Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Implement editable OOO events feature #15932

Merged
merged 29 commits into from
Aug 29, 2024
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
2b39a67
feat: Implement edit ooo feature
Souptik2001 Jul 26, 2024
b109791
Merge branch 'main' into feature-15828-edit-ooo-events
Souptik2001 Jul 26, 2024
07a0e74
refactor: Refactor button text and gap between buttons
Souptik2001 Jul 26, 2024
b737ad1
fix: Fix entry create and edit edge cases
Souptik2001 Jul 27, 2024
410bdc0
chore: Change "edit ooo event" modal title
Souptik2001 Jul 27, 2024
9625ab3
chore: Add tooltips to entry eidt and delete buttons
Souptik2001 Jul 27, 2024
e8f0dc3
feat: Reset form on form close event
Souptik2001 Jul 27, 2024
cbbbad0
refactor: Refactor reset-form function
Souptik2001 Jul 27, 2024
64127f8
chore: Rename create or edit OOO as suggested
Souptik2001 Jul 28, 2024
825bb46
refactor: Refactor component and function names
Souptik2001 Jul 28, 2024
1cd1df1
feat: Improve ooo events email notifications
Souptik2001 Jul 28, 2024
0862e92
Merge branch 'main' into feature-15828-edit-ooo-events
Souptik2001 Jul 29, 2024
909498e
fix: Fix email templates
Souptik2001 Jul 29, 2024
27d2100
feat: Add e2e test for ooo event edit functionality
Souptik2001 Jul 29, 2024
f0c7a7f
refactor: Create separate test for edit ooo event
Souptik2001 Jul 29, 2024
a3bf1e9
fix: Remove extra user from redirect user configuration test
Souptik2001 Jul 29, 2024
d47952f
Merge branch 'main' into feature-15828-edit-ooo-events
Amit91848 Aug 6, 2024
1a68ebf
chore: code refactor
Amit91848 Aug 7, 2024
b385146
remove log
Amit91848 Aug 7, 2024
298965d
Merge branch 'main' into feature-15828-edit-ooo-events
Amit91848 Aug 7, 2024
53a41a3
chore: Update ooo-input-schema uuid data type
Souptik2001 Aug 7, 2024
95f41ce
Merge branch 'main' into feature-15828-edit-ooo-events
Souptik2001 Aug 7, 2024
92777c7
Merge branch 'main' into feature-15828-edit-ooo-events
Souptik2001 Aug 17, 2024
5c8c10c
fix: Address feedbacks
Souptik2001 Aug 17, 2024
57b7b59
Merge branch 'main' into feature-15828-edit-ooo-events
Amit91848 Aug 29, 2024
acea1ab
chore: code splitting and refactor
Amit91848 Aug 29, 2024
470e01c
Merge branch 'main' into feature-15828-edit-ooo-events
anikdhabal Aug 29, 2024
ea2f347
fix: e2e
Amit91848 Aug 29, 2024
c9f6ec3
Merge branch 'main' into feature-15828-edit-ooo-events
Amit91848 Aug 29, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
259 changes: 169 additions & 90 deletions apps/web/pages/settings/my-account/out-of-office/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
TableRow,
TextArea,
UpgradeTeamsBadge,
Tooltip,
} from "@calcom/ui";

import PageWrapper from "@components/PageWrapper";
Expand All @@ -39,99 +40,107 @@ export type BookingRedirectForm = {
toTeamUserId: number | null;
reasonId: number;
notes?: string;
uuid?: string | null;
};

