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:
neo773
2023-08-31 18:57:33 +00:00
committed by GitHub
co-authored by Udit Takkar Peer Richelsen
parent 781a0f4a7c
commit acaa79b0b8
17 changed files with 723 additions and 286 deletions
+3 -12
View File
@@ -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}
@@ -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;
+2
View File
@@ -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";
+2
View File
@@ -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";