feat: Add public OOO notes display on booking pages (#25471)
* feat: ooo message on booking page * make ooo days selectable even when no redirect booking * handle long notes * remove unused i18n key * Private notes stay private on the server, No accidental data leaks through client-side payloads * address cubics comments * fix: replace toggle with checkbox for OOO note visibility - Replace Switch with Checkbox for "show note publicly" option - Remove "OOO Message:" prefix from displayed notes on booking page - Update i18n text to "Show note on public booking page" - Remove unused ooo_message i18n key * fix the accessibility issue by using proper htmlFor and id association * only allow selecting OOO dates when the note is public
This commit is contained in:
@@ -2994,6 +2994,7 @@
|
||||
"ooo_slots_returning": "<0>{{displayName}}</0> can take their meetings while they are away.",
|
||||
"ooo_slots_book_with": "Book {{displayName}}",
|
||||
"ooo_select_reason": "Select reason",
|
||||
"show_note_publicly_description": "Show note on public booking page",
|
||||
"create_an_out_of_office": "Go Out of Office",
|
||||
"edit_an_out_of_office": "Edit Out of Office Entry",
|
||||
"submit_feedback": "Submit Feedback",
|
||||
|
||||
@@ -14,6 +14,7 @@ export const selectOOOEntries = {
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
notes: true,
|
||||
showNotePublicly: true,
|
||||
reason: {
|
||||
select: {
|
||||
reason: true,
|
||||
|
||||
@@ -77,7 +77,7 @@ export type GetUserAvailabilityInitialData = {
|
||||
seatsReferences: number;
|
||||
};
|
||||
})[];
|
||||
outOfOfficeDays?: (Pick<OutOfOfficeEntry, "id" | "start" | "end"> & {
|
||||
outOfOfficeDays?: (Pick<OutOfOfficeEntry, "id" | "start" | "end" | "notes" | "showNotePublicly"> & {
|
||||
user: Pick<User, "id" | "name">;
|
||||
toUser: Pick<User, "id" | "username" | "name"> | null;
|
||||
reason: Pick<OutOfOfficeReason, "id" | "emoji" | "reason"> | null;
|
||||
@@ -144,6 +144,8 @@ export interface IOutOfOfficeData {
|
||||
toUser?: IToUser | null;
|
||||
reason?: string | null;
|
||||
emoji?: string | null;
|
||||
notes?: string | null;
|
||||
showNotePublicly?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -615,40 +617,45 @@ export class UserAvailabilityService {
|
||||
return {};
|
||||
}
|
||||
|
||||
return outOfOfficeDays.reduce((acc: IOutOfOfficeData, { start, end, toUser, user, reason }) => {
|
||||
// here we should use startDate or today if start is before today
|
||||
// consider timezone in start and end date range
|
||||
const startDateRange = dayjs(start).utc().isBefore(dayjs().startOf("day").utc())
|
||||
? dayjs().utc().startOf("day")
|
||||
: dayjs(start).utc().startOf("day");
|
||||
return outOfOfficeDays.reduce(
|
||||
(acc: IOutOfOfficeData, { start, end, toUser, user, reason, notes, showNotePublicly }) => {
|
||||
// here we should use startDate or today if start is before today
|
||||
// consider timezone in start and end date range
|
||||
const startDateRange = dayjs(start).utc().isBefore(dayjs().startOf("day").utc())
|
||||
? dayjs().utc().startOf("day")
|
||||
: dayjs(start).utc().startOf("day");
|
||||
|
||||
// get number of day in the week and see if it's on the availability
|
||||
const flattenDays = Array.from(new Set(availability.flatMap((a) => ("days" in a ? a.days : [])))).sort(
|
||||
(a, b) => a - b
|
||||
);
|
||||
// get number of day in the week and see if it's on the availability
|
||||
const flattenDays = Array.from(
|
||||
new Set(availability.flatMap((a) => ("days" in a ? a.days : [])))
|
||||
).sort((a, b) => a - b);
|
||||
|
||||
const endDateRange = dayjs(end).utc().endOf("day");
|
||||
const endDateRange = dayjs(end).utc().endOf("day");
|
||||
|
||||
for (let date = startDateRange; date.isBefore(endDateRange); date = date.add(1, "day")) {
|
||||
const dayNumberOnWeek = date.day();
|
||||
for (let date = startDateRange; date.isBefore(endDateRange); date = date.add(1, "day")) {
|
||||
const dayNumberOnWeek = date.day();
|
||||
|
||||
if (!flattenDays?.includes(dayNumberOnWeek)) {
|
||||
continue; // Skip to the next iteration if day not found in flattenDays
|
||||
if (!flattenDays?.includes(dayNumberOnWeek)) {
|
||||
continue; // Skip to the next iteration if day not found in flattenDays
|
||||
}
|
||||
|
||||
acc[date.format("YYYY-MM-DD")] = {
|
||||
// @TODO: would be good having start and end availability time here, but for now should be good
|
||||
// you can obtain that from user availability defined outside of here
|
||||
fromUser: { id: user.id, displayName: user.name },
|
||||
// optional chaining destructuring toUser
|
||||
toUser: toUser ? { id: toUser.id, displayName: toUser.name, username: toUser.username } : null,
|
||||
reason: reason ? reason.reason : null,
|
||||
emoji: reason ? reason.emoji : null,
|
||||
notes: showNotePublicly ? notes || null : null,
|
||||
showNotePublicly: showNotePublicly ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
acc[date.format("YYYY-MM-DD")] = {
|
||||
// @TODO: would be good having start and end availability time here, but for now should be good
|
||||
// you can obtain that from user availability defined outside of here
|
||||
fromUser: { id: user.id, displayName: user.name },
|
||||
// optional chaining destructuring toUser
|
||||
toUser: toUser ? { id: toUser.id, displayName: toUser.name, username: toUser.username } : null,
|
||||
reason: reason ? reason.reason : null,
|
||||
emoji: reason ? reason.emoji : null,
|
||||
};
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
async _getUsersAvailability({ users, query, initialData }: GetUsersAvailabilityProps) {
|
||||
|
||||
@@ -191,23 +191,27 @@ export const AvailableTimeSlots = ({
|
||||
<div className="mb-3 h-8" />
|
||||
) : (
|
||||
slotsPerDay.length > 0 &&
|
||||
slotsPerDay.map((slots) => (
|
||||
<AvailableTimesHeader
|
||||
customClassNames={{
|
||||
availableTimeSlotsHeaderContainer: customClassNames?.availableTimeSlotsHeaderContainer,
|
||||
availableTimeSlotsTitle: customClassNames?.availableTimeSlotsTitle,
|
||||
availableTimeSlotsTimeFormatToggle: customClassNames?.availableTimeSlotsTimeFormatToggle,
|
||||
}}
|
||||
key={slots.date}
|
||||
date={dayjs(slots.date)}
|
||||
showTimeFormatToggle={!isColumnView}
|
||||
availableMonth={
|
||||
dayjs(selectedDate).format("MM") !== dayjs(slots.date).format("MM")
|
||||
? dayjs(slots.date).format("MMM")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))
|
||||
slotsPerDay.map((slots) => {
|
||||
// Check if this day is OOO - since OOO is date-level, just check the first slot
|
||||
const isOOODay = slots.slots.length > 0 && slots.slots[0]?.away;
|
||||
return (
|
||||
<AvailableTimesHeader
|
||||
customClassNames={{
|
||||
availableTimeSlotsHeaderContainer: customClassNames?.availableTimeSlotsHeaderContainer,
|
||||
availableTimeSlotsTitle: customClassNames?.availableTimeSlotsTitle,
|
||||
availableTimeSlotsTimeFormatToggle: customClassNames?.availableTimeSlotsTimeFormatToggle,
|
||||
}}
|
||||
key={slots.date}
|
||||
date={dayjs(slots.date)}
|
||||
showTimeFormatToggle={!isColumnView && !isOOODay}
|
||||
availableMonth={
|
||||
dayjs(selectedDate).format("MM") !== dayjs(slots.date).format("MM")
|
||||
? dayjs(slots.date).format("MMM")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -223,7 +227,7 @@ export const AvailableTimeSlots = ({
|
||||
{!isLoading &&
|
||||
slotsPerDay.length > 0 &&
|
||||
slotsPerDay.map((slots) => (
|
||||
<div key={slots.date} className="scroll-bar h-full w-full overflow-y-auto overflow-x-hidden!">
|
||||
<div key={slots.date} className="scroll-bar overflow-x-hidden! h-full w-full overflow-y-auto">
|
||||
<AvailableTimes
|
||||
className={customClassNames?.availableTimeSlotsContainer}
|
||||
customClassNames={customClassNames?.availableTimes}
|
||||
|
||||
@@ -13,18 +13,29 @@ interface IOutOfOfficeInSlotsProps {
|
||||
toUser?: IOutOfOfficeData["anyDate"]["toUser"];
|
||||
emoji?: string;
|
||||
reason?: string;
|
||||
notes?: string | null;
|
||||
showNotePublicly?: boolean;
|
||||
borderDashed?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const OutOfOfficeInSlots = (props: IOutOfOfficeInSlotsProps) => {
|
||||
const { t } = useLocale();
|
||||
const { fromUser, toUser, emoji = "🏝️", borderDashed = true, date, className } = props;
|
||||
const {
|
||||
fromUser,
|
||||
toUser,
|
||||
emoji = "🏝️",
|
||||
borderDashed = true,
|
||||
date,
|
||||
className,
|
||||
notes,
|
||||
showNotePublicly,
|
||||
} = props;
|
||||
const searchParams = useCompatSearchParams();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
if (!fromUser || !toUser) return null;
|
||||
if (!fromUser) return null;
|
||||
return (
|
||||
<div className={classNames("relative h-full pb-5", className)}>
|
||||
<div
|
||||
@@ -35,11 +46,17 @@ export const OutOfOfficeInSlots = (props: IOutOfOfficeInSlotsProps) => {
|
||||
<div className="bg-emphasis flex h-14 w-14 flex-col items-center justify-center rounded-full">
|
||||
<span className="m-auto text-center text-lg">{emoji}</span>
|
||||
</div>
|
||||
<div className="stack-y-2 text-center">
|
||||
<div className="stack-y-2 max-h-[300px] w-full overflow-y-auto text-center">
|
||||
<p className="mt-2 text-base font-bold">
|
||||
{t("ooo_user_is_ooo", { displayName: fromUser.displayName })}
|
||||
</p>
|
||||
|
||||
{notes && showNotePublicly && (
|
||||
<p className="text-subtle mt-2 max-h-[120px] overflow-y-auto break-words px-2 text-center text-sm italic">
|
||||
{notes}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{fromUser?.displayName && toUser?.displayName && (
|
||||
<p className="text-center text-sm">
|
||||
<ServerTrans
|
||||
|
||||
@@ -306,13 +306,15 @@ interface IOOOSlotProps {
|
||||
toUser?: IOutOfOfficeData["anyDate"]["toUser"];
|
||||
reason?: string;
|
||||
emoji?: string;
|
||||
notes?: string | null;
|
||||
showNotePublicly?: boolean;
|
||||
time?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const OOOSlot: React.FC<IOOOSlotProps> = (props) => {
|
||||
const isPlatform = useIsPlatform();
|
||||
const { fromUser, toUser, reason, emoji, time, className = "" } = props;
|
||||
const { fromUser, toUser, reason, emoji, notes, showNotePublicly, time, className = "" } = props;
|
||||
|
||||
if (isPlatform) return <></>;
|
||||
return (
|
||||
@@ -322,6 +324,8 @@ const OOOSlot: React.FC<IOOOSlotProps> = (props) => {
|
||||
date={dayjs(time).format("YYYY-MM-DD")}
|
||||
reason={reason}
|
||||
emoji={emoji}
|
||||
notes={notes}
|
||||
showNotePublicly={showNotePublicly}
|
||||
borderDashed
|
||||
className={className}
|
||||
/>
|
||||
|
||||
@@ -54,6 +54,7 @@ export type DatePickerProps = {
|
||||
toUser?: IToUser;
|
||||
reason?: string;
|
||||
emoji?: string;
|
||||
showNotePublicly?: boolean;
|
||||
}[]
|
||||
>;
|
||||
periodData?: PeriodData;
|
||||
@@ -271,7 +272,9 @@ const Days = ({
|
||||
const isOOOAllDay = daySlots.length > 0 && daySlots.every((slot) => slot.away);
|
||||
const away = isOOOAllDay;
|
||||
|
||||
const disabled = away ? !oooInfo?.toUser : isNextMonth ? !hasAvailableSlots : !included || excluded;
|
||||
// OOO dates are selectable only if there's a redirect user OR the note is public
|
||||
const oooIsSelectable = oooInfo?.toUser || oooInfo?.showNotePublicly;
|
||||
const disabled = away ? !oooIsSelectable : isNextMonth ? !hasAvailableSlots : !included || excluded;
|
||||
|
||||
return {
|
||||
day,
|
||||
|
||||
@@ -191,12 +191,14 @@ function buildSlotsWithDateRanges({
|
||||
toUser?: IToUser;
|
||||
reason?: string;
|
||||
emoji?: string;
|
||||
notes?: string | null;
|
||||
showNotePublicly?: boolean;
|
||||
} = {
|
||||
time: slotStartTime,
|
||||
};
|
||||
|
||||
if (dateOutOfOfficeExists) {
|
||||
const { toUser, fromUser, reason, emoji } = dateOutOfOfficeExists;
|
||||
const { toUser, fromUser, reason, emoji, notes, showNotePublicly } = dateOutOfOfficeExists;
|
||||
|
||||
slotData = {
|
||||
time: slotStartTime,
|
||||
@@ -205,6 +207,8 @@ function buildSlotsWithDateRanges({
|
||||
...(toUser && { toUser }),
|
||||
...(reason && { reason }),
|
||||
...(emoji && { emoji }),
|
||||
...(notes && showNotePublicly && { notes }),
|
||||
...(showNotePublicly !== undefined && { showNotePublicly }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import classNames from "@calcom/ui/classNames";
|
||||
import { UpgradeTeamsBadge } from "@calcom/ui/components/badge";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/components/dialog";
|
||||
import { DateRangePicker, TextArea, Input } from "@calcom/ui/components/form";
|
||||
import { DateRangePicker, TextArea, Input, Checkbox } from "@calcom/ui/components/form";
|
||||
import { Label } from "@calcom/ui/components/form";
|
||||
import { Select } from "@calcom/ui/components/form";
|
||||
import { Switch } from "@calcom/ui/components/form";
|
||||
@@ -29,6 +29,7 @@ export type BookingRedirectForm = {
|
||||
toTeamUserId: number | null;
|
||||
reasonId: number;
|
||||
notes?: string;
|
||||
showNotePublicly?: boolean;
|
||||
uuid?: string | null;
|
||||
forUserId: number | null;
|
||||
forUserName?: string;
|
||||
@@ -155,6 +156,7 @@ export const CreateOrEditOutOfOfficeEntryModal = ({
|
||||
toTeamUserId: null,
|
||||
reasonId: 1,
|
||||
forUserId: null,
|
||||
showNotePublicly: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -180,7 +182,6 @@ export const CreateOrEditOutOfOfficeEntryModal = ({
|
||||
return (
|
||||
<Dialog
|
||||
open={openModal}
|
||||
modal={false}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
closeModal();
|
||||
@@ -188,7 +189,6 @@ export const CreateOrEditOutOfOfficeEntryModal = ({
|
||||
}}>
|
||||
<DialogContent
|
||||
enableOverflow
|
||||
preventCloseOnOutsideClick
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
}}>
|
||||
@@ -338,16 +338,33 @@ export const CreateOrEditOutOfOfficeEntryModal = ({
|
||||
|
||||
{/* Notes input */}
|
||||
<div className="mt-4">
|
||||
<p className="text-emphasis block text-sm font-medium">{t("notes")}</p>
|
||||
<p className="text-emphasis text-sm font-medium">{t("notes")}</p>
|
||||
<TextArea
|
||||
data-testid="notes_input"
|
||||
className="border-subtle mt-1 h-10 w-full rounded-lg border px-2"
|
||||
className="border-subtle mt-2 h-10 w-full rounded-lg border px-2"
|
||||
placeholder={t("additional_notes")}
|
||||
{...register("notes")}
|
||||
onChange={(e) => {
|
||||
setValue("notes", e?.target.value);
|
||||
}}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="showNotePublicly"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="mt-2 flex items-center">
|
||||
<Checkbox
|
||||
id="show-note-publicly"
|
||||
data-testid="show-note-publicly-checkbox"
|
||||
checked={value ?? false}
|
||||
onCheckedChange={onChange}
|
||||
/>
|
||||
<label htmlFor="show-note-publicly" className="text-emphasis ml-2 cursor-pointer text-sm">
|
||||
{t("show_note_publicly_description")}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-cal-muted my-4 rounded-xl p-5">
|
||||
@@ -382,7 +399,7 @@ export const CreateOrEditOutOfOfficeEntryModal = ({
|
||||
onChange={(e) => setSearchRedirectMember(e.target.value)}
|
||||
value={searchRedirectMember}
|
||||
/>
|
||||
<div className="scroll-bar bg-default mt-2 flex h-[150px] flex-col gap-0.5 overflow-y-scroll rounded-[10px] border p-2 pl-5">
|
||||
<div className="scroll-bar bg-default mt-2 flex h-[150px] flex-col gap-0.5 overflow-y-scroll rounded-[10px] border p-1">
|
||||
{redirectToMemberListOptions
|
||||
.filter((member) => member.value !== getValues("forUserId"))
|
||||
.map((member) => (
|
||||
|
||||
@@ -58,6 +58,7 @@ interface OutOfOfficeEntry {
|
||||
userId: number;
|
||||
} | null;
|
||||
notes: string | null;
|
||||
showNotePublicly: boolean | null;
|
||||
user: { id: number; avatarUrl: string; username: string; email: string; name: string } | null;
|
||||
canEditAndDelete: boolean;
|
||||
}
|
||||
@@ -284,6 +285,7 @@ function OutOfOfficeEntriesListContent({
|
||||
toTeamUserId: item.toUserId,
|
||||
reasonId: item.reason?.id ?? 1,
|
||||
notes: item.notes ?? undefined,
|
||||
showNotePublicly: item.showNotePublicly ?? false,
|
||||
forUserId: item.user?.id || null,
|
||||
forUserName:
|
||||
item.user?.name ||
|
||||
|
||||
@@ -56,6 +56,8 @@ export class PrismaOOORepository {
|
||||
id: true,
|
||||
start: true,
|
||||
end: true,
|
||||
notes: true,
|
||||
showNotePublicly: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -123,6 +125,8 @@ export class PrismaOOORepository {
|
||||
id: true,
|
||||
start: true,
|
||||
end: true,
|
||||
notes: true,
|
||||
showNotePublicly: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."OutOfOfficeEntry" ADD COLUMN "showNotePublicly" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -1937,17 +1937,18 @@ model Avatar {
|
||||
}
|
||||
|
||||
model OutOfOfficeEntry {
|
||||
id Int @id @default(autoincrement())
|
||||
uuid String @unique
|
||||
start DateTime
|
||||
end DateTime
|
||||
notes String?
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
toUserId Int?
|
||||
toUser User? @relation(name: "toUser", fields: [toUserId], references: [id], onDelete: Cascade)
|
||||
reasonId Int?
|
||||
reason OutOfOfficeReason? @relation(fields: [reasonId], references: [id], onDelete: SetNull)
|
||||
id Int @id @default(autoincrement())
|
||||
uuid String @unique
|
||||
start DateTime
|
||||
end DateTime
|
||||
notes String?
|
||||
showNotePublicly Boolean @default(false)
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
toUserId Int?
|
||||
toUser User? @relation(name: "toUser", fields: [toUserId], references: [id], onDelete: Cascade)
|
||||
reasonId Int?
|
||||
reason OutOfOfficeReason? @relation(fields: [reasonId], references: [id], onDelete: SetNull)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -179,6 +179,7 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect
|
||||
start: startTimeUtc.toISOString(),
|
||||
end: endTimeUtc.toISOString(),
|
||||
notes: input.notes,
|
||||
showNotePublicly: input.showNotePublicly ?? false,
|
||||
userId: oooUserId,
|
||||
reasonId: input.reasonId,
|
||||
toUserId: toUserId,
|
||||
@@ -189,6 +190,7 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect
|
||||
start: startTimeUtc.toISOString(),
|
||||
end: endTimeUtc.toISOString(),
|
||||
notes: input.notes,
|
||||
...(input.showNotePublicly !== undefined && { showNotePublicly: input.showNotePublicly }),
|
||||
userId: oooUserId,
|
||||
reasonId: input.reasonId,
|
||||
toUserId: toUserId ? toUserId : null,
|
||||
|
||||
@@ -12,6 +12,7 @@ export const ZOutOfOfficeInputSchema = z.object({
|
||||
toTeamUserId: z.number().nullable(),
|
||||
reasonId: z.number(),
|
||||
notes: z.string().nullable().optional(),
|
||||
showNotePublicly: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type TOutOfOfficeInputSchema = z.infer<typeof ZOutOfOfficeInputSchema>;
|
||||
|
||||
@@ -138,6 +138,7 @@ export const outOfOfficeEntriesList = async ({ ctx, input }: GetOptions) => {
|
||||
},
|
||||
},
|
||||
notes: true,
|
||||
showNotePublicly: true,
|
||||
user: fetchTeamMembersEntries
|
||||
? {
|
||||
select: {
|
||||
|
||||
Reference in New Issue
Block a user