Files
calendar/packages/app-store/routing-forms/components/FormActions.tsx
T
GitStart-Cal.comandGitHub e731c33235 feat: Add divider and set spacing on modals with the type=creation prop (feat-add-divider)
## What does this PR do?

<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->

- Add the prop customDividerClassName to customize the CSS Divider if needed
- Add a divider on all modals with type="creation" prop, following the [figma](https://www.figma.com/file/9MOufQNLtdkpnDucmNX10R/1.-Cal-DS?type=design&node-id=29898%3A100590&t=62LvZCZAEZm5zDdw-1) design
- Some type="creation" modals seem not to be this type, we pushed all changes with screenshots, if you want us to revert the changes on some modals we will revert

![image](https://github.com/calcom/cal.com/assets/121884634/6674016e-819d-49da-9d44-14eefb4160ee)


- Inline Embed: 
![image](https://github.com/calcom/cal.com/assets/121884634/191c94ea-257f-44ba-8c7a-3815550e36f1)

- Enable Two-Factor Authentication:
![image](https://github.com/calcom/cal.com/assets/121884634/3b9246be-52ab-4c9d-af12-eda82541e837)

-Enable Two-Factor Authentication (step 2):
![image](https://github.com/calcom/cal.com/assets/121884634/a3b7934b-fd66-441d-b9d7-c5ba2fc7fa91)

-Enable Two-Factor Authentication (step 3):
![image](https://github.com/calcom/cal.com/assets/121884634/77367ae7-0967-497a-858a-f18a91b62077)

- Disable Two-Factor Authentication:
![image](https://github.com/calcom/cal.com/assets/121884634/8fc31cc2-e3ca-4c74-8ac6-c4832f9116d9)

- Delete Account:
![image](https://github.com/calcom/cal.com/assets/121884634/5146f605-523e-4c49-aeeb-7c5dd533844d)

- Update Timezone:
![image](https://github.com/calcom/cal.com/assets/121884634/f5acb9f0-2550-4d48-8c02-5cf1b94aaf33)

- Duplicate Event Type:
![image](https://github.com/calcom/cal.com/assets/121884634/078d40a5-5e35-4d4a-8d0e-77d7b8c21d6d)

- Add a New Form:
![image](https://github.com/calcom/cal.com/assets/121884634/5b6352e0-a1c1-46e3-af18-dc84e83fdecd)

- Change Team Member Role:
![image](https://github.com/calcom/cal.com/assets/121884634/dc8b8ba1-7986-4980-ab78-6027aca350ca)

- Impersonate:
![image](https://github.com/calcom/cal.com/assets/121884634/ffd28b83-0c9b-47a1-8894-29d35add9c27)

- Bulk Update Event Types:
![image](https://github.com/calcom/cal.com/assets/121884634/ba864dcc-0c73-426e-bc49-3ac81a55d3ea)

- Connecting with MS Teams:
![image](https://github.com/calcom/cal.com/assets/121884634/d6666cbf-cc36-4fe5-90bb-9d69de26248d)

- Set a Default App Link:
![image](https://github.com/calcom/cal.com/assets/121884634/f4f7093c-839c-4ae1-a4f3-ecd266238115)

- OIDC configuration:
![image](https://github.com/calcom/cal.com/assets/121884634/a689acd7-2488-423e-a57d-5610d683d11d)

- SAML Configuration:
![image](https://github.com/calcom/cal.com/assets/121884634/61480037-1733-4ba9-8dfd-429819e7eac9)

- How to use booking questions as variables?
![image](https://github.com/calcom/cal.com/assets/121884634/2982ace4-41b0-4849-878b-b2ceabf59469)
![image](https://github.com/calcom/cal.com/assets/121884634/65888a13-d9a7-4df5-a8b7-56becc61882e)

- Invite Link Settings:
![image](https://github.com/calcom/cal.com/assets/121884634/3f81fcd1-f862-4106-a79c-70808090f055)

- Add Action:
![image](https://github.com/calcom/cal.com/assets/121884634/ec5f9490-dccc-4c8e-bc8e-b48f8a29144b)

- Edit Keys:
![245192220-c669e6fe-01ba-4a66-9b03-96ab4e8f1809](https://github.com/calcom/cal.com/assets/121884634/bfff69b3-831a-4a5c-9835-0569109b8386)

- Create an Api Key:
![image](https://github.com/calcom/cal.com/assets/121884634/fbdf7494-6da4-49da-ba41-5f9a6393bb72)

- API key created successfully:
![image](https://github.com/calcom/cal.com/assets/121884634/81e6a5c2-5d9d-40ad-a78e-ed1ab2128a26)

- Invite Team Member:
![image](https://github.com/calcom/cal.com/assets/121884634/9e4ceea9-f708-4958-a6d7-e00304beacbf)

- Add a Question:
![image](https://github.com/calcom/cal.com/assets/121884634/8329c593-8203-484e-a4dd-c9bd5210d8db)

- Add a new event type:
![image](https://github.com/calcom/cal.com/assets/121884634/19a72734-aa79-4828-81bb-f534d46a5fc5)
2023-06-29 08:47:16 +01:00

522 lines
15 KiB
TypeScript

import type { App_RoutingForms_Form } from "@prisma/client";
import type { NextRouter } from "next/router";
import { useRouter } from "next/router";
import { createContext, forwardRef, useContext, useState } from "react";
import { useForm } from "react-hook-form";
import { Controller } from "react-hook-form";
import { v4 as uuidv4 } from "uuid";
import { z } from "zod";
import { classNames } from "@calcom/lib";
import { CAL_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { ButtonProps } from "@calcom/ui";
import {
Button,
ConfirmationDialogContent,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
Dropdown,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Form,
showToast,
Switch,
TextAreaField,
TextField,
SettingsToggle,
} from "@calcom/ui";
import { MoreHorizontal } from "@calcom/ui/components/icon";
import { EmbedButton, EmbedDialog } from "@components/Embed";
import getFieldIdentifier from "../lib/getFieldIdentifier";
import type { SerializableForm } from "../types/types";
type RoutingForm = SerializableForm<App_RoutingForms_Form>;
const newFormModalQuerySchema = z.object({
action: z.literal("new").or(z.literal("duplicate")),
target: z.string().optional(),
});
const openModal = (router: NextRouter, option: z.infer<typeof newFormModalQuerySchema>) => {
const query = {
...router.query,
dialog: "new-form",
...option,
};
router.push(
{
pathname: router.pathname,
query,
},
undefined,
{ shallow: true }
);
};
function NewFormDialog({ appUrl }: { appUrl: string }) {
const { t } = useLocale();
const router = useRouter();
const utils = trpc.useContext();
const mutation = trpc.viewer.appRoutingForms.formMutation.useMutation({
onSuccess: (_data, variables) => {
router.push(`${appUrl}/form-edit/${variables.id}`);
},
onError: (err) => {
showToast(err.message || t("something_went_wrong"), "error");
},
onSettled: () => {
utils.viewer.appRoutingForms.forms.invalidate();
},
});
const hookForm = useForm<{
name: string;
description: string;
shouldConnect: boolean;
}>();
const { action, target } = router.query as z.infer<typeof newFormModalQuerySchema>;
const formToDuplicate = action === "duplicate" ? target : null;
const teamId = action === "new" ? Number(target) : null;
const { register } = hookForm;
return (
<Dialog name="new-form" clearQueryParamsOnClose={["target", "action"]}>
<DialogContent className="overflow-y-auto">
<div className="mb-1">
<h3
className="text-emphasis !font-cal text-semibold leading-20 text-xl font-medium"
id="modal-title">
{teamId ? t("add_new_team_form") : t("add_new_form")}
</h3>
<div>
<p className="text-subtle text-sm">{t("form_description")}</p>
</div>
</div>
<Form
form={hookForm}
handleSubmit={(values) => {
const formId = uuidv4();
mutation.mutate({
id: formId,
...values,
addFallback: true,
teamId,
duplicateFrom: formToDuplicate,
});
}}>
<div className="mt-3 space-y-5">
<TextField label={t("title")} required placeholder={t("a_routing_form")} {...register("name")} />
<div className="mb-5">
<TextAreaField
id="description"
label={t("description")}
{...register("description")}
data-testid="description"
placeholder={t("form_description_placeholder")}
/>
</div>
{action === "duplicate" && (
<Controller
name="shouldConnect"
render={({ field: { value, onChange } }) => {
return (
<SettingsToggle
title={t("keep_me_connected_with_form")}
description={t("fields_in_form_duplicated")}
checked={value}
onCheckedChange={(checked) => {
onChange(checked);
}}
/>
);
}}
/>
)}
</div>
<DialogFooter showDivider className="mt-12 flex flex-row-reverse gap-x-2">
<DialogClose />
<Button loading={mutation.isLoading} data-testid="add-form" type="submit">
{t("continue")}
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
}
const dropdownCtx = createContext<{ dropdown: boolean }>({ dropdown: false });
export const FormActionsDropdown = ({
children,
disabled,
}: {
disabled?: boolean;
children: React.ReactNode;
}) => {
return (
<dropdownCtx.Provider value={{ dropdown: true }}>
<Dropdown>
<DropdownMenuTrigger disabled={disabled} data-testid="form-dropdown" asChild>
<Button
type="button"
variant="icon"
color="secondary"
className={classNames("radix-state-open:rounded-r-md", disabled && "opacity-30")}
StartIcon={MoreHorizontal}
/>
</DropdownMenuTrigger>
<DropdownMenuContent>{children}</DropdownMenuContent>
</Dropdown>
</dropdownCtx.Provider>
);
};
function Dialogs({
appUrl,
deleteDialogOpen,
setDeleteDialogOpen,
deleteDialogFormId,
}: {
appUrl: string;
deleteDialogOpen: boolean;
setDeleteDialogOpen: (open: boolean) => void;
deleteDialogFormId: string | null;
}) {
const utils = trpc.useContext();
const router = useRouter();
const { t } = useLocale();
const deleteMutation = trpc.viewer.appRoutingForms.deleteForm.useMutation({
onMutate: async ({ id: formId }) => {
await utils.viewer.appRoutingForms.forms.cancel();
const previousValue = utils.viewer.appRoutingForms.forms.getData();
if (previousValue) {
const filtered = previousValue.filtered.filter(({ form: { id } }) => id !== formId);
utils.viewer.appRoutingForms.forms.setData(
{},
{
...previousValue,
filtered,
}
);
}
return { previousValue };
},
onSuccess: () => {
showToast(t("form_deleted"), "success");
setDeleteDialogOpen(false);
},
onSettled: () => {
utils.viewer.appRoutingForms.forms.invalidate();
setDeleteDialogOpen(false);
},
onError: (err, newTodo, context) => {
if (context?.previousValue) {
utils.viewer.appRoutingForms.forms.setData({}, context.previousValue);
}
showToast(err.message || t("something_went_wrong"), "error");
},
});
return (
<div id="form-dialogs">
<EmbedDialog />
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<ConfirmationDialogContent
isLoading={deleteMutation.isLoading}
variety="danger"
title={t("delete_form")}
confirmBtnText={t("delete_form_action")}
loadingText={t("delete_form_action")}
onConfirm={(e) => {
if (!deleteDialogFormId) {
return;
}
e.preventDefault();
deleteMutation.mutate({
id: deleteDialogFormId,
});
}}>
<ul className="list-disc pl-3">
<li> {t("delete_form_confirmation")}</li>
<li> {t("delete_form_confirmation_2")}</li>
</ul>
</ConfirmationDialogContent>
</Dialog>
<NewFormDialog appUrl={appUrl} />
</div>
);
}
const actionsCtx = createContext({
appUrl: "",
_delete: {
// eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars
onAction: (_arg: { routingForm: RoutingForm | null }) => {},
isLoading: false,
},
toggle: {
// eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars
onAction: (_arg: { routingForm: RoutingForm | null; checked: boolean }) => {},
isLoading: false,
},
});
export function FormActionsProvider({ appUrl, children }: { appUrl: string; children: React.ReactNode }) {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteDialogFormId, setDeleteDialogFormId] = useState<string | null>(null);
const { t } = useLocale();
const utils = trpc.useContext();
const toggleMutation = trpc.viewer.appRoutingForms.formMutation.useMutation({
onMutate: async ({ id: formId, disabled }) => {
await utils.viewer.appRoutingForms.forms.cancel();
const previousValue = utils.viewer.appRoutingForms.forms.getData();
if (previousValue) {
const formIndex = previousValue.filtered.findIndex(({ form: { id } }) => id === formId);
const previousListOfForms = [...previousValue.filtered];
if (formIndex !== -1 && previousListOfForms[formIndex] && disabled !== undefined) {
previousListOfForms[formIndex].form.disabled = disabled;
}
utils.viewer.appRoutingForms.forms.setData(
{},
{
filtered: previousListOfForms,
totalCount: previousValue.totalCount,
}
);
}
return { previousValue };
},
onSuccess: () => {
showToast(t("form_updated_successfully"), "success");
},
onSettled: (routingForm) => {
utils.viewer.appRoutingForms.forms.invalidate();
if (routingForm) {
utils.viewer.appRoutingForms.formQuery.invalidate({
id: routingForm.id,
});
}
},
onError: (err, value, context) => {
if (context?.previousValue) {
utils.viewer.appRoutingForms.forms.setData({}, context.previousValue);
}
showToast(err.message || t("something_went_wrong"), "error");
},
});
return (
<>
<actionsCtx.Provider
value={{
appUrl,
_delete: {
onAction: ({ routingForm }) => {
if (!routingForm) {
return;
}
setDeleteDialogOpen(true);
setDeleteDialogFormId(routingForm.id);
},
isLoading: false,
},
toggle: {
onAction: ({ routingForm, checked }) => {
if (!routingForm) {
return;
}
toggleMutation.mutate({
...routingForm,
disabled: !checked,
});
},
isLoading: toggleMutation.isLoading,
},
}}>
{children}
</actionsCtx.Provider>
<Dialogs
appUrl={appUrl}
deleteDialogFormId={deleteDialogFormId}
deleteDialogOpen={deleteDialogOpen}
setDeleteDialogOpen={setDeleteDialogOpen}
/>
</>
);
}
type FormActionType =
| "preview"
| "edit"
| "copyLink"
| "toggle"
| "_delete"
| "embed"
| "duplicate"
| "download"
| "copyRedirectUrl"
| "create";
type FormActionProps<T> = {
routingForm: RoutingForm | null;
as?: T;
label?: string;
//TODO: Provide types here
action: FormActionType;
children?: React.ReactNode;
render?: (props: {
routingForm: RoutingForm | null;
className?: string;
label?: string;
disabled?: boolean | null | undefined;
}) => JSX.Element;
extraClassNames?: string;
} & ButtonProps;
export const FormAction = forwardRef(function FormAction<T extends typeof Button>(
props: FormActionProps<T>,
forwardedRef: React.ForwardedRef<HTMLAnchorElement | HTMLButtonElement>
) {
const {
action: actionName,
routingForm,
children,
as: asFromElement,
extraClassNames,
...additionalProps
} = props;
const { appUrl, _delete, toggle } = useContext(actionsCtx);
const dropdownCtxValue = useContext(dropdownCtx);
const dropdown = dropdownCtxValue?.dropdown;
const embedLink = `forms/${routingForm?.id}`;
const formLink = `${CAL_URL}/${embedLink}`;
let redirectUrl = `${CAL_URL}/router?form=${routingForm?.id}`;
routingForm?.fields?.forEach((field) => {
redirectUrl += `&${getFieldIdentifier(field)}={Recalled_Response_For_This_Field}`;
});
const { t } = useLocale();
const router = useRouter();
const actionData: Record<
FormActionType,
ButtonProps & { as?: React.ElementType; render?: FormActionProps<unknown>["render"] }
> = {
preview: {
href: formLink,
},
copyLink: {
onClick: () => {
showToast(t("link_copied"), "success");
navigator.clipboard.writeText(formLink);
},
},
duplicate: {
onClick: () => openModal(router, { action: "duplicate", target: routingForm?.id }),
},
embed: {
as: EmbedButton,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
embedUrl: embedLink,
},
edit: {
href: `${appUrl}/form-edit/${routingForm?.id}`,
},
download: {
href: `/api/integrations/routing-forms/responses/${routingForm?.id}`,
},
_delete: {
onClick: () => _delete.onAction({ routingForm }),
loading: _delete.isLoading,
},
create: {
onClick: () => createAction({ router, teamId: null }),
},
copyRedirectUrl: {
onClick: () => {
navigator.clipboard.writeText(redirectUrl);
showToast(t("typeform_redirect_url_copied"), "success");
},
},
toggle: {
render: ({ routingForm, label = "", disabled, ...restProps }) => {
if (!routingForm) {
return <></>;
}
return (
<div
{...restProps}
className={classNames(
"sm:hover:bg-subtle self-center rounded-md p-2 hover:bg-gray-200",
extraClassNames
)}>
<Switch
disabled={!!disabled}
checked={!routingForm.disabled}
label={label}
onCheckedChange={(checked) => toggle.onAction({ routingForm, checked })}
labelOnLeading
/>
</div>
);
},
loading: toggle.isLoading,
},
};
const { as: asFromAction, ...action } = actionData[actionName];
const as = asFromElement || asFromAction;
const actionProps = {
...action,
...(additionalProps as ButtonProps),
} as ButtonProps & { render?: FormActionProps<unknown>["render"] };
if (actionProps.render) {
return actionProps.render({
routingForm,
...additionalProps,
});
}
const Component = as || Button;
if (!dropdown) {
return (
<Component data-testid={`form-action-${actionName}`} ref={forwardedRef} {...actionProps}>
{children}
</Component>
);
}
return (
<DropdownMenuItem>
<Component
ref={forwardedRef}
{...actionProps}
className={classNames(
props.className,
"w-full transition-none",
props.color === "destructive" && "border-0"
)}>
{children}
</Component>
</DropdownMenuItem>
);
});
export const createAction = ({ router, teamId }: { router: NextRouter; teamId: number | null }) => {
openModal(router, { action: "new", target: teamId ? String(teamId) : "" });
};