* feat: update forms procedure to match getServerSideProps * feat: init react query and use prefetched data * feat: invalidate onSettled while creating a form * feat: use optimistic updates when deleting * fix: add optional chaining * feat: add server-side translation * feat: add correct translation key Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
419 lines
12 KiB
TypeScript
419 lines
12 KiB
TypeScript
import { useRouter } from "next/router";
|
|
import type { NextRouter } from "next/router";
|
|
import { useState, createContext, useContext, forwardRef } from "react";
|
|
import { useForm } 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 { Icon } from "@calcom/ui";
|
|
import ConfirmationDialogContent from "@calcom/ui/ConfirmationDialogContent";
|
|
import { Dialog, DialogClose, DialogContent } from "@calcom/ui/Dialog";
|
|
import {
|
|
Button,
|
|
ButtonProps,
|
|
Dropdown,
|
|
DropdownMenuItem,
|
|
DropdownMenuContent,
|
|
DropdownMenuTrigger,
|
|
Form,
|
|
showToast,
|
|
Switch,
|
|
TextField,
|
|
TextAreaField,
|
|
} from "@calcom/ui/v2";
|
|
|
|
import { EmbedButton, EmbedDialog } from "@components/Embed";
|
|
|
|
import getFieldIdentifier from "../lib/getFieldIdentifier";
|
|
import { SerializableForm } from "../types/types";
|
|
import { App_RoutingForms_Form } from ".prisma/client";
|
|
|
|
type RoutingForm = SerializableForm<App_RoutingForms_Form>;
|
|
|
|
const newFormModalQuerySchema = z.object({
|
|
action: z.string(),
|
|
target: z.string().optional(),
|
|
});
|
|
|
|
const openModal = (router: NextRouter, option: { target?: string; action: string }) => {
|
|
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.useMutation("viewer.app_routing_forms.formMutation", {
|
|
onSuccess: (_data, variables) => {
|
|
router.push(`${appUrl}/form-edit/${variables.id}`);
|
|
},
|
|
onError: () => {
|
|
showToast(`Something went wrong`, "error");
|
|
},
|
|
onSettled: () => {
|
|
utils.invalidateQueries("viewer.app_routing_forms.forms");
|
|
},
|
|
});
|
|
|
|
const hookForm = useForm<{
|
|
name: string;
|
|
description: string;
|
|
}>();
|
|
|
|
const { action, target } = router.query as z.infer<typeof newFormModalQuerySchema>;
|
|
|
|
const { register } = hookForm;
|
|
return (
|
|
<Dialog name="new-form" clearQueryParamsOnClose={["target", "action"]}>
|
|
<DialogContent className="overflow-y-auto">
|
|
<div className="mb-4">
|
|
<h3 className="text-lg font-bold leading-6 text-gray-900" id="modal-title">
|
|
{t("add_new_form")}
|
|
</h3>
|
|
<div>
|
|
<p className="text-sm text-gray-500">{t("form_description")}</p>
|
|
</div>
|
|
</div>
|
|
<Form
|
|
form={hookForm}
|
|
handleSubmit={(values) => {
|
|
const formId = uuidv4();
|
|
mutation.mutate({
|
|
id: formId,
|
|
...values,
|
|
addFallback: true,
|
|
duplicateFrom: action === "duplicate" ? target : null,
|
|
});
|
|
}}>
|
|
<div className="mt-3 space-y-4">
|
|
<TextField label={t("title")} required placeholder="A Routing Form" {...register("name")} />
|
|
<div className="mb-5">
|
|
<TextAreaField
|
|
id="description"
|
|
label={t("description")}
|
|
{...register("description")}
|
|
data-testid="description"
|
|
placeholder="Form Description"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="mt-8 flex flex-row-reverse gap-x-2">
|
|
<Button loading={mutation.isLoading} data-testid="add-form" type="submit">
|
|
{t("continue")}
|
|
</Button>
|
|
<DialogClose asChild>
|
|
<Button color="secondary">{t("cancel")}</Button>
|
|
</DialogClose>
|
|
</div>
|
|
</Form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
const dropdownCtx = createContext<{ dropdown: boolean }>({ dropdown: false });
|
|
|
|
export const FormActionsDropdown = ({ form, children }: { form: RoutingForm; children: React.ReactNode }) => {
|
|
const { disabled } = form;
|
|
return (
|
|
<dropdownCtx.Provider value={{ dropdown: true }}>
|
|
<Dropdown>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
size="icon"
|
|
combined
|
|
color="secondary"
|
|
className={classNames(disabled && " opacity-30")}
|
|
StartIcon={Icon.FiMoreHorizontal}
|
|
/>
|
|
</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 deleteMutation = trpc.useMutation("viewer.app_routing_forms.deleteForm", {
|
|
onMutate: async ({ id: formId }) => {
|
|
await utils.cancelQuery(["viewer.app_routing_forms.forms"]);
|
|
const previousValue = utils.getQueryData(["viewer.app_routing_forms.forms"]);
|
|
if (previousValue) {
|
|
const filtered = previousValue.filter(({ id }) => id !== formId);
|
|
utils.setQueryData(["viewer.app_routing_forms.forms"], filtered);
|
|
}
|
|
return { previousValue };
|
|
},
|
|
onSuccess: () => {
|
|
showToast("Form deleted", "success");
|
|
setDeleteDialogOpen(false);
|
|
router.replace(`${appUrl}/forms`);
|
|
},
|
|
onSettled: () => {
|
|
utils.invalidateQueries(["viewer.app_routing_forms.forms"]);
|
|
setDeleteDialogOpen(false);
|
|
},
|
|
onError: (err, newTodo, context) => {
|
|
if (context?.previousValue) {
|
|
utils.setQueryData(["viewer.app_routing_forms.forms"], context.previousValue);
|
|
}
|
|
showToast("Something went wrong", "error");
|
|
},
|
|
});
|
|
return (
|
|
<div id="form-dialogs">
|
|
<EmbedDialog />
|
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
|
<ConfirmationDialogContent
|
|
isLoading={deleteMutation.isLoading}
|
|
variety="danger"
|
|
title="Delete Form"
|
|
confirmBtnText="Yes, delete Form"
|
|
loadingText="Yes, delete Form"
|
|
onConfirm={(e) => {
|
|
if (!deleteDialogFormId) {
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
deleteMutation.mutate({
|
|
id: deleteDialogFormId,
|
|
});
|
|
}}>
|
|
Are you sure you want to delete this form? Anyone who you've shared the link with will no
|
|
longer be able to book using it. Also, all associated responses would be deleted.
|
|
</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 router = useRouter();
|
|
|
|
const toggleMutation = trpc.useMutation("viewer.app_routing_forms.formMutation", {
|
|
onError: () => {
|
|
showToast(`Something went wrong`, "error");
|
|
},
|
|
onSuccess: () => {
|
|
router.replace(router.asPath);
|
|
},
|
|
});
|
|
|
|
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 }) => JSX.Element;
|
|
} & 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, ...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: () => openModal(router, { action: "new" }),
|
|
},
|
|
copyRedirectUrl: {
|
|
onClick: () => {
|
|
navigator.clipboard.writeText(redirectUrl);
|
|
showToast("Typeform Redirect URL copied! You can go and set the URL in Typeform form.", "success");
|
|
},
|
|
},
|
|
toggle: {
|
|
render: ({ routingForm, label = "", ...restProps }) => {
|
|
if (!routingForm) {
|
|
return <></>;
|
|
}
|
|
return (
|
|
<div {...restProps} className="self-center rounded-md p-2 hover:bg-gray-200">
|
|
<Switch
|
|
checked={!routingForm.disabled}
|
|
label={label}
|
|
onCheckedChange={(checked) => toggle.onAction({ routingForm, checked })}
|
|
/>
|
|
</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 ref={forwardedRef} {...actionProps}>
|
|
{children}
|
|
</Component>
|
|
);
|
|
}
|
|
return (
|
|
<DropdownMenuItem>
|
|
<Component ref={forwardedRef} {...actionProps}>
|
|
{children}
|
|
</Component>
|
|
</DropdownMenuItem>
|
|
);
|
|
});
|