first commit
This commit is contained in:
@@ -0,0 +1,520 @@
|
||||
import type { App_RoutingForms_Form } from "@prisma/client";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { createContext, forwardRef, useContext, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import { RoutingFormEmbedButton, RoutingFormEmbedDialog } from "@calcom/features/embed/RoutingFormEmbed";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { ButtonProps } from "@calcom/ui";
|
||||
import {
|
||||
Button,
|
||||
ConfirmationDialogContent,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
Dropdown,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Form,
|
||||
SettingsToggle,
|
||||
showToast,
|
||||
Switch,
|
||||
TextAreaField,
|
||||
TextField,
|
||||
} from "@calcom/ui";
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
export const useOpenModal = () => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useCompatSearchParams();
|
||||
const openModal = (option: z.infer<typeof newFormModalQuerySchema>) => {
|
||||
const newQuery = new URLSearchParams(searchParams ?? undefined);
|
||||
newQuery.set("dialog", "new-form");
|
||||
Object.keys(option).forEach((key) => {
|
||||
newQuery.set(key, option[key as keyof typeof option] || "");
|
||||
});
|
||||
router.push(`${pathname}?${newQuery.toString()}`);
|
||||
};
|
||||
return openModal;
|
||||
};
|
||||
|
||||
function NewFormDialog({ appUrl }: { appUrl: string }) {
|
||||
const routerQuery = useRouterQuery();
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
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 } = routerQuery 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">
|
||||
<DialogClose />
|
||||
<Button loading={mutation.isPending} 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="ellipsis"
|
||||
/>
|
||||
</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.useUtils();
|
||||
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);
|
||||
router.push(`${appUrl}/forms`);
|
||||
},
|
||||
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">
|
||||
<RoutingFormEmbedDialog />
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<ConfirmationDialogContent
|
||||
isPending={deleteMutation.isPending}
|
||||
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 }) => {},
|
||||
isPending: false,
|
||||
},
|
||||
toggle: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars
|
||||
onAction: (_arg: { routingForm: RoutingForm | null; checked: boolean }) => {},
|
||||
isPending: 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.useUtils();
|
||||
|
||||
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);
|
||||
},
|
||||
isPending: false,
|
||||
},
|
||||
toggle: {
|
||||
onAction: ({ routingForm, checked }) => {
|
||||
if (!routingForm) {
|
||||
return;
|
||||
}
|
||||
toggleMutation.mutate({
|
||||
...routingForm,
|
||||
disabled: !checked,
|
||||
});
|
||||
},
|
||||
isPending: toggleMutation.isPending,
|
||||
},
|
||||
}}>
|
||||
{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 orgBranding = useOrgBranding();
|
||||
|
||||
const formLink = `${orgBranding?.fullDomain ?? WEBSITE_URL}/${embedLink}`;
|
||||
let redirectUrl = `${orgBranding?.fullDomain ?? WEBSITE_URL}/router?form=${routingForm?.id}`;
|
||||
|
||||
routingForm?.fields?.forEach((field) => {
|
||||
redirectUrl += `&${getFieldIdentifier(field)}={Recalled_Response_For_This_Field}`;
|
||||
});
|
||||
|
||||
const { t } = useLocale();
|
||||
const openModal = useOpenModal();
|
||||
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({ action: "duplicate", target: routingForm?.id }),
|
||||
},
|
||||
embed: {
|
||||
as: RoutingFormEmbedButton,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
embedUrl: embedLink,
|
||||
// We are okay with namespace clashing here if just in case names clash
|
||||
namespace: slugify((routingForm?.name || "").substring(0, 5)),
|
||||
},
|
||||
edit: {
|
||||
href: `${appUrl}/form-edit/${routingForm?.id}`,
|
||||
},
|
||||
download: {
|
||||
href: `/api/integrations/routing-forms/responses/${routingForm?.id}`,
|
||||
},
|
||||
_delete: {
|
||||
onClick: () => _delete.onAction({ routingForm }),
|
||||
loading: _delete.isPending,
|
||||
},
|
||||
create: {
|
||||
onClick: () => openModal({ action: "new", target: "" }),
|
||||
},
|
||||
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.isPending,
|
||||
},
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { App_RoutingForms_Form } from "@prisma/client";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
import getFieldIdentifier from "../lib/getFieldIdentifier";
|
||||
import { getQueryBuilderConfig } from "../lib/getQueryBuilderConfig";
|
||||
import isRouterLinkedField from "../lib/isRouterLinkedField";
|
||||
import transformResponse from "../lib/transformResponse";
|
||||
import type { SerializableForm, Response } from "../types/types";
|
||||
|
||||
type Props = {
|
||||
form: SerializableForm<App_RoutingForms_Form>;
|
||||
response: Response;
|
||||
setResponse: Dispatch<SetStateAction<Response>>;
|
||||
};
|
||||
|
||||
export default function FormInputFields(props: Props) {
|
||||
const { form, response, setResponse } = props;
|
||||
|
||||
const queryBuilderConfig = getQueryBuilderConfig(form);
|
||||
|
||||
return (
|
||||
<>
|
||||
{form.fields?.map((field) => {
|
||||
if (isRouterLinkedField(field)) {
|
||||
// @ts-expect-error FIXME @hariombalhara
|
||||
field = field.routerField;
|
||||
}
|
||||
const widget = queryBuilderConfig.widgets[field.type];
|
||||
if (!("factory" in widget)) {
|
||||
return null;
|
||||
}
|
||||
const Component = widget.factory;
|
||||
|
||||
const optionValues = field.selectText?.trim().split("\n");
|
||||
const options = optionValues?.map((value) => {
|
||||
const title = value;
|
||||
return {
|
||||
value,
|
||||
title,
|
||||
};
|
||||
});
|
||||
return (
|
||||
<div key={field.id} className="mb-4 block flex-col sm:flex ">
|
||||
<div className="min-w-48 mb-2 flex-grow">
|
||||
<label id="slug-label" htmlFor="slug" className="text-default flex text-sm font-medium">
|
||||
{field.label}
|
||||
</label>
|
||||
</div>
|
||||
<Component
|
||||
value={response[field.id]?.value}
|
||||
placeholder={field.placeholder ?? ""}
|
||||
// required property isn't accepted by query-builder types
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
/* @ts-ignore */
|
||||
required={!!field.required}
|
||||
listValues={options}
|
||||
data-testid={`form-field-${getFieldIdentifier(field)}`}
|
||||
setValue={(value: number | string | string[]) => {
|
||||
setResponse((response) => {
|
||||
response = response || {};
|
||||
return {
|
||||
...response,
|
||||
[field.id]: {
|
||||
label: field.label,
|
||||
value: transformResponse({ field, value }),
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { HorizontalTabs } from "@calcom/ui";
|
||||
|
||||
import type { getSerializableForm } from "../lib/getSerializableForm";
|
||||
|
||||
export default function RoutingNavBar({
|
||||
form,
|
||||
appUrl,
|
||||
}: {
|
||||
form: Awaited<ReturnType<typeof getSerializableForm>>;
|
||||
appUrl: string;
|
||||
}) {
|
||||
const tabs = [
|
||||
{
|
||||
name: "Form",
|
||||
href: `${appUrl}/form-edit/${form?.id}`,
|
||||
},
|
||||
{
|
||||
name: "Routing",
|
||||
href: `${appUrl}/route-builder/${form?.id}`,
|
||||
className: "pointer-events-none opacity-30 lg:pointer-events-auto lg:opacity-100",
|
||||
},
|
||||
{
|
||||
name: "Reporting",
|
||||
target: "_blank",
|
||||
href: `${appUrl}/reporting/${form?.id}`,
|
||||
className: "pointer-events-none opacity-30 lg:pointer-events-auto lg:opacity-100",
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<HorizontalTabs tabs={tabs} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
import type { App_RoutingForms_Form, Team } from "@prisma/client";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import AddMembersWithSwitch from "@calcom/features/eventtypes/components/AddMembersWithSwitch";
|
||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||
import useApp from "@calcom/lib/hooks/useApp";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DropdownMenuSeparator,
|
||||
Form,
|
||||
Meta,
|
||||
SettingsToggle,
|
||||
showToast,
|
||||
TextAreaField,
|
||||
TextField,
|
||||
Tooltip,
|
||||
VerticalDivider,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import { getAbsoluteEventTypeRedirectUrl } from "../getEventTypeRedirectUrl";
|
||||
import { RoutingPages } from "../lib/RoutingPages";
|
||||
import { isFallbackRoute } from "../lib/isFallbackRoute";
|
||||
import { processRoute } from "../lib/processRoute";
|
||||
import type { Response, Route, SerializableForm } from "../types/types";
|
||||
import { FormAction, FormActionsDropdown, FormActionsProvider } from "./FormActions";
|
||||
import FormInputFields from "./FormInputFields";
|
||||
import RoutingNavBar from "./RoutingNavBar";
|
||||
import { getServerSidePropsForSingleFormView } from "./getServerSidePropsSingleForm";
|
||||
|
||||
type RoutingForm = SerializableForm<App_RoutingForms_Form>;
|
||||
|
||||
export type RoutingFormWithResponseCount = RoutingForm & {
|
||||
team: {
|
||||
slug: Team["slug"];
|
||||
name: Team["name"];
|
||||
} | null;
|
||||
_count: {
|
||||
responses: number;
|
||||
};
|
||||
};
|
||||
|
||||
const Actions = ({
|
||||
form,
|
||||
mutation,
|
||||
}: {
|
||||
form: RoutingFormWithResponseCount;
|
||||
mutation: {
|
||||
isPending: boolean;
|
||||
};
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const { data: typeformApp } = useApp("typeform");
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<div className="hidden items-center sm:inline-flex">
|
||||
<FormAction className="self-center" data-testid="toggle-form" action="toggle" routingForm={form} />
|
||||
<VerticalDivider />
|
||||
</div>
|
||||
<ButtonGroup combined containerProps={{ className: "hidden md:inline-flex items-center" }}>
|
||||
<Tooltip content={t("preview")}>
|
||||
<FormAction
|
||||
routingForm={form}
|
||||
color="secondary"
|
||||
target="_blank"
|
||||
variant="icon"
|
||||
type="button"
|
||||
rel="noreferrer"
|
||||
action="preview"
|
||||
StartIcon="external-link"
|
||||
/>
|
||||
</Tooltip>
|
||||
<FormAction
|
||||
routingForm={form}
|
||||
action="copyLink"
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
type="button"
|
||||
StartIcon="link"
|
||||
tooltip={t("copy_link_to_form")}
|
||||
/>
|
||||
|
||||
<Tooltip content="Download Responses">
|
||||
<FormAction
|
||||
data-testid="download-responses"
|
||||
routingForm={form}
|
||||
action="download"
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
type="button"
|
||||
StartIcon="download"
|
||||
/>
|
||||
</Tooltip>
|
||||
<FormAction
|
||||
routingForm={form}
|
||||
action="embed"
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon="code"
|
||||
tooltip={t("embed")}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<FormAction
|
||||
routingForm={form}
|
||||
action="_delete"
|
||||
// className="mr-3"
|
||||
variant="icon"
|
||||
StartIcon="trash"
|
||||
color="secondary"
|
||||
type="button"
|
||||
tooltip={t("delete")}
|
||||
/>
|
||||
{typeformApp?.isInstalled ? (
|
||||
<FormActionsDropdown>
|
||||
<FormAction
|
||||
data-testid="copy-redirect-url"
|
||||
routingForm={form}
|
||||
action="copyRedirectUrl"
|
||||
color="minimal"
|
||||
type="button"
|
||||
StartIcon="link">
|
||||
{t("Copy Typeform Redirect Url")}
|
||||
</FormAction>
|
||||
</FormActionsDropdown>
|
||||
) : null}
|
||||
</ButtonGroup>
|
||||
|
||||
<div className="flex md:hidden">
|
||||
<FormActionsDropdown>
|
||||
<FormAction
|
||||
routingForm={form}
|
||||
color="minimal"
|
||||
target="_blank"
|
||||
type="button"
|
||||
rel="noreferrer"
|
||||
action="preview"
|
||||
StartIcon="external-link">
|
||||
{t("preview")}
|
||||
</FormAction>
|
||||
<FormAction
|
||||
action="copyLink"
|
||||
className="w-full"
|
||||
routingForm={form}
|
||||
color="minimal"
|
||||
type="button"
|
||||
StartIcon="link">
|
||||
{t("copy_link_to_form")}
|
||||
</FormAction>
|
||||
<FormAction
|
||||
action="download"
|
||||
routingForm={form}
|
||||
className="w-full"
|
||||
color="minimal"
|
||||
type="button"
|
||||
StartIcon="download">
|
||||
{t("download_responses")}
|
||||
</FormAction>
|
||||
<FormAction
|
||||
action="embed"
|
||||
routingForm={form}
|
||||
color="minimal"
|
||||
type="button"
|
||||
className="w-full"
|
||||
StartIcon="code">
|
||||
{t("embed")}
|
||||
</FormAction>
|
||||
{typeformApp ? (
|
||||
<FormAction
|
||||
data-testid="copy-redirect-url"
|
||||
routingForm={form}
|
||||
action="copyRedirectUrl"
|
||||
color="minimal"
|
||||
type="button"
|
||||
StartIcon="link">
|
||||
{t("Copy Typeform Redirect Url")}
|
||||
</FormAction>
|
||||
) : null}
|
||||
<DropdownMenuSeparator className="hidden sm:block" />
|
||||
<FormAction
|
||||
action="_delete"
|
||||
routingForm={form}
|
||||
className="w-full"
|
||||
type="button"
|
||||
color="destructive"
|
||||
StartIcon="trash">
|
||||
{t("delete")}
|
||||
</FormAction>
|
||||
<div className="block sm:hidden">
|
||||
<DropdownMenuSeparator />
|
||||
<FormAction
|
||||
data-testid="toggle-form"
|
||||
action="toggle"
|
||||
routingForm={form}
|
||||
label="Disable Form"
|
||||
extraClassNames="hover:bg-subtle cursor-pointer rounded-[5px] pr-4"
|
||||
/>
|
||||
</div>
|
||||
</FormActionsDropdown>
|
||||
</div>
|
||||
<VerticalDivider />
|
||||
<Button data-testid="update-form" loading={mutation.isPending} type="submit" color="primary">
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type SingleFormComponentProps = {
|
||||
form: RoutingFormWithResponseCount;
|
||||
appUrl: string;
|
||||
Page: React.FC<{
|
||||
form: RoutingFormWithResponseCount;
|
||||
appUrl: string;
|
||||
hookForm: UseFormReturn<RoutingFormWithResponseCount>;
|
||||
}>;
|
||||
enrichedWithUserProfileForm?: inferSSRProps<
|
||||
typeof getServerSidePropsForSingleFormView
|
||||
>["enrichedWithUserProfileForm"];
|
||||
};
|
||||
|
||||
function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleFormComponentProps) {
|
||||
const utils = trpc.useUtils();
|
||||
const { t } = useLocale();
|
||||
|
||||
const [isTestPreviewOpen, setIsTestPreviewOpen] = useState(false);
|
||||
const [response, setResponse] = useState<Response>({});
|
||||
const [decidedAction, setDecidedAction] = useState<Route["action"] | null>(null);
|
||||
const [skipFirstUpdate, setSkipFirstUpdate] = useState(true);
|
||||
const [eventTypeUrl, setEventTypeUrl] = useState("");
|
||||
|
||||
function testRouting() {
|
||||
const action = processRoute({ form, response });
|
||||
if (action.type === "eventTypeRedirectUrl") {
|
||||
setEventTypeUrl(
|
||||
enrichedWithUserProfileForm
|
||||
? getAbsoluteEventTypeRedirectUrl({
|
||||
eventTypeRedirectUrl: action.value,
|
||||
form: enrichedWithUserProfileForm,
|
||||
allURLSearchParams: new URLSearchParams(),
|
||||
})
|
||||
: ""
|
||||
);
|
||||
}
|
||||
setDecidedAction(action);
|
||||
}
|
||||
|
||||
const hookForm = useFormContext<RoutingFormWithResponseCount>();
|
||||
|
||||
useEffect(() => {
|
||||
// The first time a tab is opened, the hookForm copies the form data (saved version, from the backend),
|
||||
// and then it is considered the source of truth.
|
||||
|
||||
// There are two events we need to overwrite the hookForm data with the form data coming from the server.
|
||||
|
||||
// 1 - When we change the edited form.
|
||||
|
||||
// 2 - When the form is saved elsewhere (such as in another browser tab)
|
||||
|
||||
// In the second case. We skipped the first execution of useEffect to differentiate a tab change from a form change,
|
||||
// because each time a tab changes, a new component is created and another useEffect is executed.
|
||||
// An update from the form always occurs after the first useEffect execution.
|
||||
if (Object.keys(hookForm.getValues()).length === 0 || hookForm.getValues().id !== form.id) {
|
||||
hookForm.reset(form);
|
||||
}
|
||||
|
||||
if (skipFirstUpdate) {
|
||||
setSkipFirstUpdate(false);
|
||||
} else {
|
||||
hookForm.reset(form);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [form]);
|
||||
|
||||
const sendUpdatesTo = hookForm.watch("settings.sendUpdatesTo", []) as number[];
|
||||
const sendToAll = hookForm.watch("settings.sendToAll", false) as boolean;
|
||||
|
||||
const mutation = trpc.viewer.appRoutingForms.formMutation.useMutation({
|
||||
onSuccess() {
|
||||
showToast(t("form_updated_successfully"), "success");
|
||||
},
|
||||
onError(e) {
|
||||
if (e.message) {
|
||||
showToast(e.message, "error");
|
||||
return;
|
||||
}
|
||||
showToast(`Something went wrong`, "error");
|
||||
},
|
||||
onSettled() {
|
||||
utils.viewer.appRoutingForms.formQuery.invalidate({ id: form.id });
|
||||
},
|
||||
});
|
||||
const connectedForms = form.connectedForms;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form
|
||||
form={hookForm}
|
||||
handleSubmit={(data) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
mutation.mutate({
|
||||
...data,
|
||||
});
|
||||
}}>
|
||||
<FormActionsProvider appUrl={appUrl}>
|
||||
<Meta title={form.name} description={form.description || ""} />
|
||||
<ShellMain
|
||||
heading={
|
||||
<div className="flex">
|
||||
<div>{form.name}</div>
|
||||
{form.team && (
|
||||
<Badge className="ml-4 mt-1" variant="gray">
|
||||
{form.team.name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
subtitle={form.description || ""}
|
||||
backPath={`/${appUrl}/forms`}
|
||||
CTA={<Actions form={form} mutation={mutation} />}>
|
||||
<div className="-mx-4 mt-4 px-4 sm:px-6 md:-mx-8 md:mt-0 md:px-8">
|
||||
<div className="flex flex-col items-center items-baseline md:flex-row md:items-start">
|
||||
<div className="lg:min-w-72 lg:max-w-72 mb-6 md:mr-6">
|
||||
<TextField
|
||||
type="text"
|
||||
containerClassName="mb-6"
|
||||
placeholder={t("title")}
|
||||
{...hookForm.register("name")}
|
||||
/>
|
||||
<TextAreaField
|
||||
rows={3}
|
||||
id="description"
|
||||
data-testid="description"
|
||||
placeholder={t("form_description_placeholder")}
|
||||
{...hookForm.register("description")}
|
||||
defaultValue={form.description || ""}
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
{form.teamId ? (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-emphasis mb-3 block text-sm font-medium leading-none">
|
||||
{t("routing_forms_send_email_to")}
|
||||
</span>
|
||||
<AddMembersWithSwitch
|
||||
teamMembers={form.teamMembers.map((member) => ({
|
||||
value: member.id.toString(),
|
||||
label: member.name || "",
|
||||
avatar: member.avatarUrl || "",
|
||||
email: member.email,
|
||||
isFixed: true,
|
||||
}))}
|
||||
value={sendUpdatesTo.map((userId) => ({
|
||||
isFixed: true,
|
||||
userId: userId,
|
||||
priority: 1,
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
hookForm.setValue(
|
||||
"settings.sendUpdatesTo",
|
||||
value.map((teamMember) => teamMember.userId),
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
hookForm.setValue("settings.emailOwnerOnSubmission", false, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}}
|
||||
assignAllTeamMembers={sendToAll}
|
||||
setAssignAllTeamMembers={(value) => {
|
||||
hookForm.setValue("settings.sendToAll", !!value, { shouldDirty: true });
|
||||
}}
|
||||
automaticAddAllEnabled={true}
|
||||
isFixed={true}
|
||||
onActive={() => {
|
||||
hookForm.setValue(
|
||||
"settings.sendUpdatesTo",
|
||||
form.teamMembers.map((teamMember) => teamMember.id),
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
hookForm.setValue("settings.emailOwnerOnSubmission", false, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}}
|
||||
placeholder={t("select_members")}
|
||||
containerClassName="!px-0 !pb-0 !pt-0"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Controller
|
||||
name="settings.emailOwnerOnSubmission"
|
||||
control={hookForm.control}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return (
|
||||
<SettingsToggle
|
||||
title={t("routing_forms_send_email_owner")}
|
||||
description={t("routing_forms_send_email_owner_description")}
|
||||
checked={value}
|
||||
onCheckedChange={(val) => {
|
||||
onChange(val);
|
||||
hookForm.unregister("settings.sendUpdatesTo");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{form.routers.length ? (
|
||||
<div className="mt-6">
|
||||
<div className="text-emphasis mb-2 block text-sm font-semibold leading-none ">
|
||||
{t("routers")}
|
||||
</div>
|
||||
<p className="text-default -mt-1 text-xs leading-normal">
|
||||
{t("modifications_in_fields_warning")}
|
||||
</p>
|
||||
<div className="flex">
|
||||
{form.routers.map((router) => {
|
||||
return (
|
||||
<div key={router.id} className="mr-2">
|
||||
<Link href={`${appUrl}/route-builder/${router.id}`}>
|
||||
<Badge variant="gray">{router.name}</Badge>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{connectedForms?.length ? (
|
||||
<div className="mt-6">
|
||||
<div className="text-emphasis mb-2 block text-sm font-semibold leading-none ">
|
||||
{t("connected_forms")}
|
||||
</div>
|
||||
<p className="text-default -mt-1 text-xs leading-normal">
|
||||
{t("form_modifications_warning")}
|
||||
</p>
|
||||
<div className="flex">
|
||||
{connectedForms.map((router) => {
|
||||
return (
|
||||
<div key={router.id} className="mr-2">
|
||||
<Link href={`${appUrl}/route-builder/${router.id}`}>
|
||||
<Badge variant="default">{router.name}</Badge>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-6">
|
||||
<Button
|
||||
color="secondary"
|
||||
data-testid="test-preview"
|
||||
onClick={() => setIsTestPreviewOpen(true)}>
|
||||
{t("test_preview")}
|
||||
</Button>
|
||||
</div>
|
||||
{form.routes?.every(isFallbackRoute) && (
|
||||
<Alert
|
||||
className="mt-6 !bg-orange-100 font-semibold text-orange-900"
|
||||
iconClassName="!text-orange-900"
|
||||
severity="neutral"
|
||||
title={t("no_routes_defined")}
|
||||
/>
|
||||
)}
|
||||
{!form._count?.responses && (
|
||||
<>
|
||||
<Alert
|
||||
className="mt-2 px-4 py-3"
|
||||
severity="neutral"
|
||||
title={t("no_responses_yet")}
|
||||
CustomIcon="message-circle"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-subtle bg-muted w-full rounded-md border p-8">
|
||||
<RoutingNavBar appUrl={appUrl} form={form} />
|
||||
<Page hookForm={hookForm} form={form} appUrl={appUrl} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ShellMain>
|
||||
</FormActionsProvider>
|
||||
</Form>
|
||||
<Dialog open={isTestPreviewOpen} onOpenChange={setIsTestPreviewOpen}>
|
||||
<DialogContent enableOverflow>
|
||||
<DialogHeader title={t("test_routing_form")} subtitle={t("test_preview_description")} />
|
||||
<div>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
testRouting();
|
||||
}}>
|
||||
<div className="px-1">
|
||||
{form && <FormInputFields form={form} response={response} setResponse={setResponse} />}
|
||||
</div>
|
||||
<div>
|
||||
{decidedAction && (
|
||||
<div className="bg-subtle text-default mt-5 rounded-md p-3">
|
||||
<div className="font-bold ">{t("route_to")}:</div>
|
||||
<div className="mt-2">
|
||||
{RoutingPages.map((page) => {
|
||||
if (page.value !== decidedAction.type) return null;
|
||||
return (
|
||||
<span key={page.value} data-testid="test-routing-result-type">
|
||||
{page.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
:{" "}
|
||||
{decidedAction.type === "customPageMessage" ? (
|
||||
<span className="text-default" data-testid="test-routing-result">
|
||||
{decidedAction.value}
|
||||
</span>
|
||||
) : decidedAction.type === "externalRedirectUrl" ? (
|
||||
<span className="text-default underline">
|
||||
<a
|
||||
target="_blank"
|
||||
data-testid="test-routing-result"
|
||||
href={
|
||||
decidedAction.value.includes("https://") ||
|
||||
decidedAction.value.includes("http://")
|
||||
? decidedAction.value
|
||||
: `http://${decidedAction.value}`
|
||||
}
|
||||
rel="noreferrer">
|
||||
{decidedAction.value}
|
||||
</a>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-default underline">
|
||||
<a
|
||||
target="_blank"
|
||||
href={eventTypeUrl}
|
||||
rel="noreferrer"
|
||||
data-testid="test-routing-result">
|
||||
{decidedAction.value}
|
||||
</a>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
setIsTestPreviewOpen(false);
|
||||
setDecidedAction(null);
|
||||
setResponse({});
|
||||
}}>
|
||||
{t("close")}
|
||||
</DialogClose>
|
||||
<Button type="submit" data-testid="test-routing">
|
||||
{t("test_routing")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SingleFormWrapper({ form: _form, ...props }: SingleFormComponentProps) {
|
||||
const { data: form, isPending } = trpc.viewer.appRoutingForms.formQuery.useQuery(
|
||||
{ id: _form.id },
|
||||
{
|
||||
initialData: _form,
|
||||
trpc: {},
|
||||
}
|
||||
);
|
||||
const { t } = useLocale();
|
||||
|
||||
if (isPending) {
|
||||
// It shouldn't be possible because we are passing the data from SSR to it as initialData. So, no need for skeleton here
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!form) {
|
||||
throw new Error(t("something_went_wrong"));
|
||||
}
|
||||
return (
|
||||
<LicenseRequired>
|
||||
<SingleForm form={form} {...props} />
|
||||
</LicenseRequired>
|
||||
);
|
||||
}
|
||||
|
||||
export { getServerSidePropsForSingleFormView };
|
||||
@@ -0,0 +1,120 @@
|
||||
import type {
|
||||
AppGetServerSidePropsContext,
|
||||
AppPrisma,
|
||||
AppSsrInit,
|
||||
AppUser,
|
||||
} from "@calcom/types/AppGetServerSideProps";
|
||||
|
||||
import { enrichFormWithMigrationData } from "../enrichFormWithMigrationData";
|
||||
import { getSerializableForm } from "../lib/getSerializableForm";
|
||||
|
||||
export const getServerSidePropsForSingleFormView = async function getServerSidePropsForSingleFormView(
|
||||
context: AppGetServerSidePropsContext,
|
||||
prisma: AppPrisma,
|
||||
user: AppUser,
|
||||
ssrInit: AppSsrInit
|
||||
) {
|
||||
const ssr = await ssrInit(context);
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/auth/login",
|
||||
},
|
||||
};
|
||||
}
|
||||
const { params } = context;
|
||||
if (!params) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
const formId = params.appPages[0];
|
||||
if (!formId || params.appPages.length > 1) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
const isFormCreateEditAllowed = (await import("../lib/isFormCreateEditAllowed")).isFormCreateEditAllowed;
|
||||
if (!(await isFormCreateEditAllowed({ userId: user.id, formId, targetTeamId: null }))) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
const form = await prisma.app_RoutingForms_Form.findUnique({
|
||||
where: {
|
||||
id: formId,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
movedToProfileId: true,
|
||||
organization: {
|
||||
select: {
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
username: true,
|
||||
theme: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
metadata: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
slug: true,
|
||||
name: true,
|
||||
parent: {
|
||||
select: { slug: true },
|
||||
},
|
||||
parentId: true,
|
||||
metadata: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
responses: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!form) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
const { user: u, ...formWithoutUser } = form;
|
||||
|
||||
const formWithoutProfilInfo = {
|
||||
...formWithoutUser,
|
||||
team: form.team
|
||||
? {
|
||||
slug: form.team.slug,
|
||||
name: form.team.name,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
const { UserRepository } = await import("@calcom/lib/server/repository/user");
|
||||
|
||||
const formWithUserInfoProfil = {
|
||||
...form,
|
||||
user: await UserRepository.enrichUserWithItsProfile({ user: form.user }),
|
||||
};
|
||||
|
||||
return {
|
||||
props: {
|
||||
trpcState: await ssr.dehydrate(),
|
||||
form: await getSerializableForm({ form: formWithoutProfilInfo }),
|
||||
enrichedWithUserProfileForm: await getSerializableForm({
|
||||
form: enrichFormWithMigrationData(formWithUserInfoProfil),
|
||||
}),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { ChangeEvent } from "react";
|
||||
import type { Settings, Widgets, SelectWidgetProps } from "react-awesome-query-builder";
|
||||
// Figure out why routing-forms/env.d.ts doesn't work
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
import BasicConfig from "react-awesome-query-builder/lib/config/basic";
|
||||
|
||||
import { EmailField } from "@calcom/ui";
|
||||
|
||||
import widgetsComponents from "../widgets";
|
||||
|
||||
const {
|
||||
TextWidget,
|
||||
TextAreaWidget,
|
||||
MultiSelectWidget,
|
||||
SelectWidget,
|
||||
NumberWidget,
|
||||
FieldSelect,
|
||||
Conjs,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Provider,
|
||||
} = widgetsComponents;
|
||||
|
||||
const renderComponent = function <T1>(props: T1 | undefined, Component: React.FC<T1>) {
|
||||
if (!props) {
|
||||
return <div />;
|
||||
}
|
||||
return <Component {...props} />;
|
||||
};
|
||||
|
||||
const settings: Settings = {
|
||||
...BasicConfig.settings,
|
||||
|
||||
renderField: (props) => renderComponent(props, FieldSelect),
|
||||
renderOperator: (props) => renderComponent(props, FieldSelect),
|
||||
renderFunc: (props) => renderComponent(props, FieldSelect),
|
||||
renderConjs: (props) => renderComponent(props, Conjs),
|
||||
renderButton: (props) => renderComponent(props, Button),
|
||||
renderButtonGroup: (props) => renderComponent(props, ButtonGroup),
|
||||
renderProvider: (props) => renderComponent(props, Provider),
|
||||
|
||||
groupActionsPosition: "bottomCenter",
|
||||
|
||||
// Disable groups
|
||||
maxNesting: 1,
|
||||
};
|
||||
|
||||
// react-query-builder types have missing type property on Widget
|
||||
//TODO: Reuse FormBuilder Components - FormBuilder components are built considering Cal.com design system and coding guidelines. But when awesome-query-builder renders these components, it passes its own props which are different from what our Components expect.
|
||||
// So, a mapper should be written here that maps the props provided by awesome-query-builder to the props that our components expect.
|
||||
const widgets: Widgets & { [key in keyof Widgets]: Widgets[key] & { type: string } } = {
|
||||
...BasicConfig.widgets,
|
||||
text: {
|
||||
...BasicConfig.widgets.text,
|
||||
factory: (props) => renderComponent(props, TextWidget),
|
||||
},
|
||||
textarea: {
|
||||
...BasicConfig.widgets.textarea,
|
||||
factory: (props) => renderComponent(props, TextAreaWidget),
|
||||
},
|
||||
number: {
|
||||
...BasicConfig.widgets.number,
|
||||
factory: (props) => renderComponent(props, NumberWidget),
|
||||
},
|
||||
multiselect: {
|
||||
...BasicConfig.widgets.multiselect,
|
||||
factory: (
|
||||
props: SelectWidgetProps & {
|
||||
listValues: { title: string; value: string }[];
|
||||
}
|
||||
) => renderComponent(props, MultiSelectWidget),
|
||||
},
|
||||
select: {
|
||||
...BasicConfig.widgets.select,
|
||||
factory: (
|
||||
props: SelectWidgetProps & {
|
||||
listValues: { title: string; value: string }[];
|
||||
}
|
||||
) => renderComponent(props, SelectWidget),
|
||||
},
|
||||
phone: {
|
||||
...BasicConfig.widgets.text,
|
||||
factory: (props) => {
|
||||
if (!props) {
|
||||
return <div />;
|
||||
}
|
||||
return <TextWidget type="tel" {...props} />;
|
||||
},
|
||||
valuePlaceholder: "Enter Phone Number",
|
||||
},
|
||||
email: {
|
||||
...BasicConfig.widgets.text,
|
||||
factory: (props) => {
|
||||
if (!props) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
return (
|
||||
<EmailField
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value;
|
||||
props.setValue(val);
|
||||
}}
|
||||
containerClassName="w-full"
|
||||
className="dark:placeholder:text-darkgray-600 focus:border-brand border-subtle dark:text-darkgray-900 block w-full rounded-md border-gray-300 text-sm focus:ring-black disabled:bg-gray-200 disabled:hover:cursor-not-allowed dark:bg-transparent dark:selection:bg-green-500 disabled:dark:text-gray-500"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const types = {
|
||||
...BasicConfig.types,
|
||||
phone: {
|
||||
...BasicConfig.types.text,
|
||||
widgets: {
|
||||
...BasicConfig.types.text.widgets,
|
||||
},
|
||||
},
|
||||
email: {
|
||||
...BasicConfig.types.text,
|
||||
widgets: {
|
||||
...BasicConfig.types.text.widgets,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const operators = BasicConfig.operators;
|
||||
operators.equal.label = operators.select_equals.label = "Equals";
|
||||
operators.greater_or_equal.label = "Greater than or equal to";
|
||||
operators.greater.label = "Greater than";
|
||||
operators.less_or_equal.label = "Less than or equal to";
|
||||
operators.less.label = "Less than";
|
||||
operators.not_equal.label = operators.select_not_equals.label = "Does not equal";
|
||||
operators.between.label = "Between";
|
||||
|
||||
delete operators.proximity;
|
||||
delete operators.is_null;
|
||||
delete operators.is_not_null;
|
||||
|
||||
/**
|
||||
* Not supported with JSONLogic. Implement them and add these back -> https://github.com/jwadhams/json-logic-js/issues/81
|
||||
*/
|
||||
delete operators.starts_with;
|
||||
delete operators.ends_with;
|
||||
|
||||
const config = {
|
||||
conjunctions: BasicConfig.conjunctions,
|
||||
operators,
|
||||
types,
|
||||
widgets,
|
||||
settings,
|
||||
};
|
||||
export default config;
|
||||
@@ -0,0 +1,128 @@
|
||||
.cal-query-builder .query-builder,
|
||||
.cal-query-builder .qb-draggable,
|
||||
.cal-query-builder .qb-drag-handler {
|
||||
-webkit-user-select: none;
|
||||
-khtml-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* hide connectors */
|
||||
.cal-query-builder .group-or-rule::before,
|
||||
.cal-query-builder .group-or-rule::after {
|
||||
/* !important to ensure that styles added by react-query-awesome-builder are overriden */
|
||||
content: unset !important;
|
||||
}
|
||||
|
||||
.cal-query-builder .group--children {
|
||||
/* !important to ensure that styles added by react-query-awesome-builder are overriden */
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
|
||||
/* Hide "and" for between numbers */
|
||||
.cal-query-builder .widget--sep {
|
||||
/* !important to ensure that styles added by react-query-awesome-builder are overriden */
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Layout of all fields- Distance b/w them, positioning, width */
|
||||
.cal-query-builder .rule--body--wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cal-query-builder .rule--body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cal-query-builder .rule--field,
|
||||
.cal-query-builder .rule--operator,
|
||||
.cal-query-builder .rule--value {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.cal-query-builder .rule--widget {
|
||||
display: "inline-block";
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cal-query-builder .widget--widget,
|
||||
.cal-query-builder .widget--widget,
|
||||
.cal-query-builder .widget--widget > * {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cal-query-builder .rule--drag-handler,
|
||||
.cal-query-builder .rule--header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 8px;
|
||||
/* !important to ensure that styles added by react-query-awesome-builder are overriden */
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* Disable Reordering of rules - It is not required with the current functionality plus it's not working correctly even if someone wants to re-order */
|
||||
.cal-query-builder .rule--drag-handler {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cal-query-builder .rule--func--wrapper,
|
||||
.cal-query-builder .rule--func,
|
||||
.cal-query-builder .rule--func--args,
|
||||
.cal-query-builder .rule--func--arg,
|
||||
.cal-query-builder .rule--func--arg-value,
|
||||
.cal-query-builder .rule--func--bracket-before,
|
||||
.cal-query-builder .rule--func--bracket-after,
|
||||
.cal-query-builder .rule--func--arg-sep,
|
||||
.cal-query-builder .rule--func--arg-label,
|
||||
.cal-query-builder .rule--func--arg-label-sep {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.cal-query-builder .rule--field,
|
||||
.cal-query-builder .group--field,
|
||||
.cal-query-builder .rule--operator,
|
||||
.cal-query-builder .rule--value,
|
||||
.cal-query-builder .rule--operator-options,
|
||||
.cal-query-builder .widget--widget,
|
||||
.cal-query-builder .widget--valuesrc,
|
||||
.cal-query-builder .operator--options--sep,
|
||||
.cal-query-builder .rule--before-widget,
|
||||
.cal-query-builder .rule--after-widget {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.cal-query-builder .rule--operator,
|
||||
.cal-query-builder .widget--widget,
|
||||
.cal-query-builder .widget--valuesrc,
|
||||
.cal-query-builder .widget--sep {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.cal-query-builder .widget--valuesrc {
|
||||
margin-right: -8px;
|
||||
}
|
||||
|
||||
.cal-query-builder .group--header,
|
||||
.cal-query-builder .group--footer {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.cal-query-builder .group-or-rule-container {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.cal-query-builder .rule {
|
||||
border: 1px solid transparent;
|
||||
padding: 10px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import type { ChangeEvent } from "react";
|
||||
import type {
|
||||
ButtonGroupProps,
|
||||
ButtonProps,
|
||||
ConjsProps,
|
||||
FieldProps,
|
||||
ProviderProps,
|
||||
} from "react-awesome-query-builder";
|
||||
|
||||
import { Button as CalButton, TextField, TextArea } from "@calcom/ui";
|
||||
import { Icon } from "@calcom/ui";
|
||||
|
||||
const Select = dynamic(
|
||||
async () => (await import("@calcom/ui")).SelectWithValidation
|
||||
) as unknown as typeof import("@calcom/ui").SelectWithValidation;
|
||||
|
||||
export type CommonProps<
|
||||
TVal extends
|
||||
| string
|
||||
| boolean
|
||||
| string[]
|
||||
| {
|
||||
value: string;
|
||||
optionValue: string;
|
||||
}
|
||||
> = {
|
||||
placeholder?: string;
|
||||
readOnly?: boolean;
|
||||
className?: string;
|
||||
name?: string;
|
||||
label?: string;
|
||||
value: TVal;
|
||||
setValue: (value: TVal) => void;
|
||||
/**
|
||||
* required and other validations are supported using zodResolver from react-hook-form
|
||||
*/
|
||||
// required?: boolean;
|
||||
};
|
||||
|
||||
export type SelectLikeComponentProps<
|
||||
TVal extends
|
||||
| string
|
||||
| string[]
|
||||
| {
|
||||
value: string;
|
||||
optionValue: string;
|
||||
} = string
|
||||
> = {
|
||||
options: {
|
||||
label: string;
|
||||
value: TVal extends (infer P)[]
|
||||
? P
|
||||
: TVal extends {
|
||||
value: string;
|
||||
}
|
||||
? TVal["value"]
|
||||
: TVal;
|
||||
}[];
|
||||
} & CommonProps<TVal>;
|
||||
|
||||
export type SelectLikeComponentPropsRAQB<TVal extends string | string[] = string> = {
|
||||
listValues: { title: string; value: TVal extends (infer P)[] ? P : TVal }[];
|
||||
} & CommonProps<TVal>;
|
||||
|
||||
export type TextLikeComponentProps<TVal extends string | string[] | boolean = string> = CommonProps<TVal> & {
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export type TextLikeComponentPropsRAQB<TVal extends string | boolean = string> =
|
||||
TextLikeComponentProps<TVal> & {
|
||||
customProps?: object;
|
||||
type?: "text" | "number" | "email" | "tel";
|
||||
maxLength?: number;
|
||||
noLabel?: boolean;
|
||||
};
|
||||
|
||||
const TextAreaWidget = (props: TextLikeComponentPropsRAQB) => {
|
||||
const { value, setValue, readOnly, placeholder, maxLength, customProps, ...remainingProps } = props;
|
||||
|
||||
const onChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const val = e.target.value;
|
||||
setValue(val);
|
||||
};
|
||||
|
||||
const textValue = value || "";
|
||||
return (
|
||||
<TextArea
|
||||
value={textValue}
|
||||
placeholder={placeholder}
|
||||
disabled={readOnly}
|
||||
onChange={onChange}
|
||||
maxLength={maxLength}
|
||||
{...customProps}
|
||||
{...remainingProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const TextWidget = (props: TextLikeComponentPropsRAQB) => {
|
||||
const {
|
||||
value,
|
||||
noLabel,
|
||||
setValue,
|
||||
readOnly,
|
||||
placeholder,
|
||||
customProps,
|
||||
type = "text",
|
||||
...remainingProps
|
||||
} = props;
|
||||
const onChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value;
|
||||
setValue(val);
|
||||
};
|
||||
const textValue = value || "";
|
||||
return (
|
||||
<TextField
|
||||
containerClassName="w-full"
|
||||
type={type}
|
||||
value={textValue}
|
||||
labelSrOnly={noLabel}
|
||||
placeholder={placeholder}
|
||||
disabled={readOnly}
|
||||
onChange={onChange}
|
||||
{...remainingProps}
|
||||
{...customProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function NumberWidget({ value, setValue, ...remainingProps }: TextLikeComponentPropsRAQB) {
|
||||
return (
|
||||
<TextField
|
||||
type="number"
|
||||
labelSrOnly={remainingProps.noLabel}
|
||||
containerClassName="w-full"
|
||||
className="bg-default border-default disabled:bg-emphasis focus:ring-brand-default dark:focus:border-emphasis focus:border-subtle block w-full rounded-md text-sm disabled:hover:cursor-not-allowed"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
}}
|
||||
{...remainingProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const MultiSelectWidget = ({
|
||||
listValues,
|
||||
setValue,
|
||||
value,
|
||||
...remainingProps
|
||||
}: SelectLikeComponentPropsRAQB<string[]>) => {
|
||||
//TODO: Use Select here.
|
||||
//TODO: Let's set listValue itself as label and value instead of using title.
|
||||
if (!listValues) {
|
||||
return null;
|
||||
}
|
||||
const selectItems = listValues.map((item) => {
|
||||
return {
|
||||
label: item.title,
|
||||
value: item.value,
|
||||
};
|
||||
});
|
||||
|
||||
const optionsFromList = selectItems.filter((item) => value?.includes(item.value));
|
||||
|
||||
return (
|
||||
<Select
|
||||
aria-label="multi-select-dropdown"
|
||||
className="mb-2"
|
||||
onChange={(items) => {
|
||||
setValue(items?.map((item) => item.value));
|
||||
}}
|
||||
value={optionsFromList}
|
||||
isMulti={true}
|
||||
isDisabled={remainingProps.readOnly}
|
||||
options={selectItems}
|
||||
{...remainingProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function SelectWidget({ listValues, setValue, value, ...remainingProps }: SelectLikeComponentPropsRAQB) {
|
||||
if (!listValues) {
|
||||
return null;
|
||||
}
|
||||
const selectItems = listValues.map((item) => {
|
||||
return {
|
||||
label: item.title,
|
||||
value: item.value,
|
||||
};
|
||||
});
|
||||
const optionFromList = selectItems.find((item) => item.value === value);
|
||||
|
||||
return (
|
||||
<Select
|
||||
aria-label="select-dropdown"
|
||||
className="data-testid-select mb-2"
|
||||
onChange={(item) => {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
setValue(item.value);
|
||||
}}
|
||||
isDisabled={remainingProps.readOnly}
|
||||
value={optionFromList}
|
||||
options={selectItems}
|
||||
{...remainingProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Button({ config, type, label, onClick, readonly }: ButtonProps) {
|
||||
if (type === "delRule" || type == "delGroup") {
|
||||
return (
|
||||
<button className="ml-5">
|
||||
<Icon name="trash" className="text-subtle m-0 h-4 w-4" onClick={onClick} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
let dataTestId = "";
|
||||
if (type === "addRule") {
|
||||
label = config?.operators.__calReporting ? "Add Filter" : "Add rule";
|
||||
dataTestId = "add-rule";
|
||||
} else if (type == "addGroup") {
|
||||
label = "Add rule group";
|
||||
dataTestId = "add-rule-group";
|
||||
}
|
||||
return (
|
||||
<CalButton
|
||||
StartIcon="plus"
|
||||
data-testid={dataTestId}
|
||||
type="button"
|
||||
color="secondary"
|
||||
disabled={readonly}
|
||||
onClick={onClick}>
|
||||
{label}
|
||||
</CalButton>
|
||||
);
|
||||
}
|
||||
|
||||
function ButtonGroup({ children }: ButtonGroupProps) {
|
||||
if (!(children instanceof Array)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{children.map((button, key) => {
|
||||
if (!button) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div key={key} className="mb-2">
|
||||
{button}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Conjs({ not, setNot, config, conjunctionOptions, setConjunction, disabled }: ConjsProps) {
|
||||
if (!config || !conjunctionOptions) {
|
||||
return null;
|
||||
}
|
||||
const conjsCount = Object.keys(conjunctionOptions).length;
|
||||
|
||||
const lessThenTwo = disabled;
|
||||
const { forceShowConj } = config.settings;
|
||||
const showConj = forceShowConj || (conjsCount > 1 && !lessThenTwo);
|
||||
const options = [
|
||||
{ label: "All", value: "all" },
|
||||
{ label: "Any", value: "any" },
|
||||
{ label: "None", value: "none" },
|
||||
];
|
||||
const renderOptions = () => {
|
||||
const { checked: andSelected } = conjunctionOptions["AND"];
|
||||
const { checked: orSelected } = conjunctionOptions["OR"];
|
||||
const notSelected = not;
|
||||
// Default to All
|
||||
let value = andSelected ? "all" : orSelected ? "any" : "all";
|
||||
|
||||
if (notSelected) {
|
||||
// not of All -> None
|
||||
// not of Any -> All
|
||||
value = value == "any" ? "none" : "all";
|
||||
}
|
||||
const selectValue = options.find((option) => option.value === value);
|
||||
const summary = !config.operators.__calReporting ? "Rule group when" : "Query where";
|
||||
return (
|
||||
<div className="flex items-center text-sm">
|
||||
<span>{summary}</span>
|
||||
<Select
|
||||
className="flex px-2"
|
||||
defaultValue={selectValue}
|
||||
options={options}
|
||||
onChange={(option) => {
|
||||
if (!option) return;
|
||||
if (option.value === "all") {
|
||||
setConjunction("AND");
|
||||
setNot(false);
|
||||
} else if (option.value === "any") {
|
||||
setConjunction("OR");
|
||||
setNot(false);
|
||||
} else if (option.value === "none") {
|
||||
setConjunction("OR");
|
||||
setNot(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>match</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return showConj ? renderOptions() : null;
|
||||
}
|
||||
|
||||
const FieldSelect = function FieldSelect(props: FieldProps) {
|
||||
const { items, setField, selectedKey } = props;
|
||||
const selectItems = items.map((item) => {
|
||||
return {
|
||||
...item,
|
||||
value: item.key,
|
||||
};
|
||||
});
|
||||
|
||||
const defaultValue = selectItems.find((item) => {
|
||||
return item.value === selectedKey;
|
||||
});
|
||||
|
||||
return (
|
||||
<Select
|
||||
className="data-testid-field-select mb-2"
|
||||
menuPosition="fixed"
|
||||
onChange={(item) => {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
setField(item.value);
|
||||
}}
|
||||
defaultValue={defaultValue}
|
||||
options={selectItems}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Provider = ({ children }: ProviderProps) => children;
|
||||
|
||||
const widgets = {
|
||||
TextWidget,
|
||||
TextAreaWidget,
|
||||
SelectWidget,
|
||||
NumberWidget,
|
||||
MultiSelectWidget,
|
||||
FieldSelect,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Conjs,
|
||||
Provider,
|
||||
};
|
||||
|
||||
export default widgets;
|
||||
Reference in New Issue
Block a user