Skip to content

Commit 26b4385

Browse files
authored
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
1 parent b220420 commit 26b4385

File tree

16 files changed

+141
-70
lines changed

16 files changed

+141
-70
lines changed

apps/web/public/static/locales/en/common.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2994,6 +2994,7 @@
29942994
"ooo_slots_returning": "<0>{{displayName}}</0> can take their meetings while they are away.",
29952995
"ooo_slots_book_with": "Book {{displayName}}",
29962996
"ooo_select_reason": "Select reason",
2997+
"show_note_publicly_description": "Show note on public booking page",
29972998
"create_an_out_of_office": "Go Out of Office",
29982999
"edit_an_out_of_office": "Edit Out of Office Entry",
29993000
"submit_feedback": "Submit Feedback",

packages/app-store/zapier/api/subscriptions/listOOOEntries.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export const selectOOOEntries = {
1414
createdAt: true,
1515
updatedAt: true,
1616
notes: true,
17+
showNotePublicly: true,
1718
reason: {
1819
select: {
1920
reason: true,

packages/features/availability/lib/getUserAvailability.ts

Lines changed: 36 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export type GetUserAvailabilityInitialData = {
7777
seatsReferences: number;
7878
};
7979
})[];
80-
outOfOfficeDays?: (Pick<OutOfOfficeEntry, "id" | "start" | "end"> & {
80+
outOfOfficeDays?: (Pick<OutOfOfficeEntry, "id" | "start" | "end" | "notes" | "showNotePublicly"> & {
8181
user: Pick<User, "id" | "name">;
8282
toUser: Pick<User, "id" | "username" | "name"> | null;
8383
reason: Pick<OutOfOfficeReason, "id" | "emoji" | "reason"> | null;
@@ -144,6 +144,8 @@ export interface IOutOfOfficeData {
144144
toUser?: IToUser | null;
145145
reason?: string | null;
146146
emoji?: string | null;
147+
notes?: string | null;
148+
showNotePublicly?: boolean;
147149
};
148150
}
149151

@@ -615,40 +617,45 @@ export class UserAvailabilityService {
615617
return {};
616618
}
617619

618-
return outOfOfficeDays.reduce((acc: IOutOfOfficeData, { start, end, toUser, user, reason }) => {
619-
// here we should use startDate or today if start is before today
620-
// consider timezone in start and end date range
621-
const startDateRange = dayjs(start).utc().isBefore(dayjs().startOf("day").utc())
622-
? dayjs().utc().startOf("day")
623-
: dayjs(start).utc().startOf("day");
620+
return outOfOfficeDays.reduce(
621+
(acc: IOutOfOfficeData, { start, end, toUser, user, reason, notes, showNotePublicly }) => {
622+
// here we should use startDate or today if start is before today
623+
// consider timezone in start and end date range
624+
const startDateRange = dayjs(start).utc().isBefore(dayjs().startOf("day").utc())
625+
? dayjs().utc().startOf("day")
626+
: dayjs(start).utc().startOf("day");
624627

625-
// get number of day in the week and see if it's on the availability
626-
const flattenDays = Array.from(new Set(availability.flatMap((a) => ("days" in a ? a.days : [])))).sort(
627-
(a, b) => a - b
628-
);
628+
// get number of day in the week and see if it's on the availability
629+
const flattenDays = Array.from(
630+
new Set(availability.flatMap((a) => ("days" in a ? a.days : [])))
631+
).sort((a, b) => a - b);
629632

630-
const endDateRange = dayjs(end).utc().endOf("day");
633+
const endDateRange = dayjs(end).utc().endOf("day");
631634

632-
for (let date = startDateRange; date.isBefore(endDateRange); date = date.add(1, "day")) {
633-
const dayNumberOnWeek = date.day();
635+
for (let date = startDateRange; date.isBefore(endDateRange); date = date.add(1, "day")) {
636+
const dayNumberOnWeek = date.day();
634637

635-
if (!flattenDays?.includes(dayNumberOnWeek)) {
636-
continue; // Skip to the next iteration if day not found in flattenDays
637-
}
638+
if (!flattenDays?.includes(dayNumberOnWeek)) {
639+
continue; // Skip to the next iteration if day not found in flattenDays
640+
}
638641

639-
acc[date.format("YYYY-MM-DD")] = {
640-
// @TODO: would be good having start and end availability time here, but for now should be good
641-
// you can obtain that from user availability defined outside of here
642-
fromUser: { id: user.id, displayName: user.name },
643-
// optional chaining destructuring toUser
644-
toUser: toUser ? { id: toUser.id, displayName: toUser.name, username: toUser.username } : null,
645-
reason: reason ? reason.reason : null,
646-
emoji: reason ? reason.emoji : null,
647-
};
648-
}
642+
acc[date.format("YYYY-MM-DD")] = {
643+
// @TODO: would be good having start and end availability time here, but for now should be good
644+
// you can obtain that from user availability defined outside of here
645+
fromUser: { id: user.id, displayName: user.name },
646+
// optional chaining destructuring toUser
647+
toUser: toUser ? { id: toUser.id, displayName: toUser.name, username: toUser.username } : null,
648+
reason: reason ? reason.reason : null,
649+
emoji: reason ? reason.emoji : null,
650+
notes: showNotePublicly ? notes || null : null,
651+
showNotePublicly: showNotePublicly ?? false,
652+
};
653+
}
649654

650-
return acc;
651-
}, {});
655+
return acc;
656+
},
657+
{}
658+
);
652659
}
653660

654661
async _getUsersAvailability({ users, query, initialData }: GetUsersAvailabilityProps) {

packages/features/bookings/Booker/components/AvailableTimeSlots.tsx

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -191,23 +191,27 @@ export const AvailableTimeSlots = ({
191191
<div className="mb-3 h-8" />
192192
) : (
193193
slotsPerDay.length > 0 &&
194-
slotsPerDay.map((slots) => (
195-
<AvailableTimesHeader
196-
customClassNames={{
197-
availableTimeSlotsHeaderContainer: customClassNames?.availableTimeSlotsHeaderContainer,
198-
availableTimeSlotsTitle: customClassNames?.availableTimeSlotsTitle,
199-
availableTimeSlotsTimeFormatToggle: customClassNames?.availableTimeSlotsTimeFormatToggle,
200-
}}
201-
key={slots.date}
202-
date={dayjs(slots.date)}
203-
showTimeFormatToggle={!isColumnView}
204-
availableMonth={
205-
dayjs(selectedDate).format("MM") !== dayjs(slots.date).format("MM")
206-
? dayjs(slots.date).format("MMM")
207-
: undefined
208-
}
209-
/>
210-
))
194+
slotsPerDay.map((slots) => {
195+
// Check if this day is OOO - since OOO is date-level, just check the first slot
196+
const isOOODay = slots.slots.length > 0 && slots.slots[0]?.away;
197+
return (
198+
<AvailableTimesHeader
199+
customClassNames={{
200+
availableTimeSlotsHeaderContainer: customClassNames?.availableTimeSlotsHeaderContainer,
201+
availableTimeSlotsTitle: customClassNames?.availableTimeSlotsTitle,
202+
availableTimeSlotsTimeFormatToggle: customClassNames?.availableTimeSlotsTimeFormatToggle,
203+
}}
204+
key={slots.date}
205+
date={dayjs(slots.date)}
206+
showTimeFormatToggle={!isColumnView && !isOOODay}
207+
availableMonth={
208+
dayjs(selectedDate).format("MM") !== dayjs(slots.date).format("MM")
209+
? dayjs(slots.date).format("MMM")
210+
: undefined
211+
}
212+
/>
213+
);
214+
})
211215
)}
212216
</div>
213217

@@ -223,7 +227,7 @@ export const AvailableTimeSlots = ({
223227
{!isLoading &&
224228
slotsPerDay.length > 0 &&
225229
slotsPerDay.map((slots) => (
226-
<div key={slots.date} className="scroll-bar h-full w-full overflow-y-auto overflow-x-hidden!">
230+
<div key={slots.date} className="scroll-bar overflow-x-hidden! h-full w-full overflow-y-auto">
227231
<AvailableTimes
228232
className={customClassNames?.availableTimeSlotsContainer}
229233
customClassNames={customClassNames?.availableTimes}

packages/features/bookings/Booker/components/OutOfOfficeInSlots.tsx

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,29 @@ interface IOutOfOfficeInSlotsProps {
1313
toUser?: IOutOfOfficeData["anyDate"]["toUser"];
1414
emoji?: string;
1515
reason?: string;
16+
notes?: string | null;
17+
showNotePublicly?: boolean;
1618
borderDashed?: boolean;
1719
className?: string;
1820
}
1921

2022
export const OutOfOfficeInSlots = (props: IOutOfOfficeInSlotsProps) => {
2123
const { t } = useLocale();
22-
const { fromUser, toUser, emoji = "🏝️", borderDashed = true, date, className } = props;
24+
const {
25+
fromUser,
26+
toUser,
27+
emoji = "🏝️",
28+
borderDashed = true,
29+
date,
30+
className,
31+
notes,
32+
showNotePublicly,
33+
} = props;
2334
const searchParams = useCompatSearchParams();
2435

2536
const router = useRouter();
2637

27-
if (!fromUser || !toUser) return null;
38+
if (!fromUser) return null;
2839
return (
2940
<div className={classNames("relative h-full pb-5", className)}>
3041
<div
@@ -35,11 +46,17 @@ export const OutOfOfficeInSlots = (props: IOutOfOfficeInSlotsProps) => {
3546
<div className="bg-emphasis flex h-14 w-14 flex-col items-center justify-center rounded-full">
3647
<span className="m-auto text-center text-lg">{emoji}</span>
3748
</div>
38-
<div className="stack-y-2 text-center">
49+
<div className="stack-y-2 max-h-[300px] w-full overflow-y-auto text-center">
3950
<p className="mt-2 text-base font-bold">
4051
{t("ooo_user_is_ooo", { displayName: fromUser.displayName })}
4152
</p>
4253

54+
{notes && showNotePublicly && (
55+
<p className="text-subtle mt-2 max-h-[120px] overflow-y-auto break-words px-2 text-center text-sm italic">
56+
{notes}
57+
</p>
58+
)}
59+
4360
{fromUser?.displayName && toUser?.displayName && (
4461
<p className="text-center text-sm">
4562
<ServerTrans

packages/features/bookings/components/AvailableTimes.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,13 +306,15 @@ interface IOOOSlotProps {
306306
toUser?: IOutOfOfficeData["anyDate"]["toUser"];
307307
reason?: string;
308308
emoji?: string;
309+
notes?: string | null;
310+
showNotePublicly?: boolean;
309311
time?: string;
310312
className?: string;
311313
}
312314

313315
const OOOSlot: React.FC<IOOOSlotProps> = (props) => {
314316
const isPlatform = useIsPlatform();
315-
const { fromUser, toUser, reason, emoji, time, className = "" } = props;
317+
const { fromUser, toUser, reason, emoji, notes, showNotePublicly, time, className = "" } = props;
316318

317319
if (isPlatform) return <></>;
318320
return (
@@ -322,6 +324,8 @@ const OOOSlot: React.FC<IOOOSlotProps> = (props) => {
322324
date={dayjs(time).format("YYYY-MM-DD")}
323325
reason={reason}
324326
emoji={emoji}
327+
notes={notes}
328+
showNotePublicly={showNotePublicly}
325329
borderDashed
326330
className={className}
327331
/>

packages/features/calendars/DatePicker.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export type DatePickerProps = {
5454
toUser?: IToUser;
5555
reason?: string;
5656
emoji?: string;
57+
showNotePublicly?: boolean;
5758
}[]
5859
>;
5960
periodData?: PeriodData;
@@ -271,7 +272,9 @@ const Days = ({
271272
const isOOOAllDay = daySlots.length > 0 && daySlots.every((slot) => slot.away);
272273
const away = isOOOAllDay;
273274

274-
const disabled = away ? !oooInfo?.toUser : isNextMonth ? !hasAvailableSlots : !included || excluded;
275+
// OOO dates are selectable only if there's a redirect user OR the note is public
276+
const oooIsSelectable = oooInfo?.toUser || oooInfo?.showNotePublicly;
277+
const disabled = away ? !oooIsSelectable : isNextMonth ? !hasAvailableSlots : !included || excluded;
275278

276279
return {
277280
day,

packages/features/schedules/lib/slots.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,12 +191,14 @@ function buildSlotsWithDateRanges({
191191
toUser?: IToUser;
192192
reason?: string;
193193
emoji?: string;
194+
notes?: string | null;
195+
showNotePublicly?: boolean;
194196
} = {
195197
time: slotStartTime,
196198
};
197199

198200
if (dateOutOfOfficeExists) {
199-
const { toUser, fromUser, reason, emoji } = dateOutOfOfficeExists;
201+
const { toUser, fromUser, reason, emoji, notes, showNotePublicly } = dateOutOfOfficeExists;
200202

201203
slotData = {
202204
time: slotStartTime,
@@ -205,6 +207,8 @@ function buildSlotsWithDateRanges({
205207
...(toUser && { toUser }),
206208
...(reason && { reason }),
207209
...(emoji && { emoji }),
210+
...(notes && showNotePublicly && { notes }),
211+
...(showNotePublicly !== undefined && { showNotePublicly }),
208212
};
209213
}
210214

packages/features/settings/outOfOffice/CreateOrEditOutOfOfficeModal.tsx

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import classNames from "@calcom/ui/classNames";
1414
import { UpgradeTeamsBadge } from "@calcom/ui/components/badge";
1515
import { Button } from "@calcom/ui/components/button";
1616
import { DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/components/dialog";
17-
import { DateRangePicker, TextArea, Input } from "@calcom/ui/components/form";
17+
import { DateRangePicker, TextArea, Input, Checkbox } from "@calcom/ui/components/form";
1818
import { Label } from "@calcom/ui/components/form";
1919
import { Select } from "@calcom/ui/components/form";
2020
import { Switch } from "@calcom/ui/components/form";
@@ -29,6 +29,7 @@ export type BookingRedirectForm = {
2929
toTeamUserId: number | null;
3030
reasonId: number;
3131
notes?: string;
32+
showNotePublicly?: boolean;
3233
uuid?: string | null;
3334
forUserId: number | null;
3435
forUserName?: string;
@@ -155,6 +156,7 @@ export const CreateOrEditOutOfOfficeEntryModal = ({
155156
toTeamUserId: null,
156157
reasonId: 1,
157158
forUserId: null,
159+
showNotePublicly: false,
158160
},
159161
});
160162

@@ -180,15 +182,13 @@ export const CreateOrEditOutOfOfficeEntryModal = ({
180182
return (
181183
<Dialog
182184
open={openModal}
183-
modal={false}
184185
onOpenChange={(open) => {
185186
if (!open) {
186187
closeModal();
187188
}
188189
}}>
189190
<DialogContent
190191
enableOverflow
191-
preventCloseOnOutsideClick
192192
onOpenAutoFocus={(event) => {
193193
event.preventDefault();
194194
}}>
@@ -338,16 +338,33 @@ export const CreateOrEditOutOfOfficeEntryModal = ({
338338

339339
{/* Notes input */}
340340
<div className="mt-4">
341-
<p className="text-emphasis block text-sm font-medium">{t("notes")}</p>
341+
<p className="text-emphasis text-sm font-medium">{t("notes")}</p>
342342
<TextArea
343343
data-testid="notes_input"
344-
className="border-subtle mt-1 h-10 w-full rounded-lg border px-2"
344+
className="border-subtle mt-2 h-10 w-full rounded-lg border px-2"
345345
placeholder={t("additional_notes")}
346346
{...register("notes")}
347347
onChange={(e) => {
348348
setValue("notes", e?.target.value);
349349
}}
350350
/>
351+
<Controller
352+
control={control}
353+
name="showNotePublicly"
354+
render={({ field: { value, onChange } }) => (
355+
<div className="mt-2 flex items-center">
356+
<Checkbox
357+
id="show-note-publicly"
358+
data-testid="show-note-publicly-checkbox"
359+
checked={value ?? false}
360+
onCheckedChange={onChange}
361+
/>
362+
<label htmlFor="show-note-publicly" className="text-emphasis ml-2 cursor-pointer text-sm">
363+
{t("show_note_publicly_description")}
364+
</label>
365+
</div>
366+
)}
367+
/>
351368
</div>
352369

353370
<div className="bg-cal-muted my-4 rounded-xl p-5">
@@ -382,7 +399,7 @@ export const CreateOrEditOutOfOfficeEntryModal = ({
382399
onChange={(e) => setSearchRedirectMember(e.target.value)}
383400
value={searchRedirectMember}
384401
/>
385-
<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">
402+
<div className="scroll-bar bg-default mt-2 flex h-[150px] flex-col gap-0.5 overflow-y-scroll rounded-[10px] border p-1">
386403
{redirectToMemberListOptions
387404
.filter((member) => member.value !== getValues("forUserId"))
388405
.map((member) => (

packages/features/settings/outOfOffice/OutOfOfficeEntriesList.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ interface OutOfOfficeEntry {
5858
userId: number;
5959
} | null;
6060
notes: string | null;
61+
showNotePublicly: boolean | null;
6162
user: { id: number; avatarUrl: string; username: string; email: string; name: string } | null;
6263
canEditAndDelete: boolean;
6364
}
@@ -284,6 +285,7 @@ function OutOfOfficeEntriesListContent({
284285
toTeamUserId: item.toUserId,
285286
reasonId: item.reason?.id ?? 1,
286287
notes: item.notes ?? undefined,
288+
showNotePublicly: item.showNotePublicly ?? false,
287289
forUserId: item.user?.id || null,
288290
forUserName:
289291
item.user?.name ||

0 commit comments

Comments
 (0)