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:
Dhairyashil Shinde
2025-12-11 11:31:59 +00:00
committed by GitHub
parent b220420630
commit 26b4385679
16 changed files with 141 additions and 70 deletions
@@ -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}
/>
+4 -1
View File
@@ -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,
+5 -1
View File
@@ -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 ||
+4
View File
@@ -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;
+12 -11
View File
@@ -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: {