feat: Routing Forms/Teams Support (#9417)
This commit is contained in:
parent
e513180d7e
commit
53224886e3
|
@ -789,6 +789,7 @@ const CTA = () => {
|
|||
|
||||
return (
|
||||
<CreateButton
|
||||
data-testid="new-event-type"
|
||||
subtitle={t("create_event_on").toUpperCase()}
|
||||
options={profileOptions}
|
||||
createDialog={() => <CreateEventTypeDialog profileOptions={profileOptions} />}
|
||||
|
|
|
@ -195,5 +195,5 @@ export async function gotoRoutingLink({
|
|||
await page.goto(`${previewLink}${queryString ? `?${queryString}` : ""}`);
|
||||
|
||||
// HACK: There seems to be some issue with the inputs to the form getting reset if we don't wait.
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
|
|
@ -32,7 +32,7 @@ test.describe("Managed Event Types tests", () => {
|
|||
await page.waitForURL("/settings/teams/**");
|
||||
// Going to create an event type
|
||||
await page.goto("/event-types");
|
||||
await page.getByTestId("new-event-type-dropdown").click();
|
||||
await page.getByTestId("new-event-type").click();
|
||||
await page.getByTestId("option-team-1").click();
|
||||
// Expecting we can add a managed event type as team owner
|
||||
await expect(page.locator('button[value="MANAGED"]')).toBeVisible();
|
||||
|
|
|
@ -429,6 +429,8 @@ test.describe("FORM_SUBMITTED", async () => {
|
|||
await page.waitForLoadState("networkidle");
|
||||
await page.goto("/apps/routing-forms/forms");
|
||||
await page.click('[data-testid="new-routing-form"]');
|
||||
// Choose to create the Form for the user(which is the first option) and not the team
|
||||
await page.click('[data-testid="option-0"]');
|
||||
await page.fill("input[name]", "TEST FORM");
|
||||
await page.click('[data-testid="add-form"]');
|
||||
await page.waitForSelector('[data-testid="add-field"]');
|
||||
|
|
|
@ -238,6 +238,7 @@
|
|||
"done": "Done",
|
||||
"all_done": "All done!",
|
||||
"all_apps": "All",
|
||||
"all": "All",
|
||||
"yours":"Yours",
|
||||
"available_apps": "Available Apps",
|
||||
"check_email_reset_password": "Check your email. We sent you a link to reset your password.",
|
||||
|
@ -1339,6 +1340,7 @@
|
|||
"routing_forms_send_email_owner": "Send Email to Owner",
|
||||
"routing_forms_send_email_owner_description": "Sends an email to the owner when the form is submitted",
|
||||
"add_new_form": "Add new form",
|
||||
"add_new_team_form": "Add new form to your team",
|
||||
"create_your_first_route": "Create your first route",
|
||||
"route_to_the_right_person": "Route to the right person based on the answers to your form",
|
||||
"form_description": "Create your form to route a booker",
|
||||
|
@ -1629,6 +1631,8 @@
|
|||
"scheduler": "{Scheduler}",
|
||||
"no_workflows": "No workflows",
|
||||
"change_filter": "Change filter to see your personal and team workflows.",
|
||||
"change_filter_common":"Change filter to see the results.",
|
||||
"no_results_for_filter": "No results for the filter",
|
||||
"recommended_next_steps": "Recommended next steps",
|
||||
"create_a_managed_event": "Create a managed event type",
|
||||
"meetings_are_better_with_the_right": "Meetings are better with the right team members there. Invite them now.",
|
||||
|
@ -1641,6 +1645,7 @@
|
|||
"attendee_no_longer_attending": "An attendee is no longer attending your event",
|
||||
"attendee_no_longer_attending_subtitle": "{{name}} has cancelled. This means a seat has opened up for this time slot",
|
||||
"create_event_on": "Create event on",
|
||||
"create_routing_form_on": "Create routing form on",
|
||||
"default_app_link_title": "Set a default app link",
|
||||
"default_app_link_description": "Setting a default app link allows all newly created event types to use the app link you set.",
|
||||
"organizer_default_conferencing_app": "Organizer's default app",
|
||||
|
|
|
@ -28,6 +28,8 @@ export async function ssrInit(context: GetServerSidePropsContext) {
|
|||
await ssr.viewer.public.i18n.fetch();
|
||||
// So feature flags are available on first render
|
||||
await ssr.viewer.features.map.prefetch();
|
||||
// Provides a better UX to the users who have already upgraded.
|
||||
await ssr.viewer.teams.hasTeamPlan.prefetch();
|
||||
|
||||
return ssr;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import type { App_RoutingForms_Form } from "@prisma/client";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getSession } from "next-auth/react";
|
||||
|
||||
import { entityPrismaWhereClause, canEditEntity } from "@calcom/lib/entityPermissionUtils";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { getSerializableForm } from "../../lib/getSerializableForm";
|
||||
|
@ -55,6 +57,7 @@ async function* getResponses(
|
|||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { args } = req.query;
|
||||
|
||||
if (!args) {
|
||||
throw new Error("args must be set");
|
||||
}
|
||||
|
@ -63,16 +66,37 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
throw new Error("formId must be provided");
|
||||
}
|
||||
|
||||
const session = await getSession({ req });
|
||||
|
||||
if (!session) {
|
||||
return res.status(401).json({ message: "Unauthorized" });
|
||||
}
|
||||
|
||||
const { user } = session;
|
||||
|
||||
const form = await prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
id: formId,
|
||||
...entityPrismaWhereClause({ userId: user.id }),
|
||||
},
|
||||
include: {
|
||||
team: {
|
||||
select: {
|
||||
members: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!form) {
|
||||
throw new Error("Form not found");
|
||||
return res.status(404).json({ message: "Form not found or unauthorized" });
|
||||
}
|
||||
const serializableForm = await getSerializableForm(form, true);
|
||||
|
||||
if (!canEditEntity(form, user.id)) {
|
||||
return res.status(404).json({ message: "Form not found or unauthorized" });
|
||||
}
|
||||
|
||||
const serializableForm = await getSerializableForm({ form, withDeletedFields: true });
|
||||
res.setHeader("Content-Type", "text/csv; charset=UTF-8");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
|
|
|
@ -39,11 +39,11 @@ import type { SerializableForm } from "../types/types";
|
|||
type RoutingForm = SerializableForm<App_RoutingForms_Form>;
|
||||
|
||||
const newFormModalQuerySchema = z.object({
|
||||
action: z.string(),
|
||||
action: z.literal("new").or(z.literal("duplicate")),
|
||||
target: z.string().optional(),
|
||||
});
|
||||
|
||||
const openModal = (router: NextRouter, option: { target?: string; action: string }) => {
|
||||
const openModal = (router: NextRouter, option: z.infer<typeof newFormModalQuerySchema>) => {
|
||||
const query = {
|
||||
...router.query,
|
||||
dialog: "new-form",
|
||||
|
@ -68,8 +68,8 @@ function NewFormDialog({ appUrl }: { appUrl: string }) {
|
|||
onSuccess: (_data, variables) => {
|
||||
router.push(`${appUrl}/form-edit/${variables.id}`);
|
||||
},
|
||||
onError: () => {
|
||||
showToast(t("something_went_wrong"), "error");
|
||||
onError: (err) => {
|
||||
showToast(err.message || t("something_went_wrong"), "error");
|
||||
},
|
||||
onSettled: () => {
|
||||
utils.viewer.appRoutingForms.forms.invalidate();
|
||||
|
@ -84,13 +84,16 @@ function NewFormDialog({ appUrl }: { appUrl: string }) {
|
|||
|
||||
const { action, target } = router.query as z.infer<typeof newFormModalQuerySchema>;
|
||||
|
||||
const formToDuplicate = action === "duplicate" ? target : null;
|
||||
const teamId = action === "new" ? Number(target) : null;
|
||||
|
||||
const { register } = hookForm;
|
||||
return (
|
||||
<Dialog name="new-form" clearQueryParamsOnClose={["target", "action"]}>
|
||||
<DialogContent className="overflow-y-auto">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-emphasis text-lg font-bold leading-6" id="modal-title">
|
||||
{t("add_new_form")}
|
||||
{teamId ? t("add_new_team_form") : t("add_new_form")}
|
||||
</h3>
|
||||
<div>
|
||||
<p className="text-subtle text-sm">{t("form_description")}</p>
|
||||
|
@ -105,7 +108,8 @@ function NewFormDialog({ appUrl }: { appUrl: string }) {
|
|||
id: formId,
|
||||
...values,
|
||||
addFallback: true,
|
||||
duplicateFrom: action === "duplicate" ? target : null,
|
||||
teamId,
|
||||
duplicateFrom: formToDuplicate,
|
||||
});
|
||||
}}>
|
||||
<div className="mt-3 space-y-4">
|
||||
|
@ -151,12 +155,17 @@ function NewFormDialog({ appUrl }: { appUrl: string }) {
|
|||
|
||||
const dropdownCtx = createContext<{ dropdown: boolean }>({ dropdown: false });
|
||||
|
||||
export const FormActionsDropdown = ({ form, children }: { form: RoutingForm; children: React.ReactNode }) => {
|
||||
const { disabled } = form;
|
||||
export const FormActionsDropdown = ({
|
||||
children,
|
||||
disabled,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<dropdownCtx.Provider value={{ dropdown: true }}>
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger data-testid="form-dropdown" asChild>
|
||||
<DropdownMenuTrigger disabled={disabled} data-testid="form-dropdown" asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="icon"
|
||||
|
@ -190,15 +199,20 @@ function Dialogs({
|
|||
await utils.viewer.appRoutingForms.forms.cancel();
|
||||
const previousValue = utils.viewer.appRoutingForms.forms.getData();
|
||||
if (previousValue) {
|
||||
const filtered = previousValue.filter(({ id }) => id !== formId);
|
||||
utils.viewer.appRoutingForms.forms.setData(undefined, filtered);
|
||||
const filtered = previousValue.filtered.filter(({ form: { id } }) => id !== formId);
|
||||
utils.viewer.appRoutingForms.forms.setData(
|
||||
{},
|
||||
{
|
||||
...previousValue,
|
||||
filtered,
|
||||
}
|
||||
);
|
||||
}
|
||||
return { previousValue };
|
||||
},
|
||||
onSuccess: () => {
|
||||
showToast(t("form_deleted"), "success");
|
||||
setDeleteDialogOpen(false);
|
||||
router.replace(`${appUrl}/forms`);
|
||||
},
|
||||
onSettled: () => {
|
||||
utils.viewer.appRoutingForms.forms.invalidate();
|
||||
|
@ -206,7 +220,7 @@ function Dialogs({
|
|||
},
|
||||
onError: (err, newTodo, context) => {
|
||||
if (context?.previousValue) {
|
||||
utils.viewer.appRoutingForms.forms.setData(undefined, context.previousValue);
|
||||
utils.viewer.appRoutingForms.forms.setData({}, context.previousValue);
|
||||
}
|
||||
showToast(err.message || t("something_went_wrong"), "error");
|
||||
},
|
||||
|
@ -266,13 +280,19 @@ export function FormActionsProvider({ appUrl, children }: { appUrl: string; chil
|
|||
await utils.viewer.appRoutingForms.forms.cancel();
|
||||
const previousValue = utils.viewer.appRoutingForms.forms.getData();
|
||||
if (previousValue) {
|
||||
const itemIndex = previousValue.findIndex(({ id }) => id === formId);
|
||||
const prevValueTemp = [...previousValue];
|
||||
const formIndex = previousValue.filtered.findIndex(({ form: { id } }) => id === formId);
|
||||
const previousListOfForms = [...previousValue.filtered];
|
||||
|
||||
if (itemIndex !== -1 && prevValueTemp[itemIndex] && disabled !== undefined) {
|
||||
prevValueTemp[itemIndex].disabled = disabled;
|
||||
if (formIndex !== -1 && previousListOfForms[formIndex] && disabled !== undefined) {
|
||||
previousListOfForms[formIndex].form.disabled = disabled;
|
||||
}
|
||||
utils.viewer.appRoutingForms.forms.setData(undefined, prevValueTemp);
|
||||
utils.viewer.appRoutingForms.forms.setData(
|
||||
{},
|
||||
{
|
||||
filtered: previousListOfForms,
|
||||
totalCount: previousValue.totalCount,
|
||||
}
|
||||
);
|
||||
}
|
||||
return { previousValue };
|
||||
},
|
||||
|
@ -289,9 +309,9 @@ export function FormActionsProvider({ appUrl, children }: { appUrl: string; chil
|
|||
},
|
||||
onError: (err, value, context) => {
|
||||
if (context?.previousValue) {
|
||||
utils.viewer.appRoutingForms.forms.setData(undefined, context.previousValue);
|
||||
utils.viewer.appRoutingForms.forms.setData({}, context.previousValue);
|
||||
}
|
||||
showToast(t("something_went_wrong"), "error");
|
||||
showToast(err.message || t("something_went_wrong"), "error");
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -354,7 +374,12 @@ type FormActionProps<T> = {
|
|||
//TODO: Provide types here
|
||||
action: FormActionType;
|
||||
children?: React.ReactNode;
|
||||
render?: (props: { routingForm: RoutingForm | null; className?: string; label?: string }) => JSX.Element;
|
||||
render?: (props: {
|
||||
routingForm: RoutingForm | null;
|
||||
className?: string;
|
||||
label?: string;
|
||||
disabled?: boolean | null | undefined;
|
||||
}) => JSX.Element;
|
||||
extraClassNames?: string;
|
||||
} & ButtonProps;
|
||||
|
||||
|
@ -416,7 +441,7 @@ export const FormAction = forwardRef(function FormAction<T extends typeof Button
|
|||
loading: _delete.isLoading,
|
||||
},
|
||||
create: {
|
||||
onClick: () => openModal(router, { action: "new" }),
|
||||
onClick: () => createAction({ router, teamId: null }),
|
||||
},
|
||||
copyRedirectUrl: {
|
||||
onClick: () => {
|
||||
|
@ -425,7 +450,7 @@ export const FormAction = forwardRef(function FormAction<T extends typeof Button
|
|||
},
|
||||
},
|
||||
toggle: {
|
||||
render: ({ routingForm, label = "", ...restProps }) => {
|
||||
render: ({ routingForm, label = "", disabled, ...restProps }) => {
|
||||
if (!routingForm) {
|
||||
return <></>;
|
||||
}
|
||||
|
@ -437,6 +462,7 @@ export const FormAction = forwardRef(function FormAction<T extends typeof Button
|
|||
extraClassNames
|
||||
)}>
|
||||
<Switch
|
||||
disabled={!!disabled}
|
||||
checked={!routingForm.disabled}
|
||||
label={label}
|
||||
onCheckedChange={(checked) => toggle.onAction({ routingForm, checked })}
|
||||
|
@ -486,3 +512,7 @@ export const FormAction = forwardRef(function FormAction<T extends typeof Button
|
|||
</DropdownMenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
export const createAction = ({ router, teamId }: { router: NextRouter; teamId: number | null }) => {
|
||||
openModal(router, { action: "new", target: teamId ? String(teamId) : "" });
|
||||
};
|
||||
|
|
|
@ -6,7 +6,7 @@ export default function RoutingNavBar({
|
|||
form,
|
||||
appUrl,
|
||||
}: {
|
||||
form: ReturnType<typeof getSerializableForm>;
|
||||
form: Awaited<ReturnType<typeof getSerializableForm>>;
|
||||
appUrl: string;
|
||||
}) {
|
||||
const tabs = [
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import type { App_RoutingForms_Form } from "@prisma/client";
|
||||
import type { App_RoutingForms_Form, Team } from "@prisma/client";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
|
@ -55,6 +55,10 @@ import RoutingNavBar from "./RoutingNavBar";
|
|||
type RoutingForm = SerializableForm<App_RoutingForms_Form>;
|
||||
|
||||
export type RoutingFormWithResponseCount = RoutingForm & {
|
||||
team: {
|
||||
slug: Team["slug"];
|
||||
name: Team["name"];
|
||||
} | null;
|
||||
_count: {
|
||||
responses: number;
|
||||
};
|
||||
|
@ -132,7 +136,7 @@ const Actions = ({
|
|||
tooltip={t("delete")}
|
||||
/>
|
||||
{typeformApp?.isInstalled ? (
|
||||
<FormActionsDropdown form={form}>
|
||||
<FormActionsDropdown>
|
||||
<FormAction
|
||||
data-testid="copy-redirect-url"
|
||||
routingForm={form}
|
||||
|
@ -147,7 +151,7 @@ const Actions = ({
|
|||
</ButtonGroup>
|
||||
|
||||
<div className="flex md:hidden">
|
||||
<FormActionsDropdown form={form}>
|
||||
<FormActionsDropdown>
|
||||
<FormAction
|
||||
routingForm={form}
|
||||
color="minimal"
|
||||
|
@ -288,7 +292,16 @@ function SingleForm({ form, appUrl, Page }: SingleFormComponentProps) {
|
|||
<FormActionsProvider appUrl={appUrl}>
|
||||
<Meta title={form.name} description={form.description || ""} />
|
||||
<ShellMain
|
||||
heading={form.name}
|
||||
heading={
|
||||
<div className="flex">
|
||||
<div>{form.name}</div>
|
||||
{form.team && (
|
||||
<Badge className="mt-1 ml-4" variant="gray">
|
||||
{form.team.name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
subtitle={form.description || ""}
|
||||
backPath={`/${appUrl}/forms`}
|
||||
CTA={<Actions form={form} mutation={mutation} />}>
|
||||
|
@ -539,8 +552,8 @@ export const getServerSidePropsForSingleFormView = async function getServerSideP
|
|||
};
|
||||
}
|
||||
|
||||
const isFormEditAllowed = (await import("../lib/isFormEditAllowed")).isFormEditAllowed;
|
||||
if (!(await isFormEditAllowed({ userId: user.id, formId }))) {
|
||||
const isFormCreateEditAllowed = (await import("../lib/isFormCreateEditAllowed")).isFormCreateEditAllowed;
|
||||
if (!(await isFormCreateEditAllowed({ userId: user.id, formId, targetTeamId: null }))) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
|
@ -551,6 +564,12 @@ export const getServerSidePropsForSingleFormView = async function getServerSideP
|
|||
id: formId,
|
||||
},
|
||||
include: {
|
||||
team: {
|
||||
select: {
|
||||
name: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
responses: true,
|
||||
|
@ -567,7 +586,7 @@ export const getServerSidePropsForSingleFormView = async function getServerSideP
|
|||
return {
|
||||
props: {
|
||||
trpcState: ssr.dehydrate(),
|
||||
form: await getSerializableForm(form),
|
||||
form: await getSerializableForm({ form }),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import type { App_RoutingForms_Form } from "@prisma/client";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { entityPrismaWhereClause } from "@calcom/lib/entityPermissionUtils";
|
||||
import { RoutingFormSettings } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import type { SerializableForm } from "../types/types";
|
||||
|
@ -13,10 +14,13 @@ import isRouterLinkedField from "./isRouterLinkedField";
|
|||
/**
|
||||
* Doesn't have deleted fields by default
|
||||
*/
|
||||
export async function getSerializableForm<TForm extends App_RoutingForms_Form>(
|
||||
form: TForm,
|
||||
withDeletedFields = false
|
||||
) {
|
||||
export async function getSerializableForm<TForm extends App_RoutingForms_Form>({
|
||||
form,
|
||||
withDeletedFields = false,
|
||||
}: {
|
||||
form: TForm;
|
||||
withDeletedFields?: boolean;
|
||||
}) {
|
||||
const prisma = (await import("@calcom/prisma")).default;
|
||||
const routesParsed = zodRoutes.safeParse(form.routes);
|
||||
if (!routesParsed.success) {
|
||||
|
@ -46,7 +50,7 @@ export async function getSerializableForm<TForm extends App_RoutingForms_Form>(
|
|||
fieldsExistInForm[f.id] = true;
|
||||
});
|
||||
|
||||
const { routes, routers } = await getEnrichedRoutesAndRouters(parsedRoutes);
|
||||
const { routes, routers } = await getEnrichedRoutesAndRouters(parsedRoutes, form.userId);
|
||||
|
||||
const connectedForms = (await getConnectedForms(prisma, form)).map((f) => ({
|
||||
id: f.id,
|
||||
|
@ -71,7 +75,7 @@ export async function getSerializableForm<TForm extends App_RoutingForms_Form>(
|
|||
/**
|
||||
* Enriches routes that are actually routers and returns a list of routers separately
|
||||
*/
|
||||
async function getEnrichedRoutesAndRouters(parsedRoutes: z.infer<typeof zodRoutes>) {
|
||||
async function getEnrichedRoutesAndRouters(parsedRoutes: z.infer<typeof zodRoutes>, userId: number) {
|
||||
const routers: { name: string; description: string | null; id: string }[] = [];
|
||||
const routes: z.infer<typeof zodRoutesView> = [];
|
||||
if (!parsedRoutes) {
|
||||
|
@ -83,14 +87,14 @@ export async function getSerializableForm<TForm extends App_RoutingForms_Form>(
|
|||
const router = await prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
id: route.id,
|
||||
userId: form.userId,
|
||||
...entityPrismaWhereClause({ userId: userId }),
|
||||
},
|
||||
});
|
||||
if (!router) {
|
||||
throw new Error("Form -" + route.id + ", being used as router, not found");
|
||||
}
|
||||
|
||||
const parsedRouter = await getSerializableForm(router, false);
|
||||
const parsedRouter = await getSerializableForm({ form: router });
|
||||
|
||||
routers.push({
|
||||
name: parsedRouter.name,
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
import type { App_RoutingForms_Form, User } from "@prisma/client";
|
||||
|
||||
import { canCreateEntity, canEditEntity } from "@calcom/lib/entityPermissionUtils";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
export async function isFormCreateEditAllowed({
|
||||
formId,
|
||||
userId,
|
||||
/**
|
||||
* Valid when a new form is being created for a team
|
||||
*/
|
||||
targetTeamId,
|
||||
}: {
|
||||
userId: User["id"];
|
||||
formId: App_RoutingForms_Form["id"];
|
||||
targetTeamId: App_RoutingForms_Form["teamId"] | null;
|
||||
}) {
|
||||
const form = await prisma.app_RoutingForms_Form.findUnique({
|
||||
where: {
|
||||
id: formId,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
teamId: true,
|
||||
team: {
|
||||
select: {
|
||||
members: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!form) {
|
||||
return await canCreateEntity({
|
||||
targetTeamId,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
return canEditEntity(form, userId);
|
||||
}
|
|
@ -1,25 +0,0 @@
|
|||
import type { App_RoutingForms_Form, User } from "@prisma/client";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
export async function isFormEditAllowed({
|
||||
userId,
|
||||
formId,
|
||||
}: {
|
||||
userId: User["id"];
|
||||
formId: App_RoutingForms_Form["id"];
|
||||
}) {
|
||||
const form = await prisma.app_RoutingForms_Form.findUnique({
|
||||
where: {
|
||||
id: formId,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
if (!form) {
|
||||
// If form doesn't exist at all, then it's a creation and can be allowed.
|
||||
return true;
|
||||
}
|
||||
return form.userId === userId;
|
||||
}
|
|
@ -1,7 +1,10 @@
|
|||
// TODO: i18n
|
||||
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import SkeletonLoaderTeamList from "@calcom/features/ee/teams/components/SkeletonloaderTeamList";
|
||||
import { FilterResults } from "@calcom/features/filters/components/FilterResults";
|
||||
import { TeamsFilter } from "@calcom/features/filters/components/TeamsFilter";
|
||||
import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
|
||||
import Shell, { ShellMain } from "@calcom/features/shell/Shell";
|
||||
import { UpgradeTip } from "@calcom/features/tips";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
|
@ -9,10 +12,15 @@ import useApp from "@calcom/lib/hooks/useApp";
|
|||
import { useHasPaidPlan } from "@calcom/lib/hooks/useHasPaidPlan";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { AppGetServerSidePropsContext, AppPrisma, AppUser } from "@calcom/types/AppGetServerSideProps";
|
||||
import type {
|
||||
AppGetServerSidePropsContext,
|
||||
AppPrisma,
|
||||
AppSsrInit,
|
||||
AppUser,
|
||||
} from "@calcom/types/AppGetServerSideProps";
|
||||
import { Badge, ButtonGroup, EmptyScreen, List, ListLinkItem, Tooltip, Button } from "@calcom/ui";
|
||||
import { CreateButtonWithTeamsList } from "@calcom/ui";
|
||||
import {
|
||||
Plus,
|
||||
GitMerge,
|
||||
ExternalLink,
|
||||
Link as LinkIcon,
|
||||
|
@ -32,22 +40,43 @@ import {
|
|||
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import { FormAction, FormActionsDropdown, FormActionsProvider } from "../../components/FormActions";
|
||||
import { getSerializableForm } from "../../lib/getSerializableForm";
|
||||
import {
|
||||
createAction,
|
||||
FormAction,
|
||||
FormActionsDropdown,
|
||||
FormActionsProvider,
|
||||
} from "../../components/FormActions";
|
||||
import { isFallbackRoute } from "../../lib/isFallbackRoute";
|
||||
|
||||
function NewFormButton() {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
return (
|
||||
<CreateButtonWithTeamsList
|
||||
subtitle={t("create_routing_form_on").toUpperCase()}
|
||||
data-testid="new-routing-form"
|
||||
createFunction={(teamId) => {
|
||||
createAction({ router, teamId: teamId ?? null });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RoutingForms({
|
||||
forms: forms_,
|
||||
appUrl,
|
||||
}: inferSSRProps<typeof getServerSideProps> & { appUrl: string }) {
|
||||
const { t } = useLocale();
|
||||
const { hasPaidPlan } = useHasPaidPlan();
|
||||
const router = useRouter();
|
||||
|
||||
const { data: forms, isLoading } = trpc.viewer.appRoutingForms.forms.useQuery(undefined, {
|
||||
initialData: forms_,
|
||||
const filters = getTeamsFiltersFromQuery(router.query);
|
||||
|
||||
const queryRes = trpc.viewer.appRoutingForms.forms.useQuery({
|
||||
filters,
|
||||
});
|
||||
|
||||
const { data: typeformApp } = useApp("typeform");
|
||||
|
||||
const forms = queryRes.data?.filtered;
|
||||
const features = [
|
||||
{
|
||||
icon: <FileText className="h-5 w-5 text-orange-500" />,
|
||||
|
@ -81,22 +110,10 @@ export default function RoutingForms({
|
|||
},
|
||||
];
|
||||
|
||||
function NewFormButton() {
|
||||
return (
|
||||
<FormAction
|
||||
variant="fab"
|
||||
routingForm={null}
|
||||
data-testid="new-routing-form"
|
||||
StartIcon={Plus}
|
||||
action="create">
|
||||
{t("new")}
|
||||
</FormAction>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ShellMain
|
||||
heading="Routing Forms"
|
||||
CTA={hasPaidPlan && forms?.length && <NewFormButton />}
|
||||
CTA={hasPaidPlan && forms?.length ? <NewFormButton /> : null}
|
||||
subtitle={t("routing_forms_description")}>
|
||||
<UpgradeTip
|
||||
title={t("teams_plan_required")}
|
||||
|
@ -119,37 +136,61 @@ export default function RoutingForms({
|
|||
<FormActionsProvider appUrl={appUrl}>
|
||||
<div className="-mx-4 md:-mx-8">
|
||||
<div className="mb-10 w-full px-4 pb-2 sm:px-6 md:px-8">
|
||||
{!forms?.length ? (
|
||||
<EmptyScreen
|
||||
Icon={GitMerge}
|
||||
headline={t("create_your_first_form")}
|
||||
description={t("create_your_first_form_description")}
|
||||
buttonRaw={<NewFormButton />}
|
||||
/>
|
||||
) : null}
|
||||
{forms?.length ? (
|
||||
<div className="flex">
|
||||
<TeamsFilter />
|
||||
</div>
|
||||
<FilterResults
|
||||
queryRes={queryRes}
|
||||
emptyScreen={
|
||||
<EmptyScreen
|
||||
Icon={GitMerge}
|
||||
headline={t("create_your_first_form")}
|
||||
description={t("create_your_first_form_description")}
|
||||
buttonRaw={<NewFormButton />}
|
||||
/>
|
||||
}
|
||||
noResultsScreen={
|
||||
<EmptyScreen
|
||||
Icon={GitMerge}
|
||||
headline={t("no_results_for_filter")}
|
||||
description={t("change_filter_common")}
|
||||
/>
|
||||
}
|
||||
SkeletonLoader={SkeletonLoaderTeamList}>
|
||||
<div className="bg-default mb-16 overflow-hidden">
|
||||
<List data-testid="routing-forms-list">
|
||||
{forms.map((form, index) => {
|
||||
{forms?.map(({ form, readOnly }) => {
|
||||
if (!form) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const description = form.description || "";
|
||||
const disabled = form.disabled;
|
||||
form.routes = form.routes || [];
|
||||
const fields = form.fields || [];
|
||||
const userRoutes = form.routes.filter((route) => !isFallbackRoute(route));
|
||||
return (
|
||||
<ListLinkItem
|
||||
key={index}
|
||||
key={form.id}
|
||||
href={appUrl + "/form-edit/" + form.id}
|
||||
heading={form.name}
|
||||
disabled={disabled}
|
||||
disabled={readOnly}
|
||||
subHeading={description}
|
||||
className="space-x-2 rtl:space-x-reverse"
|
||||
actions={
|
||||
<>
|
||||
<FormAction className="self-center" action="toggle" routingForm={form} />
|
||||
{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
|
||||
|
@ -159,7 +200,6 @@ export default function RoutingForms({
|
|||
StartIcon={ExternalLink}
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Tooltip>
|
||||
<FormAction
|
||||
|
@ -168,7 +208,6 @@ export default function RoutingForms({
|
|||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon={LinkIcon}
|
||||
disabled={disabled}
|
||||
tooltip={t("copy_link_to_form")}
|
||||
/>
|
||||
<FormAction
|
||||
|
@ -177,10 +216,9 @@ export default function RoutingForms({
|
|||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon={Code}
|
||||
disabled={disabled}
|
||||
tooltip={t("embed")}
|
||||
/>
|
||||
<FormActionsDropdown form={form}>
|
||||
<FormActionsDropdown disabled={readOnly}>
|
||||
<FormAction
|
||||
action="edit"
|
||||
routingForm={form}
|
||||
|
@ -232,7 +270,7 @@ export default function RoutingForms({
|
|||
{fields.length} {fields.length === 1 ? "field" : "fields"}
|
||||
</Badge>
|
||||
<Badge variant="gray" startIcon={GitMerge}>
|
||||
{form.routes.length} {form.routes.length === 1 ? "route" : "routes"}
|
||||
{userRoutes.length} {userRoutes.length === 1 ? "route" : "routes"}
|
||||
</Badge>
|
||||
<Badge variant="gray" startIcon={MessageCircle}>
|
||||
{form._count.responses} {form._count.responses === 1 ? "response" : "responses"}
|
||||
|
@ -243,7 +281,7 @@ export default function RoutingForms({
|
|||
})}
|
||||
</List>
|
||||
</div>
|
||||
) : null}
|
||||
</FilterResults>
|
||||
</div>
|
||||
</div>
|
||||
</FormActionsProvider>
|
||||
|
@ -263,7 +301,8 @@ RoutingForms.getLayout = (page: React.ReactElement) => {
|
|||
export const getServerSideProps = async function getServerSideProps(
|
||||
context: AppGetServerSidePropsContext,
|
||||
prisma: AppPrisma,
|
||||
user: AppUser
|
||||
user: AppUser,
|
||||
ssrInit: AppSsrInit
|
||||
) {
|
||||
if (!user) {
|
||||
return {
|
||||
|
@ -273,31 +312,18 @@ export const getServerSideProps = async function getServerSideProps(
|
|||
},
|
||||
};
|
||||
}
|
||||
const forms = await prisma.app_RoutingForms_Form.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
responses: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
const ssr = await ssrInit(context);
|
||||
|
||||
const filters = getTeamsFiltersFromQuery(context.query);
|
||||
|
||||
await ssr.viewer.appRoutingForms.forms.prefetch({
|
||||
filters,
|
||||
});
|
||||
|
||||
const serializableForms = [];
|
||||
for (const [, form] of Object.entries(forms)) {
|
||||
serializableForms.push(await getSerializableForm(form));
|
||||
}
|
||||
|
||||
// Prefetch this so that New Button is immediately available
|
||||
await ssr.viewer.teamsAndUserProfilesQuery.prefetch();
|
||||
return {
|
||||
props: {
|
||||
...(await serverSideTranslations(context.locale ?? "", ["common"])),
|
||||
forms: serializableForms,
|
||||
trpcState: ssr.dehydrate(),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -6,6 +6,7 @@ import { Query, Builder, Utils as QbUtils } from "react-awesome-query-builder";
|
|||
import type { JsonTree, ImmutableTree, BuilderProps } from "react-awesome-query-builder";
|
||||
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { areTheySiblingEntitites } from "@calcom/lib/entityPermissionUtils";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
|
@ -65,6 +66,7 @@ type Route =
|
|||
| GlobalRoute;
|
||||
|
||||
const Route = ({
|
||||
form,
|
||||
route,
|
||||
routes,
|
||||
setRoute,
|
||||
|
@ -75,6 +77,7 @@ const Route = ({
|
|||
appUrl,
|
||||
disabled = false,
|
||||
}: {
|
||||
form: inferSSRProps<typeof getServerSideProps>["form"];
|
||||
route: Route;
|
||||
routes: Route[];
|
||||
setRoute: (id: string, route: Partial<Route>) => void;
|
||||
|
@ -91,6 +94,20 @@ const Route = ({
|
|||
|
||||
const eventOptions: { label: string; value: string }[] = [];
|
||||
eventTypesByGroup?.eventTypeGroups.forEach((group) => {
|
||||
const eventTypeValidInContext = areTheySiblingEntitites({
|
||||
entity1: {
|
||||
teamId: group.teamId ?? null,
|
||||
// group doesn't have userId. The query ensures that it belongs to the user only, if teamId isn't set. So, I am manually setting it to the form userId
|
||||
userId: form.userId,
|
||||
},
|
||||
entity2: {
|
||||
teamId: form.teamId ?? null,
|
||||
userId: form.userId,
|
||||
},
|
||||
});
|
||||
if (!eventTypeValidInContext) {
|
||||
return;
|
||||
}
|
||||
group.eventTypes.forEach((eventType) => {
|
||||
const uniqueSlug = `${group.profile.slug}/${eventType.slug}`;
|
||||
eventOptions.push({
|
||||
|
@ -308,14 +325,26 @@ const Routes = ({
|
|||
return deserializeRoute(route, config);
|
||||
});
|
||||
});
|
||||
|
||||
const { data: allForms } = trpc.viewer.appRoutingForms.forms.useQuery();
|
||||
|
||||
const availableRouters =
|
||||
allForms
|
||||
?.filter((router) => {
|
||||
return router.id !== form.id;
|
||||
allForms?.filtered
|
||||
.filter(({ form: router }) => {
|
||||
const routerValidInContext = areTheySiblingEntitites({
|
||||
entity1: {
|
||||
teamId: router.teamId ?? null,
|
||||
// group doesn't have userId. The query ensures that it belongs to the user only, if teamId isn't set. So, I am manually setting it to the form userId
|
||||
userId: router.userId,
|
||||
},
|
||||
entity2: {
|
||||
teamId: form.teamId ?? null,
|
||||
userId: form.userId,
|
||||
},
|
||||
});
|
||||
return router.id !== form.id && routerValidInContext;
|
||||
})
|
||||
.map((router) => {
|
||||
.map(({ form: router }) => {
|
||||
return {
|
||||
value: router.id,
|
||||
label: router.name,
|
||||
|
@ -425,6 +454,7 @@ const Routes = ({
|
|||
{mainRoutes.map((route, key) => {
|
||||
return (
|
||||
<Route
|
||||
form={form}
|
||||
appUrl={appUrl}
|
||||
key={route.id}
|
||||
config={config}
|
||||
|
@ -494,6 +524,7 @@ const Routes = ({
|
|||
|
||||
<div>
|
||||
<Route
|
||||
form={form}
|
||||
config={config}
|
||||
route={fallbackRoute}
|
||||
routes={routes}
|
||||
|
|
|
@ -57,7 +57,7 @@ export const getServerSideProps = async function getServerSideProps(
|
|||
notFound: true,
|
||||
};
|
||||
}
|
||||
const serializableForm = await getSerializableForm(form);
|
||||
const serializableForm = await getSerializableForm({ form });
|
||||
|
||||
const response: Record<string, Pick<Response[string], "value">> = {};
|
||||
serializableForm.fields?.forEach((field) => {
|
||||
|
|
|
@ -279,7 +279,7 @@ export const getServerSideProps = async function getServerSideProps(
|
|||
brandColor: form.user.brandColor,
|
||||
darkBrandColor: form.user.darkBrandColor,
|
||||
},
|
||||
form: await getSerializableForm(form),
|
||||
form: await getSerializableForm({ form }),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -182,7 +182,7 @@ test.describe("Routing Forms", () => {
|
|||
hasTeam: true,
|
||||
}
|
||||
);
|
||||
await user.login();
|
||||
await user.apiLogin();
|
||||
// Install app
|
||||
await page.goto(`/apps/routing-forms`);
|
||||
await page.click('[data-testid="install-app-button"]');
|
||||
|
@ -213,7 +213,7 @@ test.describe("Routing Forms", () => {
|
|||
{ username: "routing-forms" },
|
||||
{ seedRoutingForms: true, hasTeam: true }
|
||||
);
|
||||
await user.login();
|
||||
await user.apiLogin();
|
||||
// Install app
|
||||
await page.goto(`/apps/routing-forms`);
|
||||
await page.click('[data-testid="install-app-button"]');
|
||||
|
@ -231,7 +231,7 @@ test.describe("Routing Forms", () => {
|
|||
await fillSeededForm(page, routingForm.id);
|
||||
|
||||
// Log back in to view form responses.
|
||||
await user.login();
|
||||
await user.apiLogin();
|
||||
|
||||
await page.goto(`/apps/routing-forms/reporting/${routingForm.id}`);
|
||||
// Can't keep waiting forever. So, added a timeout of 5000ms
|
||||
|
@ -426,6 +426,8 @@ async function fillSeededForm(page: Page, routingFormId: string) {
|
|||
export async function addForm(page: Page, { name = "Test Form Name" } = {}) {
|
||||
await page.goto("/apps/routing-forms/forms");
|
||||
await page.click('[data-testid="new-routing-form"]');
|
||||
// Choose to create the Form for the user(which is the first option) and not the team
|
||||
await page.click('[data-testid="option-0"]');
|
||||
await page.fill("input[name]", name);
|
||||
await page.click('[data-testid="add-form"]');
|
||||
await page.waitForSelector('[data-testid="add-field"]');
|
||||
|
|
|
@ -5,6 +5,7 @@ import { router } from "@calcom/trpc/server/trpc";
|
|||
import { ZDeleteFormInputSchema } from "./deleteForm.schema";
|
||||
import { ZFormMutationInputSchema } from "./formMutation.schema";
|
||||
import { ZFormQueryInputSchema } from "./formQuery.schema";
|
||||
import { ZFormsInputSchema } from "./forms.schema";
|
||||
import { ZReportInputSchema } from "./report.schema";
|
||||
import { ZResponseInputSchema } from "./response.schema";
|
||||
|
||||
|
@ -49,9 +50,9 @@ const appRoutingForms = router({
|
|||
return handler({ ctx, input });
|
||||
}),
|
||||
}),
|
||||
forms: authedProcedure.query(async ({ ctx }) => {
|
||||
forms: authedProcedure.input(ZFormsInputSchema).query(async ({ ctx, input }) => {
|
||||
const handler = await getHandler("forms", () => import("./forms.handler"));
|
||||
return handler({ ctx });
|
||||
return handler({ ctx, input });
|
||||
}),
|
||||
formQuery: authedProcedure.input(ZFormQueryInputSchema).query(async ({ ctx, input }) => {
|
||||
const handler = await getHandler("formQuery", () => import("./formQuery.handler"));
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import type { PrismaClient } from "@prisma/client";
|
||||
|
||||
import { entityPrismaWhereClause } from "@calcom/lib/entityPermissionUtils";
|
||||
import { TRPCError } from "@calcom/trpc/server";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import getConnectedForms from "../lib/getConnectedForms";
|
||||
import { isFormEditAllowed } from "../lib/isFormEditAllowed";
|
||||
import { isFormCreateEditAllowed } from "../lib/isFormCreateEditAllowed";
|
||||
import type { TDeleteFormInputSchema } from "./deleteForm.schema";
|
||||
|
||||
interface DeleteFormHandlerOptions {
|
||||
|
@ -16,7 +17,7 @@ interface DeleteFormHandlerOptions {
|
|||
}
|
||||
export const deleteFormHandler = async ({ ctx, input }: DeleteFormHandlerOptions) => {
|
||||
const { user, prisma } = ctx;
|
||||
if (!(await isFormEditAllowed({ userId: user.id, formId: input.id }))) {
|
||||
if (!(await isFormCreateEditAllowed({ userId: user.id, formId: input.id, targetTeamId: null }))) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
|
@ -28,18 +29,28 @@ export const deleteFormHandler = async ({ ctx, input }: DeleteFormHandlerOptions
|
|||
userId: user.id,
|
||||
})
|
||||
).length;
|
||||
|
||||
if (areFormsUsingIt) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "This form is being used by other forms. Please remove it's usage from there first.",
|
||||
});
|
||||
}
|
||||
return await prisma.app_RoutingForms_Form.deleteMany({
|
||||
|
||||
const deletedRes = await prisma.app_RoutingForms_Form.deleteMany({
|
||||
where: {
|
||||
id: input.id,
|
||||
userId: user.id,
|
||||
...entityPrismaWhereClause({ userId: user.id }),
|
||||
},
|
||||
});
|
||||
|
||||
if (!deletedRes.count) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Form seems to be already deleted.",
|
||||
});
|
||||
}
|
||||
return deletedRes;
|
||||
};
|
||||
|
||||
export default deleteFormHandler;
|
||||
|
|
|
@ -2,13 +2,14 @@ import type { PrismaClient } from "@prisma/client";
|
|||
import type { App_RoutingForms_Form } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
import { entityPrismaWhereClause, canEditEntity } from "@calcom/lib/entityPermissionUtils";
|
||||
import { TRPCError } from "@calcom/trpc/server";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import { createFallbackRoute } from "../lib/createFallbackRoute";
|
||||
import { getSerializableForm } from "../lib/getSerializableForm";
|
||||
import { isFallbackRoute } from "../lib/isFallbackRoute";
|
||||
import { isFormEditAllowed } from "../lib/isFormEditAllowed";
|
||||
import { isFormCreateEditAllowed } from "../lib/isFormCreateEditAllowed";
|
||||
import isRouter from "../lib/isRouter";
|
||||
import isRouterLinkedField from "../lib/isRouterLinkedField";
|
||||
import type { SerializableForm } from "../types/types";
|
||||
|
@ -25,7 +26,8 @@ interface FormMutationHandlerOptions {
|
|||
export const formMutationHandler = async ({ ctx, input }: FormMutationHandlerOptions) => {
|
||||
const { user, prisma } = ctx;
|
||||
const { name, id, description, settings, disabled, addFallback, duplicateFrom, shouldConnect } = input;
|
||||
if (!(await isFormEditAllowed({ userId: user.id, formId: id }))) {
|
||||
let teamId = input.teamId;
|
||||
if (!(await isFormCreateEditAllowed({ userId: user.id, formId: id, targetTeamId: teamId }))) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
|
@ -56,13 +58,16 @@ export const formMutationHandler = async ({ ctx, input }: FormMutationHandlerOpt
|
|||
routes: true,
|
||||
fields: true,
|
||||
settings: true,
|
||||
teamId: true,
|
||||
},
|
||||
});
|
||||
|
||||
const dbSerializedForm = dbForm ? await getSerializableForm(dbForm, true) : null;
|
||||
const dbSerializedForm = dbForm
|
||||
? await getSerializableForm({ form: dbForm, withDeletedFields: true })
|
||||
: null;
|
||||
|
||||
if (duplicateFrom) {
|
||||
({ routes, fields } = await getRoutesAndFieldsForDuplication(duplicateFrom));
|
||||
({ teamId, routes, fields } = await getRoutesAndFieldsForDuplication({ duplicateFrom, userId: user.id }));
|
||||
} else {
|
||||
[fields, routes] = [inputFields, inputRoutes];
|
||||
if (dbSerializedForm) {
|
||||
|
@ -100,6 +105,15 @@ export const formMutationHandler = async ({ ctx, input }: FormMutationHandlerOpt
|
|||
// Prisma doesn't allow setting null value directly for JSON. It recommends using JsonNull for that case.
|
||||
routes: routes === null ? Prisma.JsonNull : routes,
|
||||
id: id,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
connect: {
|
||||
id: teamId ?? undefined,
|
||||
},
|
||||
},
|
||||
}
|
||||
: null),
|
||||
},
|
||||
update: {
|
||||
disabled: disabled,
|
||||
|
@ -237,24 +251,47 @@ export const formMutationHandler = async ({ ctx, input }: FormMutationHandlerOpt
|
|||
}
|
||||
}
|
||||
|
||||
async function getRoutesAndFieldsForDuplication(duplicateFrom: DuplicateFrom) {
|
||||
async function getRoutesAndFieldsForDuplication({
|
||||
duplicateFrom,
|
||||
userId,
|
||||
}: {
|
||||
duplicateFrom: DuplicateFrom;
|
||||
userId: number;
|
||||
}) {
|
||||
const sourceForm = await prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
...entityPrismaWhereClause({ userId }),
|
||||
id: duplicateFrom,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
fields: true,
|
||||
routes: true,
|
||||
userId: true,
|
||||
teamId: true,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
members: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!sourceForm) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Form to duplicate: ${duplicateFrom} not found`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!canEditEntity(sourceForm, userId)) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: `Form to duplicate: ${duplicateFrom} not found or you are unauthorized`,
|
||||
});
|
||||
}
|
||||
|
||||
//TODO: Instead of parsing separately, use getSerializableForm. That would automatically remove deleted fields as well.
|
||||
const fieldsParsed = zodFields.safeParse(sourceForm.fields);
|
||||
const routesParsed = zodRoutes.safeParse(sourceForm.routes);
|
||||
|
@ -293,7 +330,7 @@ export const formMutationHandler = async ({ ctx, input }: FormMutationHandlerOpt
|
|||
// FIXME: Deleted fields shouldn't come in duplicate
|
||||
fields = fieldsParsed.data ? fieldsParsed.data.filter((f) => !f.deleted) : [];
|
||||
}
|
||||
return { routes, fields };
|
||||
return { teamId: sourceForm.teamId, routes, fields };
|
||||
}
|
||||
|
||||
function markMissingFieldsDeleted(
|
||||
|
|
|
@ -13,6 +13,7 @@ export const ZFormMutationInputSchema = z.object({
|
|||
routes: zodRoutes,
|
||||
addFallback: z.boolean().optional(),
|
||||
duplicateFrom: z.string().nullable().optional(),
|
||||
teamId: z.number().nullish().default(null),
|
||||
shouldConnect: z.boolean().optional(),
|
||||
settings: RoutingFormSettings.optional(),
|
||||
});
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import type { PrismaClient } from "@prisma/client";
|
||||
|
||||
import { entityPrismaWhereClause } from "@calcom/lib/entityPermissionUtils";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import { getSerializableForm } from "../lib/getSerializableForm";
|
||||
|
@ -12,14 +13,20 @@ interface FormsHandlerOptions {
|
|||
};
|
||||
input: TFormQueryInputSchema;
|
||||
}
|
||||
|
||||
export const formQueryHandler = async ({ ctx, input }: FormsHandlerOptions) => {
|
||||
const { prisma, user } = ctx;
|
||||
const form = await prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
id: input.id,
|
||||
AND: [
|
||||
entityPrismaWhereClause({ userId: user.id }),
|
||||
{
|
||||
id: input.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
team: { select: { slug: true, name: true } },
|
||||
_count: {
|
||||
select: {
|
||||
responses: true,
|
||||
|
@ -32,7 +39,7 @@ export const formQueryHandler = async ({ ctx, input }: FormsHandlerOptions) => {
|
|||
return null;
|
||||
}
|
||||
|
||||
return await getSerializableForm(form);
|
||||
return await getSerializableForm({ form });
|
||||
};
|
||||
|
||||
export default formQueryHandler;
|
||||
|
|
|
@ -1,26 +1,39 @@
|
|||
import type { PrismaClient } from "@prisma/client";
|
||||
|
||||
import { hasFilter } from "@calcom/features/filters/lib/hasFilter";
|
||||
import { entityPrismaWhereClause, canEditEntity } from "@calcom/lib/entityPermissionUtils";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import type { PrismaClient, Prisma } from "@calcom/prisma/client";
|
||||
import { entries } from "@calcom/prisma/zod-utils";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import { getSerializableForm } from "../lib/getSerializableForm";
|
||||
import type { TFormSchema } from "./forms.schema";
|
||||
|
||||
interface FormsHandlerOptions {
|
||||
ctx: {
|
||||
prisma: PrismaClient;
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TFormSchema;
|
||||
}
|
||||
const log = logger.getChildLogger({ prefix: ["[formsHandler]"] });
|
||||
|
||||
export const formsHandler = async ({ ctx }: FormsHandlerOptions) => {
|
||||
export const formsHandler = async ({ ctx, input }: FormsHandlerOptions) => {
|
||||
const { prisma, user } = ctx;
|
||||
|
||||
const where = getPrismaWhereFromFilters(user, input?.filters);
|
||||
log.debug("Getting forms where", JSON.stringify(where));
|
||||
|
||||
const forms = await prisma.app_RoutingForms_Form.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
where,
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
include: {
|
||||
team: {
|
||||
include: {
|
||||
members: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
responses: true,
|
||||
|
@ -29,11 +42,99 @@ export const formsHandler = async ({ ctx }: FormsHandlerOptions) => {
|
|||
},
|
||||
});
|
||||
|
||||
const totalForms = await prisma.app_RoutingForms_Form.count({
|
||||
where: entityPrismaWhereClause({
|
||||
userId: user.id,
|
||||
}),
|
||||
});
|
||||
|
||||
const serializableForms = [];
|
||||
for (let i = 0; i < forms.length; i++) {
|
||||
serializableForms.push(await getSerializableForm(forms[i]));
|
||||
const form = forms[i];
|
||||
const hasWriteAccess = canEditEntity(form, user.id);
|
||||
serializableForms.push({
|
||||
form: await getSerializableForm({ form: forms[i] }),
|
||||
readOnly: !hasWriteAccess,
|
||||
});
|
||||
}
|
||||
return serializableForms;
|
||||
|
||||
return {
|
||||
filtered: serializableForms,
|
||||
totalCount: totalForms,
|
||||
};
|
||||
};
|
||||
|
||||
export default formsHandler;
|
||||
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,11 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import { filterQuerySchemaStrict } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
|
||||
|
||||
export const ZFormsInputSchema = z
|
||||
.object({
|
||||
filters: filterQuerySchemaStrict.optional(),
|
||||
})
|
||||
.nullish();
|
||||
|
||||
export type TFormSchema = z.infer<typeof ZFormsInputSchema>;
|
|
@ -40,7 +40,7 @@ export const reportHandler = async ({ ctx: { prisma }, input }: ReportHandlerOpt
|
|||
});
|
||||
}
|
||||
// TODO: Second argument is required to return deleted operators.
|
||||
const serializedForm = await getSerializableForm(form, true);
|
||||
const serializedForm = await getSerializableForm({ form, withDeletedFields: true });
|
||||
|
||||
const rows = await prisma.app_RoutingForms_FormResponse.findMany({
|
||||
where: {
|
||||
|
|
|
@ -33,7 +33,7 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) =>
|
|||
});
|
||||
}
|
||||
|
||||
const serializableForm = await getSerializableForm(form);
|
||||
const serializableForm = await getSerializableForm({ form });
|
||||
if (!serializableForm.fields) {
|
||||
// There is no point in submitting a form that doesn't have fields defined
|
||||
throw new TRPCError({
|
||||
|
|
|
@ -44,30 +44,33 @@ export const getEmbedIframe = async ({
|
|||
pathname: string;
|
||||
}) => {
|
||||
// We can't seem to access page.frame till contentWindow is available. So wait for that.
|
||||
const iframeReady = await page.evaluate(() => {
|
||||
return new Promise((resolve) => {
|
||||
const interval = setInterval(() => {
|
||||
const iframe = document.querySelector<HTMLIFrameElement>(".cal-embed");
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
if (iframe && iframe.contentWindow && window.iframeReady) {
|
||||
clearInterval(interval);
|
||||
resolve(true);
|
||||
} else {
|
||||
const iframeReady = await page.evaluate(
|
||||
(hardTimeout) => {
|
||||
return new Promise((resolve) => {
|
||||
const interval = setInterval(() => {
|
||||
const iframe = document.querySelector<HTMLIFrameElement>(".cal-embed");
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
console.log("Iframe Status:", !!iframe, !!iframe?.contentWindow, window.iframeReady);
|
||||
}
|
||||
}, 500);
|
||||
if (iframe && iframe.contentWindow && window.iframeReady) {
|
||||
clearInterval(interval);
|
||||
resolve(true);
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
console.log("Iframe Status:", !!iframe, !!iframe?.contentWindow, window.iframeReady);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// A hard timeout if iframe isn't ready in that time. Avoids infinite wait
|
||||
setTimeout(() => {
|
||||
clearInterval(interval);
|
||||
resolve(false);
|
||||
// This is the time embed-iframe.ts loads in the iframe and fires atleast one event. Also, it is a load of entire React Application so it can sometime take more time even on CI.
|
||||
}, 15000);
|
||||
});
|
||||
});
|
||||
// A hard timeout if iframe isn't ready in that time. Avoids infinite wait
|
||||
setTimeout(() => {
|
||||
clearInterval(interval);
|
||||
resolve(false);
|
||||
// This is the time embed-iframe.ts loads in the iframe and fires atleast one event. Also, it is a load of entire React Application so it can sometime take more time even on CI.
|
||||
}, hardTimeout);
|
||||
});
|
||||
},
|
||||
!process.env.CI ? 150000 : 15000
|
||||
);
|
||||
if (!iframeReady) {
|
||||
return null;
|
||||
}
|
||||
|
@ -103,7 +106,14 @@ export async function bookFirstEvent(username: string, frame: Frame, page: Page)
|
|||
await frame.click('[data-testid="event-type-link"]');
|
||||
await frame.waitForURL((url) => {
|
||||
// Wait for reaching the event page
|
||||
return !!url.pathname.match(new RegExp(`/${username}/.+$`));
|
||||
const matches = url.pathname.match(new RegExp(`/${username}/(.+)$`));
|
||||
if (!matches || !matches[1]) {
|
||||
return false;
|
||||
}
|
||||
if (matches[1] === "embed") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Let current month dates fully render.
|
||||
|
@ -112,7 +122,8 @@ export async function bookFirstEvent(username: string, frame: Frame, page: Page)
|
|||
// It would also allow correct snapshot to be taken for current month.
|
||||
await frame.waitForTimeout(1000);
|
||||
// expect(await page.screenshot()).toMatchSnapshot("availability-page-1.png");
|
||||
const eventSlug = new URL(frame.url()).pathname;
|
||||
// Remove /embed from the end if present.
|
||||
const eventSlug = new URL(frame.url()).pathname.replace(/\/embed$/, "");
|
||||
await selectFirstAvailableTimeSlotNextMonth(frame, page);
|
||||
await frame.waitForURL((url) => {
|
||||
return url.pathname.includes(`/${username}/book`);
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
|
||||
export type IEventTypesFilters = RouterOutputs["viewer"]["eventTypes"]["listWithTeam"];
|
||||
export type IEventTypeFilter = IEventTypesFilters[0];
|
||||
|
||||
export function FilterResults({
|
||||
queryRes,
|
||||
SkeletonLoader,
|
||||
noResultsScreen,
|
||||
emptyScreen,
|
||||
children,
|
||||
}: {
|
||||
queryRes: { isLoading: boolean; data: { totalCount: number; filtered: unknown[] } | undefined };
|
||||
SkeletonLoader: React.FC;
|
||||
noResultsScreen: React.ReactNode;
|
||||
emptyScreen: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if (queryRes.isLoading) return <SkeletonLoader />;
|
||||
if (!queryRes.data?.totalCount) return <>{emptyScreen}</>;
|
||||
|
||||
return queryRes.data?.totalCount ? (
|
||||
<div>{queryRes.data?.filtered.length ? children : noResultsScreen}</div>
|
||||
) : null;
|
||||
}
|
|
@ -0,0 +1,141 @@
|
|||
import { useSession } from "next-auth/react";
|
||||
import type { ReactNode, InputHTMLAttributes } from "react";
|
||||
import { forwardRef } from "react";
|
||||
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { AnimatedPopover, Avatar, Divider } from "@calcom/ui";
|
||||
import { Layers, User } from "@calcom/ui/components/icon";
|
||||
|
||||
import { filterQuerySchema } from "../lib/getTeamsFiltersFromQuery";
|
||||
|
||||
export type IEventTypesFilters = RouterOutputs["viewer"]["eventTypes"]["listWithTeam"];
|
||||
export type IEventTypeFilter = IEventTypesFilters[0];
|
||||
|
||||
function useFilterQuery() {
|
||||
// passthrough allows additional params to not be removed
|
||||
return useTypedQuery(filterQuerySchema.passthrough());
|
||||
}
|
||||
|
||||
export const TeamsFilter = () => {
|
||||
const { t } = useLocale();
|
||||
const session = useSession();
|
||||
const { data: query, pushItemToKey, removeItemByKeyAndValue, removeAllQueryParams } = useFilterQuery();
|
||||
const { data: teams } = trpc.viewer.teams.list.useQuery(undefined, {
|
||||
// Teams don't change that frequently
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
const getCheckedOptionsNames = () => {
|
||||
const checkedOptions: string[] = [];
|
||||
const teamIds = query.teamIds;
|
||||
if (teamIds) {
|
||||
const selectedTeamsNames = teams
|
||||
?.filter((team) => {
|
||||
return teamIds.includes(team.id);
|
||||
})
|
||||
?.map((team) => team.name);
|
||||
if (selectedTeamsNames) {
|
||||
checkedOptions.push(...selectedTeamsNames);
|
||||
}
|
||||
return checkedOptions.join(",");
|
||||
}
|
||||
if (query.userIds) {
|
||||
return t("yours");
|
||||
}
|
||||
return t("all");
|
||||
};
|
||||
|
||||
if (!teams || !teams.length) return null;
|
||||
|
||||
return (
|
||||
<AnimatedPopover text={getCheckedOptionsNames()}>
|
||||
<FilterCheckboxFieldsContainer>
|
||||
<FilterCheckboxField
|
||||
id="all"
|
||||
icon={<Layers className="h-4 w-4" />}
|
||||
checked={!query.teamIds && !query.userIds?.includes(session.data?.user.id || 0)}
|
||||
onChange={(e) => {
|
||||
removeAllQueryParams();
|
||||
}}
|
||||
label={t("all_apps")}
|
||||
/>
|
||||
|
||||
<FilterCheckboxField
|
||||
id="yours"
|
||||
icon={<User className="h-4 w-4" />}
|
||||
checked={!!query.userIds?.includes(session.data?.user.id || 0)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
pushItemToKey("userIds", session.data?.user.id || 0);
|
||||
} else if (!e.target.checked) {
|
||||
removeItemByKeyAndValue("userIds", session.data?.user.id || 0);
|
||||
}
|
||||
}}
|
||||
label={t("yours")}
|
||||
/>
|
||||
<Divider />
|
||||
{teams?.map((team) => (
|
||||
<FilterCheckboxField
|
||||
key={team.id}
|
||||
id={team.name}
|
||||
label={team.name}
|
||||
checked={!!query.teamIds?.includes(team.id)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
pushItemToKey("teamIds", team.id);
|
||||
} else if (!e.target.checked) {
|
||||
removeItemByKeyAndValue("teamIds", team.id);
|
||||
}
|
||||
}}
|
||||
icon={
|
||||
<Avatar
|
||||
alt={team?.name}
|
||||
imageSrc={getPlaceholderAvatar(team.logo, team?.name as string)}
|
||||
size="xs"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</FilterCheckboxFieldsContainer>
|
||||
</AnimatedPopover>
|
||||
);
|
||||
};
|
||||
|
||||
export const FilterCheckboxFieldsContainer = ({ children }: { children: ReactNode }) => {
|
||||
return <div className="flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1">{children}</div>;
|
||||
};
|
||||
|
||||
type Props = InputHTMLAttributes<HTMLInputElement> & {
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
};
|
||||
|
||||
export const FilterCheckboxField = forwardRef<HTMLInputElement, Props>(({ label, icon, ...rest }, ref) => {
|
||||
return (
|
||||
<div className="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 hover:cursor-pointer">
|
||||
<label className="flex w-full items-center justify-between hover:cursor-pointer">
|
||||
<div className="flex items-center">
|
||||
<div className="text-default flex h-4 w-4 items-center justify-center ltr:mr-2 rtl:ml-2">
|
||||
{icon}
|
||||
</div>
|
||||
<label htmlFor={rest.id} className="text-default cursor-pointer truncate text-sm">
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex h-5 items-center">
|
||||
<input
|
||||
{...rest}
|
||||
ref={ref}
|
||||
type="checkbox"
|
||||
className="text-primary-600 focus:ring-primary-500 border-default bg-default h-4 w-4 rounded hover:cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
FilterCheckboxField.displayName = "FilterCheckboxField";
|
|
@ -0,0 +1,28 @@
|
|||
import type { ParsedUrlQuery } from "querystring";
|
||||
import { z } from "zod";
|
||||
|
||||
import { queryNumberArray } from "@calcom/lib/hooks/useTypedQuery";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
|
||||
export type IEventTypesFilters = RouterOutputs["viewer"]["eventTypes"]["listWithTeam"];
|
||||
export type IEventTypeFilter = IEventTypesFilters[0];
|
||||
|
||||
// Use filterQuerySchema when parsing filters out of query, so that additional query params(e.g. slug, appPages) aren't passed in filters
|
||||
export const filterQuerySchema = z.object({
|
||||
teamIds: queryNumberArray.optional(),
|
||||
userIds: queryNumberArray.optional(),
|
||||
});
|
||||
|
||||
export const filterQuerySchemaStrict = z.object({
|
||||
teamIds: z.number().array().optional(),
|
||||
userIds: z.number().array().optional(),
|
||||
});
|
||||
|
||||
export const getTeamsFiltersFromQuery = (query: ParsedUrlQuery) => {
|
||||
const filters = filterQuerySchema.parse(query);
|
||||
// Ensure that filters are sorted so that react-query caching can work better
|
||||
// [1,2] is equivalent to [2,1] when fetching filter data.
|
||||
filters.teamIds = filters.teamIds?.sort();
|
||||
filters.userIds = filters.userIds?.sort();
|
||||
return filters;
|
||||
};
|
|
@ -0,0 +1,5 @@
|
|||
export function hasFilter(filters: Record<string, unknown>) {
|
||||
return Object.entries(filters).some(([, filter]) => {
|
||||
return !!filter;
|
||||
});
|
||||
}
|
|
@ -0,0 +1,138 @@
|
|||
import type { Membership, Team } from "@calcom/prisma/client";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
export const enum ENTITY_PERMISSION_LEVEL {
|
||||
NONE,
|
||||
USER_ONLY_WRITE,
|
||||
TEAM_READ_ONLY,
|
||||
TEAM_WRITE,
|
||||
}
|
||||
|
||||
export function canEditEntity(
|
||||
entity: Parameters<typeof getEntityPermissionLevel>[0],
|
||||
userId: Parameters<typeof getEntityPermissionLevel>[1]
|
||||
) {
|
||||
const permissionLevel = getEntityPermissionLevel(entity, userId);
|
||||
return (
|
||||
permissionLevel === ENTITY_PERMISSION_LEVEL.TEAM_WRITE ||
|
||||
permissionLevel === ENTITY_PERMISSION_LEVEL.USER_ONLY_WRITE
|
||||
);
|
||||
}
|
||||
|
||||
export function isOrganization({ team }: { team: { metadata: Team["metadata"] } }) {
|
||||
return teamMetadataSchema.parse(team.metadata)?.isOrganization;
|
||||
}
|
||||
|
||||
export function getEntityPermissionLevel(
|
||||
entity: {
|
||||
userId: number | null;
|
||||
team: { members: Membership[] } | null;
|
||||
},
|
||||
userId: number
|
||||
) {
|
||||
if (entity.team) {
|
||||
const roleForTeamMember = entity.team.members.find((member) => member.userId === userId)?.role;
|
||||
if (roleForTeamMember) {
|
||||
//TODO: Remove type assertion
|
||||
const hasWriteAccessToTeam = (
|
||||
[MembershipRole.ADMIN, MembershipRole.OWNER] as unknown as MembershipRole
|
||||
).includes(roleForTeamMember);
|
||||
if (hasWriteAccessToTeam) {
|
||||
return ENTITY_PERMISSION_LEVEL.TEAM_WRITE;
|
||||
} else {
|
||||
return ENTITY_PERMISSION_LEVEL.TEAM_READ_ONLY;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ownedByUser = entity.userId === userId;
|
||||
if (ownedByUser) {
|
||||
return ENTITY_PERMISSION_LEVEL.USER_ONLY_WRITE;
|
||||
}
|
||||
|
||||
return ENTITY_PERMISSION_LEVEL.NONE;
|
||||
}
|
||||
|
||||
async function getMembership(teamId: number | null, userId: number) {
|
||||
const { prisma } = await import("@calcom/prisma");
|
||||
|
||||
const team = teamId
|
||||
? await prisma.team.findFirst({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
members: true,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
return team?.members.find((membership) => membership.userId === userId);
|
||||
}
|
||||
|
||||
export async function canCreateEntity({
|
||||
targetTeamId,
|
||||
userId,
|
||||
}: {
|
||||
targetTeamId: number | null | undefined;
|
||||
userId: number;
|
||||
}) {
|
||||
if (targetTeamId) {
|
||||
// If it doesn't exist and it is being created for a team. Check if user is the member of the team
|
||||
const membership = await getMembership(targetTeamId, userId);
|
||||
const creationAllowed = membership ? withRoleCanCreateEntity(membership.role) : false;
|
||||
return creationAllowed;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
//TODO: Find a better convention to create different functions for different needs(withRoleCanCreateEntity vs canCreateEntity)
|
||||
// e.g. if role is already available we don't need to refetch membership.role. We can directly call `withRoleCanCreateEntity`
|
||||
export function withRoleCanCreateEntity(role: MembershipRole) {
|
||||
return role === "ADMIN" || role === "OWNER";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whenever the entity is fetched this clause should be there to ensure that
|
||||
* 1. No item that doesn't belong to the user or the team is fetched
|
||||
* Having just a reusable where allows it to be used with different types of prisma queries.
|
||||
*/
|
||||
export const entityPrismaWhereClause = ({ userId }: { userId: number }) => ({
|
||||
OR: [
|
||||
{ userId: userId },
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId: userId,
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* It returns true if the two entities are created for the same team or same user.
|
||||
*/
|
||||
export const areTheySiblingEntitites = ({
|
||||
entity1,
|
||||
entity2,
|
||||
}: {
|
||||
entity1: { teamId: number | null; userId: number | null };
|
||||
entity2: { teamId: number | null; userId: number | null };
|
||||
}) => {
|
||||
if (entity1.teamId) {
|
||||
return entity1.teamId === entity2.teamId;
|
||||
}
|
||||
// If entity doesn't belong to a team, then target shouldn't be a team.
|
||||
// Also check for `userId` now.
|
||||
return !entity2.teamId && entity1.userId === entity2.userId;
|
||||
};
|
|
@ -57,7 +57,8 @@ export function useTypedQuery<T extends z.AnyZodObject>(schema: T) {
|
|||
// Delete a key from the query
|
||||
function removeByKey(key: OutputOptionalKeys) {
|
||||
const { [key]: _, ...newQuery } = parsedQuery;
|
||||
router.replace({ query: newQuery as Output }, undefined, { shallow: true });
|
||||
const search = new URLSearchParams(newQuery).toString();
|
||||
router.replace({ query: search }, undefined, { shallow: true });
|
||||
}
|
||||
|
||||
// push item to existing key
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "App_RoutingForms_Form" ADD COLUMN "teamId" INTEGER;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "App_RoutingForms_Form" ADD CONSTRAINT "App_RoutingForms_Form_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
@ -269,6 +269,7 @@ model Team {
|
|||
orgUsers User[] @relation("scope")
|
||||
inviteToken VerificationToken?
|
||||
webhooks Webhook[]
|
||||
routingForms App_RoutingForms_Form[]
|
||||
|
||||
@@unique([slug, parentId])
|
||||
}
|
||||
|
@ -645,7 +646,11 @@ model App_RoutingForms_Form {
|
|||
name String
|
||||
fields Json?
|
||||
user User @relation("routing-form", fields: [userId], references: [id], onDelete: Cascade)
|
||||
// This is the user who created the form and also the user who has read-write access to the form
|
||||
// If teamId is set, the members of the team would also have access to form readOnly or read-write depending on their permission level as team member.
|
||||
userId Int
|
||||
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
teamId Int?
|
||||
responses App_RoutingForms_FormResponse[]
|
||||
disabled Boolean @default(false)
|
||||
/// @zod.custom(imports.RoutingFormSettings)
|
||||
|
|
|
@ -40,6 +40,7 @@ type AppsRouterHandlerCache = {
|
|||
getDownloadLinkOfCalVideoRecordings?: typeof import("./getDownloadLinkOfCalVideoRecordings.handler").getDownloadLinkOfCalVideoRecordingsHandler;
|
||||
getUsersDefaultConferencingApp?: typeof import("./getUsersDefaultConferencingApp.handler").getUsersDefaultConferencingAppHandler;
|
||||
updateUserDefaultConferencingApp?: typeof import("./updateUserDefaultConferencingApp.handler").updateUserDefaultConferencingAppHandler;
|
||||
teamsAndUserProfilesQuery?: typeof import("./teamsAndUserProfilesQuery.handler").teamsAndUserProfilesQuery;
|
||||
};
|
||||
|
||||
const UNSTABLE_HANDLER_CACHE: AppsRouterHandlerCache = {};
|
||||
|
@ -383,4 +384,18 @@ export const loggedInViewerRouter = router({
|
|||
|
||||
return UNSTABLE_HANDLER_CACHE.shouldVerifyEmail({ ctx });
|
||||
}),
|
||||
teamsAndUserProfilesQuery: authedProcedure.query(async ({ ctx }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.teamsAndUserProfilesQuery) {
|
||||
UNSTABLE_HANDLER_CACHE.teamsAndUserProfilesQuery = (
|
||||
await import("./teamsAndUserProfilesQuery.handler")
|
||||
).teamsAndUserProfilesQuery;
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.teamsAndUserProfilesQuery) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.teamsAndUserProfilesQuery({ ctx });
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -0,0 +1,74 @@
|
|||
import { type PrismaClient } from "@prisma/client";
|
||||
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { isOrganization, withRoleCanCreateEntity } from "@calcom/lib/entityPermissionUtils";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
type TeamsAndUserProfileOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
prisma: PrismaClient;
|
||||
};
|
||||
};
|
||||
|
||||
export const teamsAndUserProfilesQuery = async ({ ctx }: TeamsAndUserProfileOptions) => {
|
||||
const { prisma } = ctx;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: ctx.user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
avatar: true,
|
||||
teams: {
|
||||
where: {
|
||||
accepted: true,
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
metadata: true,
|
||||
members: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
|
||||
}
|
||||
const image = user?.username ? `${CAL_URL}/${user.username}/avatar.png` : undefined;
|
||||
const nonOrgTeams = user.teams.filter((membership) => !isOrganization({ team: membership.team }));
|
||||
|
||||
return [
|
||||
{
|
||||
teamId: null,
|
||||
name: user.name,
|
||||
slug: user.username,
|
||||
image,
|
||||
readOnly: false,
|
||||
},
|
||||
...nonOrgTeams.map((membership) => ({
|
||||
teamId: membership.team.id,
|
||||
name: membership.team.name,
|
||||
slug: membership.team.slug ? "team/" + membership.team.slug : null,
|
||||
image: `${CAL_URL}/team/${membership.team.slug}/avatar.png`,
|
||||
role: membership.role,
|
||||
readOnly: !withRoleCanCreateEntity(membership.role),
|
||||
})),
|
||||
];
|
||||
};
|
|
@ -21,7 +21,7 @@ export interface Option {
|
|||
slug: string | null;
|
||||
}
|
||||
|
||||
interface CreateBtnProps {
|
||||
export type CreateBtnProps = {
|
||||
options: Option[];
|
||||
createDialog?: () => JSX.Element;
|
||||
createFunction?: (teamId?: number) => void;
|
||||
|
@ -29,15 +29,28 @@ interface CreateBtnProps {
|
|||
buttonText?: string;
|
||||
isLoading?: boolean;
|
||||
disableMobileButton?: boolean;
|
||||
}
|
||||
"data-testid"?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated use CreateButtonWithTeamsList instead
|
||||
*/
|
||||
export function CreateButton(props: CreateBtnProps) {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const {
|
||||
createDialog,
|
||||
options,
|
||||
isLoading,
|
||||
createFunction,
|
||||
buttonText,
|
||||
disableMobileButton,
|
||||
subtitle,
|
||||
...restProps
|
||||
} = props;
|
||||
const CreateDialog = createDialog ? createDialog() : null;
|
||||
|
||||
const CreateDialog = props.createDialog ? props.createDialog() : null;
|
||||
|
||||
const hasTeams = !!props.options.find((option) => option.teamId);
|
||||
const hasTeams = !!options.find((option) => option.teamId);
|
||||
|
||||
// inject selection data into url for correct router history
|
||||
const openModal = (option: Option) => {
|
||||
|
@ -66,33 +79,33 @@ export function CreateButton(props: CreateBtnProps) {
|
|||
<Button
|
||||
onClick={() =>
|
||||
!!CreateDialog
|
||||
? openModal(props.options[0])
|
||||
: props.createFunction
|
||||
? props.createFunction(props.options[0].teamId || undefined)
|
||||
? openModal(options[0])
|
||||
: createFunction
|
||||
? createFunction(options[0].teamId || undefined)
|
||||
: null
|
||||
}
|
||||
data-testid="new-event-type"
|
||||
StartIcon={Plus}
|
||||
loading={props.isLoading}
|
||||
variant={props.disableMobileButton ? "button" : "fab"}>
|
||||
{props.buttonText ? props.buttonText : t("new")}
|
||||
loading={isLoading}
|
||||
variant={disableMobileButton ? "button" : "fab"}
|
||||
{...restProps}>
|
||||
{buttonText ? buttonText : t("new")}
|
||||
</Button>
|
||||
) : (
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant={props.disableMobileButton ? "button" : "fab"}
|
||||
variant={disableMobileButton ? "button" : "fab"}
|
||||
StartIcon={Plus}
|
||||
data-testid="new-event-type-dropdown"
|
||||
loading={props.isLoading}>
|
||||
{props.buttonText ? props.buttonText : t("new")}
|
||||
loading={isLoading}
|
||||
{...restProps}>
|
||||
{buttonText ? buttonText : t("new")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent sideOffset={14} align="end">
|
||||
<DropdownMenuLabel>
|
||||
<div className="w-48 text-left text-xs">{props.subtitle}</div>
|
||||
<div className="w-48 text-left text-xs">{subtitle}</div>
|
||||
</DropdownMenuLabel>
|
||||
{props.options.map((option, idx) => (
|
||||
{options.map((option, idx) => (
|
||||
<DropdownMenuItem key={option.label}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
|
@ -108,8 +121,8 @@ export function CreateButton(props: CreateBtnProps) {
|
|||
onClick={() =>
|
||||
!!CreateDialog
|
||||
? openModal(option)
|
||||
: props.createFunction
|
||||
? props.createFunction(option.teamId || undefined)
|
||||
: createFunction
|
||||
? createFunction(option.teamId || undefined)
|
||||
: null
|
||||
}>
|
||||
{" "}
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
import type { CreateBtnProps } from "./CreateButton";
|
||||
import { CreateButton } from "./CreateButton";
|
||||
|
||||
export function CreateButtonWithTeamsList(props: Omit<CreateBtnProps, "options">) {
|
||||
const query = trpc.viewer.teamsAndUserProfilesQuery.useQuery();
|
||||
if (!query.data) return null;
|
||||
|
||||
const teamsAndUserProfiles = query.data
|
||||
.filter((profile) => !profile.readOnly)
|
||||
.map((profile) => {
|
||||
return {
|
||||
teamId: profile.teamId,
|
||||
label: profile.name || profile.slug,
|
||||
image: profile.image,
|
||||
slug: profile.slug,
|
||||
};
|
||||
});
|
||||
|
||||
return <CreateButton {...props} options={teamsAndUserProfiles} />;
|
||||
}
|
|
@ -1 +1,2 @@
|
|||
export { CreateButton } from "./CreateButton";
|
||||
export { CreateButtonWithTeamsList } from "./CreateButtonWithTeamsList";
|
||||
|
|
|
@ -2,6 +2,9 @@ import Link from "next/link";
|
|||
import { createElement } from "react";
|
||||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
import { Badge } from "../badge";
|
||||
|
||||
export type ListProps = {
|
||||
roundContainer?: boolean;
|
||||
|
@ -70,6 +73,7 @@ export type ListLinkItemProps = {
|
|||
|
||||
export function ListLinkItem(props: ListLinkItemProps) {
|
||||
const { href, heading = "", children, disabled = false, actions = <div />, className = "" } = props;
|
||||
const { t } = useLocale();
|
||||
let subHeading = props.subHeading;
|
||||
if (!subHeading) {
|
||||
subHeading = "";
|
||||
|
@ -77,7 +81,7 @@ export function ListLinkItem(props: ListLinkItemProps) {
|
|||
return (
|
||||
<li
|
||||
className={classNames(
|
||||
"group flex w-full items-center justify-between p-5",
|
||||
"group flex w-full items-center justify-between p-5 pb-4",
|
||||
className,
|
||||
disabled ? "hover:bg-muted" : ""
|
||||
)}>
|
||||
|
@ -88,8 +92,15 @@ export function ListLinkItem(props: ListLinkItemProps) {
|
|||
"text-default flex-grow truncate text-sm",
|
||||
disabled ? "pointer-events-none cursor-not-allowed opacity-30" : ""
|
||||
)}>
|
||||
<h1 className="text-sm font-semibold leading-none">{heading}</h1>
|
||||
<h2 className="min-h-4 mt-2 text-sm font-normal leading-none">
|
||||
<div className="flex items-center">
|
||||
<h1 className="text-sm font-semibold leading-none">{heading}</h1>
|
||||
{disabled && (
|
||||
<Badge variant="gray" className="ml-2">
|
||||
{t("readonly")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="min-h-4 mt-2 text-sm font-normal leading-none text-neutral-600">
|
||||
{subHeading.substring(0, 100)}
|
||||
{subHeading.length > 100 && "..."}
|
||||
</h2>
|
||||
|
|
|
@ -47,7 +47,7 @@ export const AnimatedPopover = ({
|
|||
<div
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
"hover:border-emphasis border-default text-default hover:text-emphasis mb-2 flex h-9 max-h-72 items-center justify-between whitespace-nowrap rounded-md border px-3 py-2 text-sm hover:cursor-pointer focus:border-neutral-300 focus:outline-none focus:ring-2 focus:ring-neutral-800 focus:ring-offset-1",
|
||||
"hover:border-emphasis border-default text-default hover:text-emphasis mb-4 flex h-9 max-h-72 items-center justify-between whitespace-nowrap rounded-md border px-3 py-2 text-sm hover:cursor-pointer focus:border-neutral-300 focus:outline-none focus:ring-2 focus:ring-neutral-800 focus:ring-offset-1",
|
||||
popoverTriggerClassNames
|
||||
)}>
|
||||
<div className="max-w-36 flex items-center">
|
||||
|
@ -68,7 +68,7 @@ export const AnimatedPopover = ({
|
|||
<Popover.Content side="bottom" align={align} asChild>
|
||||
<div
|
||||
className={classNames(
|
||||
"bg-default border-default scroll-bar absolute z-50 mt-2 max-h-64 w-56 overflow-y-scroll rounded-md border py-[2px] shadow-sm focus-within:outline-none",
|
||||
"bg-default border-default scroll-bar absolute z-50 mt-2 max-h-64 w-56 overflow-y-auto rounded-md border py-[2px] shadow-sm focus-within:outline-none",
|
||||
align === "end" && "-translate-x-[228px]"
|
||||
)}>
|
||||
{children}
|
||||
|
|
|
@ -129,7 +129,8 @@ export { default as MultiSelectCheckboxes } from "./components/form/checkbox/Mul
|
|||
export type { Option as MultiSelectCheckboxesOptionType } from "./components/form/checkbox/MultiSelectCheckboxes";
|
||||
export { default as ImageUploader } from "./components/image-uploader/ImageUploader";
|
||||
export type { ButtonColor } from "./components/button/Button";
|
||||
export { CreateButton } from "./components/createButton";
|
||||
export { CreateButton, CreateButtonWithTeamsList } from "./components/createButton";
|
||||
|
||||
export { useCalcomTheme } from "./styles/useCalcomTheme";
|
||||
export { ScrollableArea } from "./components/scrollable/ScrollableArea";
|
||||
export { WizardLayout } from "./layouts/WizardLayout";
|
||||
|
|
|
@ -69,7 +69,7 @@ const config: PlaywrightTestConfig = {
|
|||
outputDir: path.join(outputDir, "results"),
|
||||
webServer,
|
||||
use: {
|
||||
baseURL: "http://localhost:3000/",
|
||||
baseURL: process.env.NEXT_PUBLIC_WEBAPP_URL,
|
||||
locale: "en-US",
|
||||
trace: "retain-on-failure",
|
||||
headless,
|
||||
|
|
Loading…
Reference in New Issue
Block a user