const CreateOutOfOfficeEntryModal = ({
const CreateOrEditOutOfOfficeEntryModal = ({
openModal,
closeModal,
currentlyEditingOutOfOfficeEntry,
anikdhabal marked this conversation as resolved.
Show resolved Hide resolved
}: {
openModal: boolean;
closeModal: () => void;
currentlyEditingOutOfOfficeEntry: BookingRedirectForm | null;
}) => {
const { t } = useLocale();
const utils = trpc.useUtils();

const [selectedReason, setSelectedReason] = useState<{ label: string; value: number } | null>(null);
const [profileRedirect, setProfileRedirect] = useState(false);
const [selectedMember, setSelectedMember] = useState<{ label: string; value: number | null } | null>(null);

const [dateRange] = useState<{ startDate: Date; endDate: Date }>({
startDate: dayjs().startOf("d").toDate(),
endDate: dayjs().add(1, "d").endOf("d").toDate(),
});

const { hasTeamPlan } = useHasTeamPlan();
const { data: listMembers } = trpc.viewer.teams.listMembers.useQuery({});
const me = useMeQuery();
const memberListOptions: {
value: number | null;
value: number;
label: string;
}[] =
listMembers
?.filter((member) => me?.data?.id !== member.id)
.map((member) => ({
value: member.id || null,
value: member.id,
label: member.name || "",
})) || [];

const { handleSubmit, setValue, control, register } = useForm<BookingRedirectForm>({
defaultValues: {
dateRange: {
startDate: dateRange.startDate,
endDate: dateRange.endDate,
},
offset: dayjs().utcOffset(),
toTeamUserId: null,
reasonId: 1,
},
type Option = { value: number; label: string };

const { data: outOfOfficeReasonList } = trpc.viewer.outOfOfficeReasonList.useQuery();

const reasonList = (outOfOfficeReasonList || []).map((reason) => ({
label: `${reason.emoji} ${reason.userId === null ? t(reason.reason) : reason.reason}`,
value: reason.id,
}));

const [profileRedirect, setProfileRedirect] = useState(!!currentlyEditingOutOfOfficeEntry?.toTeamUserId);

const { hasTeamPlan } = useHasTeamPlan();

const { handleSubmit, setValue, control, register, reset } = useForm<BookingRedirectForm>({
Amit91848 marked this conversation as resolved.
Show resolved Hide resolved
defaultValues: currentlyEditingOutOfOfficeEntry
? currentlyEditingOutOfOfficeEntry
: {
dateRange: {
startDate: dayjs().startOf("d").toDate(),
endDate: dayjs().add(1, "d").endOf("d").toDate(),
},
offset: dayjs().utcOffset(),
toTeamUserId: null,
reasonId: 1,
},
});

const createOutOfOfficeEntry = trpc.viewer.outOfOfficeCreate.useMutation({
const resetForm = () => {
reset();
setProfileRedirect(false);
};
anikdhabal marked this conversation as resolved.
Show resolved Hide resolved

const createOrEditOutOfOfficeEntry = trpc.viewer.outOfOfficeCreateOrUpdate.useMutation({
onSuccess: () => {
showToast(t("success_entry_created"), "success");
showToast(
currentlyEditingOutOfOfficeEntry
? t("success_edited_entry_out_of_office")
: t("success_entry_created"),
"success"
);
utils.viewer.outOfOfficeEntriesList.invalidate();
setProfileRedirect(false);
resetForm();
anikdhabal marked this conversation as resolved.
Show resolved Hide resolved
closeModal();
},
onError: (error) => {
showToast(t(error.message), "error");
},
});

const { data: outOfOfficeReasonList } = trpc.viewer.outOfOfficeReasonList.useQuery();

const reasonList = [
...(outOfOfficeReasonList || []).map((reason) => ({
label: `${reason.emoji} ${reason.userId === null ? t(reason.reason) : reason.reason}`,
value: reason.id,
})),
];

return (
<Dialog open={openModal}>
<DialogContent
onOpenAutoFocus={(event) => {
event.preventDefault();
}}>
<form
id="create-ooo-form"
id="create-or-edit-ooo-form"
className="h-full"
onSubmit={handleSubmit((data) => {
createOutOfOfficeEntry.mutate(data);
setValue("toTeamUserId", null);
setValue("notes", "");
setSelectedReason(null);
setSelectedMember(null);
createOrEditOutOfOfficeEntry.mutate(data);
})}>
<div className="px-1">
<DialogHeader title={t("create_an_out_of_office")} />
<DialogHeader
title={
currentlyEditingOutOfOfficeEntry ? t("edit_an_out_of_office") : t("create_an_out_of_office")
}
/>
<div>
<p className="text-emphasis mb-1 block text-sm font-medium capitalize">{t("dates")}</p>
<div>
<Controller
name="dateRange"
control={control}
defaultValue={dateRange}
render={({ field: { onChange, value } }) => (
<DateRangePicker
dates={{ startDate: value.startDate, endDate: value.endDate }}
Expand All @@ -148,19 +157,24 @@ const CreateOutOfOfficeEntryModal = ({
<div className="mt-4 w-full">
<div className="">
<p className="text-emphasis block text-sm font-medium">{t("reason")}</p>
<Select
className="mb-0 mt-1 text-white"
name="reason"
data-testid="reason_select"
value={selectedReason}
placeholder={t("ooo_select_reason")}
options={reasonList}
onChange={(selectedOption) => {
if (selectedOption?.value) {
setSelectedReason(selectedOption);
setValue("reasonId", selectedOption?.value);
}
}}
<Controller
control={control}
name="reasonId"
render={({ field: { onChange, value } }) => (
<Select<Option>
className="mb-0 mt-1 text-white"
name="reason"
data-testid="reason_select"
value={reasonList.find((reason) => reason.value === value)}
placeholder={t("ooo_select_reason")}
options={reasonList}
onChange={(selectedOption) => {
if (selectedOption?.value) {
onChange(selectedOption.value);
}
}}
/>
)}
/>
</div>
</div>
Expand Down Expand Up @@ -188,6 +202,9 @@ const CreateOutOfOfficeEntryModal = ({
id="profile-redirect-switch"
onCheckedChange={(state) => {
setProfileRedirect(state);
if (state === false) {
setValue("toTeamUserId", null);
}
anikdhabal marked this conversation as resolved.
Show resolved Hide resolved
}}
label={hasTeamPlan ? t("redirect_team_enabled") : t("redirect_team_disabled")}
/>
Expand All @@ -202,20 +219,24 @@ const CreateOutOfOfficeEntryModal = ({
<div className="mt-4">
<div className="h-16">
<p className="text-emphasis block text-sm font-medium">{t("team_member")}</p>
<Select
className="mt-1 h-4 text-white"
name="toTeamUsername"
data-testid="team_username_select"
value={selectedMember}
placeholder={t("select_team_member")}
isSearchable
options={memberListOptions}
onChange={(selectedOption) => {
if (selectedOption?.value) {
setSelectedMember(selectedOption);
setValue("toTeamUserId", selectedOption?.value);
}
}}
<Controller
control={control}
name="toTeamUserId"
render={({ field: { onChange, value } }) => (
<Select<Option>
name="toTeamUsername"
data-testid="team_username_select"
value={memberListOptions.find((member) => member.value === value)}
placeholder={t("select_team_member")}
isSearchable
options={memberListOptions}
onChange={(selectedOption) => {
if (selectedOption?.value) {
onChange(selectedOption.value);
}
}}
/>
)}
/>
</div>
</div>
Expand All @@ -225,16 +246,23 @@ const CreateOutOfOfficeEntryModal = ({
</form>
<DialogFooter showDivider noSticky>
<div className="flex">
<Button color="minimal" type="button" onClick={() => closeModal()} className="mr-1">
<Button
color="minimal"
type="button"
onClick={() => {
resetForm();
anikdhabal marked this conversation as resolved.
Show resolved Hide resolved
closeModal();
}}
className="mr-1">
{t("cancel")}
</Button>
<Button
form="create-ooo-form"
form="create-or-edit-ooo-form"
color="primary"
type="submit"
disabled={createOutOfOfficeEntry.isPending}
data-testid="create-entry-ooo-redirect">
{t("create")}
disabled={createOrEditOutOfOfficeEntry.isPending}
data-testid="create-or-edit-entry-ooo-redirect">
{currentlyEditingOutOfOfficeEntry ? t("save") : t("create")}
</Button>
</div>
</DialogFooter>
Expand All @@ -243,7 +271,11 @@ const CreateOutOfOfficeEntryModal = ({
);
};

const OutOfOfficeEntriesList = () => {
const OutOfOfficeEntriesList = ({
editOutOfOfficeEntry,
}: {
editOutOfOfficeEntry: (entry: BookingRedirectForm) => void;
}) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const { data, isPending } = trpc.viewer.outOfOfficeEntriesList.useQuery();
Expand Down Expand Up @@ -318,23 +350,53 @@ const OutOfOfficeEntriesList = () => {
{item.notes && (
<p className="px-2">
<span className="text-subtle">{t("notes")}: </span>
{item.notes}
<span data-testid={`ooo-entry-note-${item.toUser?.username || "n-a"}`}>
{item.notes}
</span>
anikdhabal marked this conversation as resolved.
Show resolved Hide resolved
</p>
)}
</div>
</div>

<Button
className="self-center rounded-lg border"
type="button"
color="minimal"
variant="icon"
disabled={deleteOutOfOfficeEntryMutation.isPending}
StartIcon="trash-2"
onClick={() => {
deleteOutOfOfficeEntryMutation.mutate({ outOfOfficeUid: item.uuid });
}}
/>
<div className="flex flex-row items-center gap-x-2">
<Tooltip content={t("edit") as string}>
anikdhabal marked this conversation as resolved.
Show resolved Hide resolved
<Button
className="self-center rounded-lg border"
type="button"
color="minimal"
variant="icon"
StartIcon="pencil"
onClick={() => {
const outOfOfficeEntryData: BookingRedirectForm = {
uuid: item.uuid,
dateRange: {
startDate: dayjs(item.start).startOf("d").toDate(),
endDate: dayjs(item.end).subtract(1, "d").toDate(),
},
offset: dayjs().utcOffset(),
toTeamUserId: item.toUserId,
reasonId: item.reason?.id ?? 1,
notes: item.notes ?? undefined,
};
editOutOfOfficeEntry(outOfOfficeEntryData);
}}
data-testid={`ooo-edit-${item.toUser?.username || "n-a"}`}
anikdhabal marked this conversation as resolved.
Show resolved Hide resolved
/>
</Tooltip>
<Tooltip content={t("delete") as string}>
anikdhabal marked this conversation as resolved.
Show resolved Hide resolved
<Button
className="self-center rounded-lg border"
type="button"
color="minimal"
variant="icon"
disabled={deleteOutOfOfficeEntryMutation.isPending}
StartIcon="trash-2"
onClick={() => {
deleteOutOfOfficeEntryMutation.mutate({ outOfOfficeUid: item.uuid });
}}
/>
</Tooltip>
</div>
</TableCell>
</TableRow>
))}
Expand Down Expand Up @@ -375,6 +437,14 @@ const OutOfOfficePage = () => {
}, [openModalOnStart]);

const [openModal, setOpenModal] = useState(false);
const [currentlyEditingOutOfOfficeEntry, setCurrentlyEditingOutOfOfficeEntry] =
useState<BookingRedirectForm | null>(null);

const editOutOfOfficeEntry = (entry: BookingRedirectForm) => {
setCurrentlyEditingOutOfOfficeEntry(entry);
setOpenModal(true);
};

return (
<>
<Meta
Expand All @@ -391,8 +461,17 @@ const OutOfOfficePage = () => {
</Button>
}
/>
<CreateOutOfOfficeEntryModal openModal={openModal} closeModal={() => setOpenModal(false)} />
<OutOfOfficeEntriesList />
{openModal && (
<CreateOrEditOutOfOfficeEntryModal
openModal={openModal}
closeModal={() => {
setOpenModal(false);
setCurrentlyEditingOutOfOfficeEntry(null);
}}
currentlyEditingOutOfOfficeEntry={currentlyEditingOutOfOfficeEntry}
/>
)}
<OutOfOfficeEntriesList editOutOfOfficeEntry={editOutOfOfficeEntry} />
</>
);
};
Expand Down
Loading
Loading