diff --git a/apps/web/pages/event-types/index.tsx b/apps/web/pages/event-types/index.tsx index e84fd54e86..154c9314b1 100644 --- a/apps/web/pages/event-types/index.tsx +++ b/apps/web/pages/event-types/index.tsx @@ -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
{!(firstItem && firstItem.id === type.id) && ( - + moveEventType(index, -1)} arrowDirection="up" /> )} {!(lastItem && lastItem.id === type.id) && ( - + moveEventType(index, 1)} arrowDirection="down" /> )}
diff --git a/packages/app-store/routing-forms/pages/forms/[...appPages].tsx b/packages/app-store/routing-forms/pages/forms/[...appPages].tsx index 9f7a7dca42..6c08123ad5 100644 --- a/packages/app-store/routing-forms/pages/forms/[...appPages].tsx +++ b/packages/app-store/routing-forms/pages/forms/[...appPages].tsx @@ -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(); + const utils = trpc.useContext(); + const [parent] = useAutoAnimate(); + + 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 (
- - {forms?.map(({ form, readOnly }) => { + + {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 ( - - {form.team?.name && ( -
- - {form.team.name} - -
- )} - - - +
+ {!(firstItem && firstItem.id === form.id) && ( + moveRoutingForm(index, -1)} arrowDirection="up" /> + )} + + {!(lastItem && lastItem.id === form.id) && ( + moveRoutingForm(index, 1)} arrowDirection="down" /> + )} + + {form.team?.name && ( +
+ + {form.team.name} + +
+ )} + + + + + - - - - - {t("edit")} - - - {t("download_responses")} - - - {t("duplicate")} - - {typeformApp?.isInstalled ? ( + action="embed" + color="secondary" + variant="icon" + StartIcon={Code} + tooltip={t("embed")} + /> + - {t("Copy Typeform Redirect Url")} + className="!flex" + StartIcon={Edit}> + {t("edit")} - ) : null} - - {t("delete")} - - - - - }> -
- - {fields.length} {fields.length === 1 ? "field" : "fields"} - - - {userRoutes.length} {userRoutes.length === 1 ? "route" : "routes"} - - - {form._count.responses}{" "} - {form._count.responses === 1 ? "response" : "responses"} - -
-
+ + {t("download_responses")} + + + {t("duplicate")} + + {typeformApp?.isInstalled ? ( + + {t("Copy Typeform Redirect Url")} + + ) : null} + + {t("delete")} + + + + + }> +
+ + {fields.length} {fields.length === 1 ? "field" : "fields"} + + + {userRoutes.length} {userRoutes.length === 1 ? "route" : "routes"} + + + {form._count.responses}{" "} + {form._count.responses === 1 ? "response" : "responses"} + +
+ +
); })}
diff --git a/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts b/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts index aad31dc55b..5878906607 100644 --- a/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts +++ b/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts @@ -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(); diff --git a/packages/app-store/routing-forms/trpc/formMutation.handler.ts b/packages/app-store/routing-forms/trpc/formMutation.handler.ts index dffd2486bf..951a107638 100644 --- a/packages/app-store/routing-forms/trpc/formMutation.handler.ts +++ b/packages/app-store/routing-forms/trpc/formMutation.handler.ts @@ -59,6 +59,7 @@ export const formMutationHandler = async ({ ctx, input }: FormMutationHandlerOpt fields: true, settings: true, teamId: true, + position: true, }, }); diff --git a/packages/app-store/routing-forms/trpc/forms.handler.ts b/packages/app-store/routing-forms/trpc/forms.handler.ts index a01cdca8b0..2cff781e76 100644 --- a/packages/app-store/routing-forms/trpc/forms.handler.ts +++ b/packages/app-store/routing-forms/trpc/forms.handler.ts @@ -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: { diff --git a/packages/features/ee/workflows/components/WorkflowListPage.tsx b/packages/features/ee/workflows/components/WorkflowListPage.tsx index 9131e69979..5a203a1548 100644 --- a/packages/features/ee/workflows/components/WorkflowListPage.tsx +++ b/packages/features/ee/workflows/components/WorkflowListPage.tsx @@ -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(); 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 ? (
-
    - {workflows.map((workflow) => ( -
  • -
    - -
    -
    -
    - {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"} +
      + {workflows.map((workflow, index) => { + const firstItem = workflows[0]; + const lastItem = workflows[workflows.length - 1]; + return ( +
    • + {!(firstItem && firstItem.id === workflow.id) && ( + moveWorkflow(index, -1)} arrowDirection="up" /> + )} + {!(lastItem && lastItem.id === workflow.id) && ( + moveWorkflow(index, 1)} arrowDirection="down" /> + )} +
      + +
      +
      +
      + {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"} +
      +
      + {workflow.readOnly && ( + + {t("readonly")} + + )} +
      -
      - {workflow.readOnly && ( - - {t("readonly")} + +
        +
      • + +
        + {getActionIcon(workflow.steps)} + + {t("triggers")} + {workflow.timeUnit && workflow.time && ( + + {t(`${workflow.timeUnit.toLowerCase()}`, { count: workflow.time })} + + )} + {t(`${workflow.trigger.toLowerCase()}_trigger`)} +
        - )} -
      -
      - -
        -
      • - -
        - {getActionIcon(workflow.steps)} - - {t("triggers")} - {workflow.timeUnit && workflow.time && ( - - {t(`${workflow.timeUnit.toLowerCase()}`, { count: workflow.time })} - - )} - {t(`${workflow.trigger.toLowerCase()}_trigger`)} -
        -
        -
      • -
      • - - {workflow.activeOn && workflow.activeOn.length > 0 ? ( - (workflow.teamId ? wf.eventType.parentId === null : true)) - .map((activeOn, key) => ( -

        - {activeOn.eventType.title} - {activeOn.eventType._count.children > 0 - ? ` (+${activeOn.eventType._count.children})` - : ""} -

        - ))}> +
      • +
      • + + {workflow.activeOn && workflow.activeOn.length > 0 ? ( + (workflow.teamId ? wf.eventType.parentId === null : true)) + .map((activeOn, key) => ( +

        + {activeOn.eventType.title} + {activeOn.eventType._count.children > 0 + ? ` (+${activeOn.eventType._count.children})` + : ""} +

        + ))}> +
        +
        +
        + ) : (
        - - ) : ( -
        -
        + )} +
        +
      • +
        + {workflow.team?.name && ( +
      • + + <>{workflow.team.name} + +
      • )} +
        +
      +
      + +
      +
      + {workflow.team?.name && ( + + +
      {workflow.team.name}
      -
    • -
      - {workflow.team?.name && ( -
    • - - <>{workflow.team.name} - -
    • - )} -
      -
    + )} +
    - -
    -
    - {workflow.team?.name && ( - - -
    {workflow.team.name}
    -
    + +
    +
    + + +
    + {!workflow.readOnly && ( +
    + + +
    )}
    - -
    -
    - - -
    - {!workflow.readOnly && ( -
    - - -
    - )} -
    -
    -
  • - ))} + + ); + })}
{ + 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) { diff --git a/packages/trpc/server/routers/loggedInViewer/routingFormOrder.handler.ts b/packages/trpc/server/routers/loggedInViewer/routingFormOrder.handler.ts new file mode 100644 index 0000000000..de6da5c966 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/routingFormOrder.handler.ts @@ -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; + }; + 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, + }, + }); + }) + ); +}; diff --git a/packages/trpc/server/routers/loggedInViewer/routingFormOrder.schema.ts b/packages/trpc/server/routers/loggedInViewer/routingFormOrder.schema.ts new file mode 100644 index 0000000000..014a14859d --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/routingFormOrder.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZRoutingFormOrderInputSchema = z.object({ + ids: z.array(z.string()), +}); + +export type TRoutingFormOrderInputSchema = z.infer; diff --git a/packages/trpc/server/routers/loggedInViewer/workflowOrder.handler.ts b/packages/trpc/server/routers/loggedInViewer/workflowOrder.handler.ts new file mode 100644 index 0000000000..552ebb1f6f --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/workflowOrder.handler.ts @@ -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; + }; + 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["filters"] +) { + const where = { + OR: [] as Prisma.App_RoutingForms_FormWhereInput[], + }; + + const prismaQueries: Record< + keyof NonNullable, + (...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; +} diff --git a/packages/trpc/server/routers/loggedInViewer/workflowOrder.schema.ts b/packages/trpc/server/routers/loggedInViewer/workflowOrder.schema.ts new file mode 100644 index 0000000000..695c0a56e1 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/workflowOrder.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZWorkflowOrderInputSchema = z.object({ + ids: z.array(z.number()), +}); + +export type TWorkflowOrderInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/workflows/filteredList.handler.tsx b/packages/trpc/server/routers/viewer/workflows/filteredList.handler.tsx index fc8197ed71..ef117f3241 100644 --- a/packages/trpc/server/routers/viewer/workflows/filteredList.handler.tsx +++ b/packages/trpc/server/routers/viewer/workflows/filteredList.handler.tsx @@ -70,9 +70,14 @@ export const filteredListHandler = async ({ ctx, input }: FilteredListOptions) = ], }, include: includedFields, - orderBy: { - id: "asc", - }, + orderBy: [ + { + position: "desc", + }, + { + id: "asc", + }, + ], }); if (!filtered) { diff --git a/packages/ui/components/arrow-button/ArrowButton.tsx b/packages/ui/components/arrow-button/ArrowButton.tsx new file mode 100644 index 0000000000..0abe2a8cfd --- /dev/null +++ b/packages/ui/components/arrow-button/ArrowButton.tsx @@ -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" ? ( + + ) : ( + + )} + + ); +} diff --git a/packages/ui/components/arrow-button/index.ts b/packages/ui/components/arrow-button/index.ts new file mode 100644 index 0000000000..d1912265db --- /dev/null +++ b/packages/ui/components/arrow-button/index.ts @@ -0,0 +1,2 @@ +export { ArrowButton } from "./ArrowButton"; +export type { ArrowButtonProps } from "./ArrowButton"; diff --git a/packages/ui/index.tsx b/packages/ui/index.tsx index 2362eadbc2..a6fe5d2626 100644 --- a/packages/ui/index.tsx +++ b/packages/ui/index.tsx @@ -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";