2
0

first commit

This commit is contained in:
2024-08-09 00:39:27 +02:00
commit 79688abe2e
5698 changed files with 497838 additions and 0 deletions

View File

@@ -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>
);
});

View File

@@ -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>
);
})}
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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 };

View File

@@ -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),
}),
},
};
};

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;