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`)}
+
- )}
-
-
-
-
+
+
+
+
+ {workflow.team?.name && (
+
+
+ {workflow.team.name}
-
-
- {workflow.team?.name && (
-
-
-
- <>{workflow.team.name}>
-
-
- )}
-
-
+ )}
+
-
-
-
- {workflow.team?.name && (
-
-
- {workflow.team.name}
-
+
+
+
+
+
+
+
+
+
+
+ {!workflow.readOnly && (
+
+
+
+
+
+
+
+ await router.replace("/workflows/" + workflow.id)}>
+ {t("edit")}
+
+
+
+ {
+ setDeleteDialogOpen(true);
+ setwWorkflowToDeleteId(workflow.id);
+ }}>
+ {t("delete")}
+
+
+
+
+
)}
-
-
-
-
-
-
-
-
-
-
- {!workflow.readOnly && (
-
-
-
-
-
-
-
- await router.replace("/workflows/" + workflow.id)}>
- {t("edit")}
-
-
-
- {
- setDeleteDialogOpen(true);
- setwWorkflowToDeleteId(workflow.id);
- }}>
- {t("delete")}
-
-
-
-
-
- )}
-
-
-
- ))}
+
+ );
+ })}
{
+ 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";