feat: sorting for workflow and routing forms (#10780)
Co-authored-by: Udit Takkar <udit.07814802719@cse.mait.ac.in> Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
co-authored by
Udit Takkar
Peer Richelsen
parent
781a0f4a7c
commit
acaa79b0b8
@@ -52,10 +52,9 @@ import {
|
||||
Skeleton,
|
||||
Switch,
|
||||
Tooltip,
|
||||
ArrowButton,
|
||||
} from "@calcom/ui";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Clipboard,
|
||||
Code,
|
||||
Copy,
|
||||
@@ -378,19 +377,11 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
<div className="hover:bg-muted flex w-full items-center justify-between">
|
||||
<div className="group flex w-full max-w-full items-center justify-between overflow-hidden px-4 py-4 sm:px-6">
|
||||
{!(firstItem && firstItem.id === type.id) && (
|
||||
<button
|
||||
className="bg-default text-muted hover:text-emphasis border-default hover:border-emphasis invisible absolute left-[5px] -ml-4 -mt-4 mb-4 hidden h-6 w-6 scale-0 items-center justify-center rounded-md border p-1 transition-all group-hover:visible group-hover:scale-100 sm:ml-0 sm:flex lg:left-[36px]"
|
||||
onClick={() => moveEventType(index, -1)}>
|
||||
<ArrowUp className="h-5 w-5" />
|
||||
</button>
|
||||
<ArrowButton onClick={() => moveEventType(index, -1)} arrowDirection="up" />
|
||||
)}
|
||||
|
||||
{!(lastItem && lastItem.id === type.id) && (
|
||||
<button
|
||||
className="bg-default text-muted border-default hover:text-emphasis hover:border-emphasis invisible absolute left-[5px] -ml-4 mt-8 hidden h-6 w-6 scale-0 items-center justify-center rounded-md border p-1 transition-all group-hover:visible group-hover:scale-100 sm:ml-0 sm:flex lg:left-[36px]"
|
||||
onClick={() => moveEventType(index, 1)}>
|
||||
<ArrowDown className="h-5 w-5" />
|
||||
</button>
|
||||
<ArrowButton onClick={() => moveEventType(index, 1)} arrowDirection="down" />
|
||||
)}
|
||||
<MemoizedItem type={type} group={group} readOnly={readOnly} />
|
||||
<div className="mt-4 hidden sm:mt-0 sm:flex">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// TODO: i18n
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useEffect } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
List,
|
||||
ListLinkItem,
|
||||
Tooltip,
|
||||
ArrowButton,
|
||||
} from "@calcom/ui";
|
||||
import {
|
||||
BarChart,
|
||||
@@ -83,6 +85,20 @@ export default function RoutingForms({
|
||||
const { hasPaidPlan } = useHasPaidPlan();
|
||||
const routerQuery = useRouterQuery();
|
||||
const hookForm = useFormContext<RoutingFormWithResponseCount>();
|
||||
const utils = trpc.useContext();
|
||||
const [parent] = useAutoAnimate<HTMLUListElement>();
|
||||
|
||||
const mutation = trpc.viewer.routingFormOrder.useMutation({
|
||||
onError: async (err) => {
|
||||
console.error(err.message);
|
||||
await utils.viewer.appRoutingForms.forms.cancel();
|
||||
await utils.viewer.appRoutingForms.invalidate();
|
||||
},
|
||||
onSettled: () => {
|
||||
utils.viewer.appRoutingForms.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
hookForm.reset({});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -128,6 +144,29 @@ export default function RoutingForms({
|
||||
},
|
||||
];
|
||||
|
||||
async function moveRoutingForm(index: number, increment: 1 | -1) {
|
||||
const types = forms?.map((type) => {
|
||||
return type.form;
|
||||
});
|
||||
|
||||
if (types?.length) {
|
||||
const newList = [...types];
|
||||
|
||||
const type = types[index];
|
||||
const tmp = types[index + increment];
|
||||
if (tmp) {
|
||||
newList[index] = tmp;
|
||||
newList[index + increment] = type;
|
||||
}
|
||||
|
||||
await utils.viewer.appRoutingForms.forms.cancel();
|
||||
|
||||
mutation.mutate({
|
||||
ids: newList?.map((type) => type.id),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<LicenseRequired>
|
||||
<ShellMain
|
||||
@@ -177,8 +216,8 @@ export default function RoutingForms({
|
||||
}
|
||||
SkeletonLoader={SkeletonLoaderTeamList}>
|
||||
<div className="bg-default mb-16 overflow-hidden">
|
||||
<List data-testid="routing-forms-list">
|
||||
{forms?.map(({ form, readOnly }) => {
|
||||
<List data-testid="routing-forms-list" ref={parent}>
|
||||
{forms?.map(({ form, readOnly }, index) => {
|
||||
if (!form) {
|
||||
return null;
|
||||
}
|
||||
@@ -187,116 +226,129 @@ export default function RoutingForms({
|
||||
form.routes = form.routes || [];
|
||||
const fields = form.fields || [];
|
||||
const userRoutes = form.routes.filter((route) => !isFallbackRoute(route));
|
||||
const firstItem = forms[0].form;
|
||||
const lastItem = forms[forms.length - 1].form;
|
||||
|
||||
return (
|
||||
<ListLinkItem
|
||||
key={form.id}
|
||||
href={appUrl + "/form-edit/" + form.id}
|
||||
heading={form.name}
|
||||
disabled={readOnly}
|
||||
subHeading={description}
|
||||
className="space-x-2 rtl:space-x-reverse"
|
||||
actions={
|
||||
<>
|
||||
{form.team?.name && (
|
||||
<div className="border-r-2 border-neutral-300">
|
||||
<Badge className="ltr:mr-2 rtl:ml-2" variant="gray">
|
||||
{form.team.name}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
<FormAction
|
||||
disabled={readOnly}
|
||||
className="self-center"
|
||||
action="toggle"
|
||||
routingForm={form}
|
||||
/>
|
||||
<ButtonGroup combined>
|
||||
<Tooltip content={t("preview")}>
|
||||
<div
|
||||
className="group flex w-full max-w-full items-center justify-between overflow-hidden"
|
||||
key={form.id}>
|
||||
{!(firstItem && firstItem.id === form.id) && (
|
||||
<ArrowButton onClick={() => moveRoutingForm(index, -1)} arrowDirection="up" />
|
||||
)}
|
||||
|
||||
{!(lastItem && lastItem.id === form.id) && (
|
||||
<ArrowButton onClick={() => moveRoutingForm(index, 1)} arrowDirection="down" />
|
||||
)}
|
||||
<ListLinkItem
|
||||
href={appUrl + "/form-edit/" + form.id}
|
||||
heading={form.name}
|
||||
disabled={readOnly}
|
||||
subHeading={description}
|
||||
className="space-x-2 rtl:space-x-reverse"
|
||||
actions={
|
||||
<>
|
||||
{form.team?.name && (
|
||||
<div className="border-r-2 border-neutral-300">
|
||||
<Badge className="ltr:mr-2 rtl:ml-2" variant="gray">
|
||||
{form.team.name}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
<FormAction
|
||||
disabled={readOnly}
|
||||
className="self-center"
|
||||
action="toggle"
|
||||
routingForm={form}
|
||||
/>
|
||||
<ButtonGroup combined>
|
||||
<Tooltip content={t("preview")}>
|
||||
<FormAction
|
||||
action="preview"
|
||||
routingForm={form}
|
||||
target="_blank"
|
||||
StartIcon={ExternalLink}
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
/>
|
||||
</Tooltip>
|
||||
<FormAction
|
||||
action="preview"
|
||||
routingForm={form}
|
||||
target="_blank"
|
||||
StartIcon={ExternalLink}
|
||||
action="copyLink"
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon={LinkIcon}
|
||||
tooltip={t("copy_link_to_form")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<FormAction
|
||||
routingForm={form}
|
||||
action="copyLink"
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon={LinkIcon}
|
||||
tooltip={t("copy_link_to_form")}
|
||||
/>
|
||||
<FormAction
|
||||
routingForm={form}
|
||||
action="embed"
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon={Code}
|
||||
tooltip={t("embed")}
|
||||
/>
|
||||
<FormActionsDropdown disabled={readOnly}>
|
||||
<FormAction
|
||||
action="edit"
|
||||
routingForm={form}
|
||||
color="minimal"
|
||||
className="!flex"
|
||||
StartIcon={Edit}>
|
||||
{t("edit")}
|
||||
</FormAction>
|
||||
<FormAction
|
||||
action="download"
|
||||
routingForm={form}
|
||||
color="minimal"
|
||||
StartIcon={Download}>
|
||||
{t("download_responses")}
|
||||
</FormAction>
|
||||
<FormAction
|
||||
action="duplicate"
|
||||
routingForm={form}
|
||||
color="minimal"
|
||||
className="w-full"
|
||||
StartIcon={Copy}>
|
||||
{t("duplicate")}
|
||||
</FormAction>
|
||||
{typeformApp?.isInstalled ? (
|
||||
action="embed"
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon={Code}
|
||||
tooltip={t("embed")}
|
||||
/>
|
||||
<FormActionsDropdown disabled={readOnly}>
|
||||
<FormAction
|
||||
data-testid="copy-redirect-url"
|
||||
action="edit"
|
||||
routingForm={form}
|
||||
action="copyRedirectUrl"
|
||||
color="minimal"
|
||||
type="button"
|
||||
StartIcon={LinkIcon}>
|
||||
{t("Copy Typeform Redirect Url")}
|
||||
className="!flex"
|
||||
StartIcon={Edit}>
|
||||
{t("edit")}
|
||||
</FormAction>
|
||||
) : null}
|
||||
<FormAction
|
||||
action="_delete"
|
||||
routingForm={form}
|
||||
color="destructive"
|
||||
className="w-full"
|
||||
StartIcon={Trash}>
|
||||
{t("delete")}
|
||||
</FormAction>
|
||||
</FormActionsDropdown>
|
||||
</ButtonGroup>
|
||||
</>
|
||||
}>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Badge variant="gray" startIcon={Menu}>
|
||||
{fields.length} {fields.length === 1 ? "field" : "fields"}
|
||||
</Badge>
|
||||
<Badge variant="gray" startIcon={GitMerge}>
|
||||
{userRoutes.length} {userRoutes.length === 1 ? "route" : "routes"}
|
||||
</Badge>
|
||||
<Badge variant="gray" startIcon={MessageCircle}>
|
||||
{form._count.responses}{" "}
|
||||
{form._count.responses === 1 ? "response" : "responses"}
|
||||
</Badge>
|
||||
</div>
|
||||
</ListLinkItem>
|
||||
<FormAction
|
||||
action="download"
|
||||
routingForm={form}
|
||||
color="minimal"
|
||||
StartIcon={Download}>
|
||||
{t("download_responses")}
|
||||
</FormAction>
|
||||
<FormAction
|
||||
action="duplicate"
|
||||
routingForm={form}
|
||||
color="minimal"
|
||||
className="w-full"
|
||||
StartIcon={Copy}>
|
||||
{t("duplicate")}
|
||||
</FormAction>
|
||||
{typeformApp?.isInstalled ? (
|
||||
<FormAction
|
||||
data-testid="copy-redirect-url"
|
||||
routingForm={form}
|
||||
action="copyRedirectUrl"
|
||||
color="minimal"
|
||||
type="button"
|
||||
StartIcon={LinkIcon}>
|
||||
{t("Copy Typeform Redirect Url")}
|
||||
</FormAction>
|
||||
) : null}
|
||||
<FormAction
|
||||
action="_delete"
|
||||
routingForm={form}
|
||||
color="destructive"
|
||||
className="w-full"
|
||||
StartIcon={Trash}>
|
||||
{t("delete")}
|
||||
</FormAction>
|
||||
</FormActionsDropdown>
|
||||
</ButtonGroup>
|
||||
</>
|
||||
}>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Badge variant="gray" startIcon={Menu}>
|
||||
{fields.length} {fields.length === 1 ? "field" : "fields"}
|
||||
</Badge>
|
||||
<Badge variant="gray" startIcon={GitMerge}>
|
||||
{userRoutes.length} {userRoutes.length === 1 ? "route" : "routes"}
|
||||
</Badge>
|
||||
<Badge variant="gray" startIcon={MessageCircle}>
|
||||
{form._count.responses}{" "}
|
||||
{form._count.responses === 1 ? "response" : "responses"}
|
||||
</Badge>
|
||||
</div>
|
||||
</ListLinkItem>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
|
||||
@@ -21,7 +21,7 @@ test.describe("Routing Forms", () => {
|
||||
|
||||
await page.waitForSelector('[data-testid="routing-forms-list"]');
|
||||
// Ensure that it's visible in forms list
|
||||
expect(await page.locator('[data-testid="routing-forms-list"] > li').count()).toBe(1);
|
||||
expect(await page.locator('[data-testid="routing-forms-list"] > div').count()).toBe(1);
|
||||
|
||||
await gotoRoutingLink({ page, formId });
|
||||
await expect(page.locator("text=Test Form Name")).toBeVisible();
|
||||
|
||||
@@ -59,6 +59,7 @@ export const formMutationHandler = async ({ ctx, input }: FormMutationHandlerOpt
|
||||
fields: true,
|
||||
settings: true,
|
||||
teamId: true,
|
||||
position: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -26,9 +26,14 @@ export const formsHandler = async ({ ctx, input }: FormsHandlerOptions) => {
|
||||
|
||||
const forms = await prisma.app_RoutingForms_Form.findMany({
|
||||
where,
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
position: "desc",
|
||||
},
|
||||
{
|
||||
createdAt: "asc",
|
||||
},
|
||||
],
|
||||
include: {
|
||||
team: {
|
||||
include: {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import type { Workflow, WorkflowStep, Membership } from "@prisma/client";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -19,8 +20,14 @@ import {
|
||||
Tooltip,
|
||||
Badge,
|
||||
Avatar,
|
||||
ArrowButton
|
||||
} from "@calcom/ui";
|
||||
import { Edit2, Link as LinkIcon, MoreHorizontal, Trash2 } from "@calcom/ui/components/icon";
|
||||
import {
|
||||
Edit2,
|
||||
Link as LinkIcon,
|
||||
MoreHorizontal,
|
||||
Trash2,
|
||||
} from "@calcom/ui/components/icon";
|
||||
|
||||
import { useOrgBranding } from "../../organizations/context/provider";
|
||||
import { subdomainSuffix } from "../../organizations/lib/orgDomains";
|
||||
@@ -56,192 +63,240 @@ export default function WorkflowListPage({ workflows }: Props) {
|
||||
const utils = trpc.useContext();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [workflowToDeleteId, setwWorkflowToDeleteId] = useState(0);
|
||||
const [parent] = useAutoAnimate<HTMLUListElement>();
|
||||
const router = useRouter();
|
||||
|
||||
const orgBranding = useOrgBranding();
|
||||
const urlPrefix = orgBranding ? `${orgBranding.slug}.${subdomainSuffix()}` : CAL_URL;
|
||||
|
||||
const mutation = trpc.viewer.workflowOrder.useMutation({
|
||||
onError: async (err) => {
|
||||
console.error(err.message);
|
||||
await utils.viewer.workflows.filteredList.cancel();
|
||||
await utils.viewer.workflows.filteredList.invalidate();
|
||||
},
|
||||
onSettled: () => {
|
||||
utils.viewer.workflows.filteredList.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
async function moveWorkflow(index: number, increment: 1 | -1) {
|
||||
const types = workflows!;
|
||||
|
||||
const newList = [...types];
|
||||
|
||||
const type = types[index];
|
||||
const tmp = types[index + increment];
|
||||
if (tmp) {
|
||||
newList[index] = tmp;
|
||||
newList[index + increment] = type;
|
||||
}
|
||||
|
||||
await utils.viewer.appRoutingForms.forms.cancel();
|
||||
|
||||
mutation.mutate({
|
||||
ids: newList?.map((type) => type.id),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{workflows && workflows.length > 0 ? (
|
||||
<div className="bg-default border-subtle overflow-hidden rounded-md border sm:mx-0">
|
||||
<ul className="divide-subtle divide-y" data-testid="workflow-list">
|
||||
{workflows.map((workflow) => (
|
||||
<li key={workflow.id}>
|
||||
<div className="first-line:group hover:bg-muted flex w-full items-center justify-between p-4 sm:px-6">
|
||||
<Link href={"/workflows/" + workflow.id} className="flex-grow cursor-pointer">
|
||||
<div className="rtl:space-x-reverse">
|
||||
<div className="flex">
|
||||
<div
|
||||
className={classNames(
|
||||
"max-w-56 text-emphasis truncate text-sm font-medium leading-6 md:max-w-max",
|
||||
workflow.name ? "text-emphasis" : "text-subtle"
|
||||
)}>
|
||||
{workflow.name
|
||||
? workflow.name
|
||||
: workflow.steps[0]
|
||||
? "Untitled (" +
|
||||
`${t(`${workflow.steps[0].action.toLowerCase()}_action`)}`
|
||||
.charAt(0)
|
||||
.toUpperCase() +
|
||||
`${t(`${workflow.steps[0].action.toLowerCase()}_action`)}`.slice(1) +
|
||||
")"
|
||||
: "Untitled"}
|
||||
<ul className="divide-subtle !static w-full divide-y" data-testid="workflow-list" ref={parent}>
|
||||
{workflows.map((workflow, index) => {
|
||||
const firstItem = workflows[0];
|
||||
const lastItem = workflows[workflows.length - 1];
|
||||
return (
|
||||
<li
|
||||
key={workflow.id}
|
||||
className="group flex w-full max-w-full items-center justify-between overflow-hidden">
|
||||
{!(firstItem && firstItem.id === workflow.id) && (
|
||||
<ArrowButton onClick={() => moveWorkflow(index, -1)} arrowDirection="up" />
|
||||
)}
|
||||
{!(lastItem && lastItem.id === workflow.id) && (
|
||||
<ArrowButton onClick={() => moveWorkflow(index, 1)} arrowDirection="down" />
|
||||
)}
|
||||
<div className="first-line:group hover:bg-muted flex w-full items-center justify-between p-4 sm:px-6">
|
||||
<Link href={"/workflows/" + workflow.id} className="flex-grow cursor-pointer">
|
||||
<div className="rtl:space-x-reverse">
|
||||
<div className="flex">
|
||||
<div
|
||||
className={classNames(
|
||||
"max-w-56 text-emphasis truncate text-sm font-medium leading-6 md:max-w-max",
|
||||
workflow.name ? "text-emphasis" : "text-subtle"
|
||||
)}>
|
||||
{workflow.name
|
||||
? workflow.name
|
||||
: workflow.steps[0]
|
||||
? "Untitled (" +
|
||||
`${t(`${workflow.steps[0].action.toLowerCase()}_action`)}`
|
||||
.charAt(0)
|
||||
.toUpperCase() +
|
||||
`${t(`${workflow.steps[0].action.toLowerCase()}_action`)}`.slice(1) +
|
||||
")"
|
||||
: "Untitled"}
|
||||
</div>
|
||||
<div>
|
||||
{workflow.readOnly && (
|
||||
<Badge variant="gray" className="ml-2 ">
|
||||
{t("readonly")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{workflow.readOnly && (
|
||||
<Badge variant="gray" className="ml-2 ">
|
||||
{t("readonly")}
|
||||
|
||||
<ul className="mt-1 flex flex-wrap space-x-2 sm:flex-nowrap ">
|
||||
<li>
|
||||
<Badge variant="gray">
|
||||
<div>
|
||||
{getActionIcon(workflow.steps)}
|
||||
|
||||
<span className="mr-1">{t("triggers")}</span>
|
||||
{workflow.timeUnit && workflow.time && (
|
||||
<span className="mr-1">
|
||||
{t(`${workflow.timeUnit.toLowerCase()}`, { count: workflow.time })}
|
||||
</span>
|
||||
)}
|
||||
<span>{t(`${workflow.trigger.toLowerCase()}_trigger`)}</span>
|
||||
</div>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="mt-1 flex flex-wrap space-x-2 sm:flex-nowrap ">
|
||||
<li>
|
||||
<Badge variant="gray">
|
||||
<div>
|
||||
{getActionIcon(workflow.steps)}
|
||||
|
||||
<span className="mr-1">{t("triggers")}</span>
|
||||
{workflow.timeUnit && workflow.time && (
|
||||
<span className="mr-1">
|
||||
{t(`${workflow.timeUnit.toLowerCase()}`, { count: workflow.time })}
|
||||
</span>
|
||||
)}
|
||||
<span>{t(`${workflow.trigger.toLowerCase()}_trigger`)}</span>
|
||||
</div>
|
||||
</Badge>
|
||||
</li>
|
||||
<li>
|
||||
<Badge variant="gray">
|
||||
{workflow.activeOn && workflow.activeOn.length > 0 ? (
|
||||
<Tooltip
|
||||
content={workflow.activeOn
|
||||
.filter((wf) => (workflow.teamId ? wf.eventType.parentId === null : true))
|
||||
.map((activeOn, key) => (
|
||||
<p key={key}>
|
||||
{activeOn.eventType.title}
|
||||
{activeOn.eventType._count.children > 0
|
||||
? ` (+${activeOn.eventType._count.children})`
|
||||
: ""}
|
||||
</p>
|
||||
))}>
|
||||
</li>
|
||||
<li>
|
||||
<Badge variant="gray">
|
||||
{workflow.activeOn && workflow.activeOn.length > 0 ? (
|
||||
<Tooltip
|
||||
content={workflow.activeOn
|
||||
.filter((wf) => (workflow.teamId ? wf.eventType.parentId === null : true))
|
||||
.map((activeOn, key) => (
|
||||
<p key={key}>
|
||||
{activeOn.eventType.title}
|
||||
{activeOn.eventType._count.children > 0
|
||||
? ` (+${activeOn.eventType._count.children})`
|
||||
: ""}
|
||||
</p>
|
||||
))}>
|
||||
<div>
|
||||
<LinkIcon className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
|
||||
{t("active_on_event_types", {
|
||||
count: workflow.activeOn.filter((wf) =>
|
||||
workflow.teamId ? wf.eventType.parentId === null : true
|
||||
).length,
|
||||
})}
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div>
|
||||
<LinkIcon className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
|
||||
{t("active_on_event_types", {
|
||||
count: workflow.activeOn.filter((wf) =>
|
||||
workflow.teamId ? wf.eventType.parentId === null : true
|
||||
).length,
|
||||
})}
|
||||
{t("no_active_event_types")}
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div>
|
||||
<LinkIcon className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
|
||||
{t("no_active_event_types")}
|
||||
</div>
|
||||
)}
|
||||
</Badge>
|
||||
</li>
|
||||
<div className="block md:hidden">
|
||||
{workflow.team?.name && (
|
||||
<li>
|
||||
<Badge variant="gray">
|
||||
<>{workflow.team.name}</>
|
||||
</Badge>
|
||||
</li>
|
||||
)}
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
</Link>
|
||||
<div>
|
||||
<div className="hidden md:block">
|
||||
{workflow.team?.name && (
|
||||
<Badge className="mr-4 mt-1 p-[1px] px-2" variant="gray">
|
||||
<Avatar
|
||||
alt={workflow.team?.name || ""}
|
||||
href={
|
||||
workflow.team?.id
|
||||
? `/settings/teams/${workflow.team?.id}/profile`
|
||||
: "/settings/my-account/profile"
|
||||
}
|
||||
imageSrc={getPlaceholderAvatar(
|
||||
workflow?.team.logo,
|
||||
workflow.team?.name as string
|
||||
)}
|
||||
size="xxs"
|
||||
className="mt-[3px] inline-flex justify-center"
|
||||
/>
|
||||
<div>{workflow.team.name}</div>
|
||||
</Badge>
|
||||
</li>
|
||||
<div className="block md:hidden">
|
||||
{workflow.team?.name && (
|
||||
<li>
|
||||
<Badge variant="gray">
|
||||
<>{workflow.team.name}</>
|
||||
</Badge>
|
||||
</li>
|
||||
)}
|
||||
</div>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<div>
|
||||
<div className="hidden md:block">
|
||||
{workflow.team?.name && (
|
||||
<Badge className="mr-4 mt-1 p-[1px] px-2" variant="gray">
|
||||
<Avatar
|
||||
alt={workflow.team?.name || ""}
|
||||
href={
|
||||
workflow.team?.id
|
||||
? `/settings/teams/${workflow.team?.id}/profile`
|
||||
: "/settings/my-account/profile"
|
||||
}
|
||||
imageSrc={getPlaceholderAvatar(
|
||||
workflow?.team.logo,
|
||||
workflow.team?.name as string
|
||||
)}
|
||||
size="xxs"
|
||||
className="mt-[3px] inline-flex justify-center"
|
||||
/>
|
||||
<div>{workflow.team.name}</div>
|
||||
</Badge>
|
||||
|
||||
<div className="flex flex-shrink-0">
|
||||
<div className="hidden sm:block">
|
||||
<ButtonGroup combined>
|
||||
<Tooltip content={t("edit") as string}>
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon={Edit2}
|
||||
disabled={workflow.readOnly}
|
||||
onClick={async () => await router.replace("/workflows/" + workflow.id)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content={t("delete") as string}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(true);
|
||||
setwWorkflowToDeleteId(workflow.id);
|
||||
}}
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
disabled={workflow.readOnly}
|
||||
StartIcon={Trash2}
|
||||
/>
|
||||
</Tooltip>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
{!workflow.readOnly && (
|
||||
<div className="block sm:hidden">
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
variant="icon"
|
||||
StartIcon={MoreHorizontal}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
StartIcon={Edit2}
|
||||
onClick={async () => await router.replace("/workflows/" + workflow.id)}>
|
||||
{t("edit")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color="destructive"
|
||||
StartIcon={Trash2}
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(true);
|
||||
setwWorkflowToDeleteId(workflow.id);
|
||||
}}>
|
||||
{t("delete")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0">
|
||||
<div className="hidden sm:block">
|
||||
<ButtonGroup combined>
|
||||
<Tooltip content={t("edit") as string}>
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon={Edit2}
|
||||
disabled={workflow.readOnly}
|
||||
onClick={async () => await router.replace("/workflows/" + workflow.id)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content={t("delete") as string}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(true);
|
||||
setwWorkflowToDeleteId(workflow.id);
|
||||
}}
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
disabled={workflow.readOnly}
|
||||
StartIcon={Trash2}
|
||||
/>
|
||||
</Tooltip>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
{!workflow.readOnly && (
|
||||
<div className="block sm:hidden">
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" color="minimal" variant="icon" StartIcon={MoreHorizontal} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
StartIcon={Edit2}
|
||||
onClick={async () => await router.replace("/workflows/" + workflow.id)}>
|
||||
{t("edit")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color="destructive"
|
||||
StartIcon={Trash2}
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(true);
|
||||
setwWorkflowToDeleteId(workflow.id);
|
||||
}}>
|
||||
{t("delete")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<DeleteDialog
|
||||
isOpenDialog={deleteDialogOpen}
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "App_RoutingForms_Form" ADD COLUMN "position" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Workflow" ADD COLUMN "position" INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -662,6 +662,7 @@ model App {
|
||||
model App_RoutingForms_Form {
|
||||
id String @id @default(cuid())
|
||||
description String?
|
||||
position Int @default(0)
|
||||
routes Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -746,6 +747,7 @@ model WorkflowStep {
|
||||
|
||||
model Workflow {
|
||||
id Int @id @default(autoincrement())
|
||||
position Int @default(0)
|
||||
name String
|
||||
userId Int?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -11,10 +11,12 @@ import { ZGetCalVideoRecordingsInputSchema } from "./getCalVideoRecordings.schem
|
||||
import { ZGetDownloadLinkOfCalVideoRecordingsInputSchema } from "./getDownloadLinkOfCalVideoRecordings.schema";
|
||||
import { ZIntegrationsInputSchema } from "./integrations.schema";
|
||||
import { ZLocationOptionsInputSchema } from "./locationOptions.schema";
|
||||
import { ZRoutingFormOrderInputSchema } from "./routingFormOrder.schema";
|
||||
import { ZSetDestinationCalendarInputSchema } from "./setDestinationCalendar.schema";
|
||||
import { ZSubmitFeedbackInputSchema } from "./submitFeedback.schema";
|
||||
import { ZUpdateProfileInputSchema } from "./updateProfile.schema";
|
||||
import { ZUpdateUserDefaultConferencingAppInputSchema } from "./updateUserDefaultConferencingApp.schema";
|
||||
import { ZWorkflowOrderInputSchema } from "./workflowOrder.schema";
|
||||
|
||||
type AppsRouterHandlerCache = {
|
||||
me?: typeof import("./me.handler").meHandler;
|
||||
@@ -31,6 +33,8 @@ type AppsRouterHandlerCache = {
|
||||
stripeCustomer?: typeof import("./stripeCustomer.handler").stripeCustomerHandler;
|
||||
updateProfile?: typeof import("./updateProfile.handler").updateProfileHandler;
|
||||
eventTypeOrder?: typeof import("./eventTypeOrder.handler").eventTypeOrderHandler;
|
||||
routingFormOrder?: typeof import("./routingFormOrder.handler").routingFormOrderHandler;
|
||||
workflowOrder?: typeof import("./workflowOrder.handler").workflowOrderHandler;
|
||||
submitFeedback?: typeof import("./submitFeedback.handler").submitFeedbackHandler;
|
||||
locationOptions?: typeof import("./locationOptions.handler").locationOptionsHandler;
|
||||
deleteCredential?: typeof import("./deleteCredential.handler").deleteCredentialHandler;
|
||||
@@ -230,6 +234,34 @@ export const loggedInViewerRouter = router({
|
||||
return UNSTABLE_HANDLER_CACHE.eventTypeOrder({ ctx, input });
|
||||
}),
|
||||
|
||||
routingFormOrder: authedProcedure.input(ZRoutingFormOrderInputSchema).mutation(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.routingFormOrder) {
|
||||
UNSTABLE_HANDLER_CACHE.routingFormOrder = (
|
||||
await import("./routingFormOrder.handler")
|
||||
).routingFormOrderHandler;
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.routingFormOrder) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.routingFormOrder({ ctx, input });
|
||||
}),
|
||||
|
||||
workflowOrder: authedProcedure.input(ZWorkflowOrderInputSchema).mutation(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.workflowOrder) {
|
||||
UNSTABLE_HANDLER_CACHE.workflowOrder = (await import("./workflowOrder.handler")).workflowOrderHandler;
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.workflowOrder) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.workflowOrder({ ctx, input });
|
||||
}),
|
||||
|
||||
//Comment for PR: eventTypePosition is not used anywhere
|
||||
submitFeedback: authedProcedure.input(ZSubmitFeedbackInputSchema).mutation(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.submitFeedback) {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TRoutingFormOrderInputSchema } from "./routingFormOrder.schema";
|
||||
|
||||
type RoutingFormOrderOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TRoutingFormOrderInputSchema;
|
||||
};
|
||||
|
||||
export const routingFormOrderHandler = async ({ ctx, input }: RoutingFormOrderOptions) => {
|
||||
const { user } = ctx;
|
||||
|
||||
const forms = await prisma.app_RoutingForms_Form.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
userId: user.id,
|
||||
},
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId: user.id,
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
include: {
|
||||
team: {
|
||||
include: {
|
||||
members: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
responses: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const allFormIds = new Set(forms.map((form) => form.id));
|
||||
if (input.ids.some((id) => !allFormIds.has(id))) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
input.ids.reverse().map((id, position) => {
|
||||
return prisma.app_RoutingForms_Form.update({
|
||||
where: {
|
||||
id: id,
|
||||
},
|
||||
data: {
|
||||
position,
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZRoutingFormOrderInputSchema = z.object({
|
||||
ids: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type TRoutingFormOrderInputSchema = z.infer<typeof ZRoutingFormOrderInputSchema>;
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { TFormSchema } from "@calcom/app-store/routing-forms/trpc/forms.schema";
|
||||
import { hasFilter } from "@calcom/features/filters/lib/hasFilter";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import { entries } from "@calcom/prisma/zod-utils";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TWorkflowOrderInputSchema } from "./workflowOrder.schema";
|
||||
|
||||
type RoutingFormOrderOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TWorkflowOrderInputSchema;
|
||||
};
|
||||
|
||||
export const workflowOrderHandler = async ({ ctx, input }: RoutingFormOrderOptions) => {
|
||||
const { user } = ctx;
|
||||
|
||||
const includedFields = {
|
||||
activeOn: {
|
||||
select: {
|
||||
eventType: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
parentId: true,
|
||||
_count: {
|
||||
select: {
|
||||
children: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
steps: true,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
name: true,
|
||||
members: true,
|
||||
logo: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const allWorkflows = await prisma.workflow.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
userId: user.id,
|
||||
},
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId: user.id,
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
include: includedFields,
|
||||
orderBy: [
|
||||
{
|
||||
position: "desc",
|
||||
},
|
||||
{
|
||||
id: "asc",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const allWorkflowIds = new Set(allWorkflows.map((workflow) => workflow.id));
|
||||
if (input.ids.some((id) => !allWorkflowIds.has(id))) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
input.ids.reverse().map((id, position) => {
|
||||
return prisma.workflow.update({
|
||||
where: {
|
||||
id: id,
|
||||
},
|
||||
data: {
|
||||
position,
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
export function getPrismaWhereFromFilters(
|
||||
user: {
|
||||
id: number;
|
||||
},
|
||||
filters: NonNullable<TFormSchema>["filters"]
|
||||
) {
|
||||
const where = {
|
||||
OR: [] as Prisma.App_RoutingForms_FormWhereInput[],
|
||||
};
|
||||
|
||||
const prismaQueries: Record<
|
||||
keyof NonNullable<typeof filters>,
|
||||
(...args: [number[]]) => Prisma.App_RoutingForms_FormWhereInput
|
||||
> & {
|
||||
all: () => Prisma.App_RoutingForms_FormWhereInput;
|
||||
} = {
|
||||
userIds: (userIds: number[]) => ({
|
||||
userId: {
|
||||
in: userIds,
|
||||
},
|
||||
teamId: null,
|
||||
}),
|
||||
teamIds: (teamIds: number[]) => ({
|
||||
team: {
|
||||
id: {
|
||||
in: teamIds ?? [],
|
||||
},
|
||||
members: {
|
||||
some: {
|
||||
userId: user.id,
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
all: () => ({
|
||||
OR: [
|
||||
{
|
||||
userId: user.id,
|
||||
},
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId: user.id,
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
if (!filters || !hasFilter(filters)) {
|
||||
where.OR.push(prismaQueries.all());
|
||||
} else {
|
||||
for (const entry of entries(filters)) {
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
const [filterName, filter] = entry;
|
||||
const getPrismaQuery = prismaQueries[filterName];
|
||||
// filter might be accidentally set undefined as well
|
||||
if (!getPrismaQuery || !filter) {
|
||||
continue;
|
||||
}
|
||||
where.OR.push(getPrismaQuery(filter));
|
||||
}
|
||||
}
|
||||
|
||||
return where;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZWorkflowOrderInputSchema = z.object({
|
||||
ids: z.array(z.number()),
|
||||
});
|
||||
|
||||
export type TWorkflowOrderInputSchema = z.infer<typeof ZWorkflowOrderInputSchema>;
|
||||
@@ -70,9 +70,14 @@ export const filteredListHandler = async ({ ctx, input }: FilteredListOptions) =
|
||||
],
|
||||
},
|
||||
include: includedFields,
|
||||
orderBy: {
|
||||
id: "asc",
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
position: "desc",
|
||||
},
|
||||
{
|
||||
id: "asc",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!filtered) {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ArrowUp, ArrowDown } from "@calcom/ui/components/icon";
|
||||
|
||||
export type ArrowButtonProps = {
|
||||
arrowDirection: "up" | "down";
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export function ArrowButton(props: ArrowButtonProps) {
|
||||
return (
|
||||
<>
|
||||
{props.arrowDirection === "up" ? (
|
||||
<button
|
||||
className="bg-default text-muted hover:text-emphasis border-default hover:border-emphasis invisible absolute left-[5px] -ml-4 -mt-4 mb-4 hidden h-6 w-6 scale-0 items-center justify-center rounded-md border p-1 transition-all group-hover:visible group-hover:scale-100 sm:ml-0 sm:flex lg:left-[36px]"
|
||||
onClick={props.onClick}>
|
||||
<ArrowUp className="h-5 w-5" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="bg-default text-muted border-default hover:text-emphasis hover:border-emphasis invisible absolute left-[5px] -ml-4 mt-8 hidden h-6 w-6 scale-0 items-center justify-center rounded-md border p-1 transition-all group-hover:visible group-hover:scale-100 sm:ml-0 sm:flex lg:left-[36px]"
|
||||
onClick={props.onClick}>
|
||||
<ArrowDown className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ArrowButton } from "./ArrowButton";
|
||||
export type { ArrowButtonProps } from "./ArrowButton";
|
||||
@@ -1,5 +1,7 @@
|
||||
export { Avatar, AvatarGroup } from "./components/avatar";
|
||||
export type { AvatarProps, AvatarGroupProps } from "./components/avatar";
|
||||
export { ArrowButton } from "./components/arrow-button";
|
||||
export type { ArrowButtonProps } from "./components/arrow-button";
|
||||
export { Badge, UpgradeTeamsBadge } from "./components/badge";
|
||||
export type { BadgeProps } from "./components/badge";
|
||||
export { Breadcrumb, BreadcrumbContainer, BreadcrumbItem } from "./components/breadcrumb";
|
||||
|
||||
Reference in New Issue
Block a user