feat: Routing Forms/Teams Support (#9417)

This commit is contained in:
Hariom Balhara 2023-06-15 14:28:07 +05:30 committed by GitHub
parent e513180d7e
commit 53224886e3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 1046 additions and 219 deletions

View File

@ -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} />}

View File

@ -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));
}

View File

@ -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();

View File

@ -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"]');

View File

@ -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",

View File

@ -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;
}

View File

@ -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",

View File

@ -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) : "" });
};

View File

@ -6,7 +6,7 @@ export default function RoutingNavBar({
form,
appUrl,
}: {
form: ReturnType<typeof getSerializableForm>;
form: Awaited<ReturnType<typeof getSerializableForm>>;
appUrl: string;
}) {
const tabs = [

View File

@ -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 }),
},
};
};

View File

@ -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,

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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(),
},
};
};

View File

@ -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}

View File

@ -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) => {

View File

@ -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 }),
},
};
};

View File

@ -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"]');

View File

@ -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"));

View File

@ -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;

View File

@ -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(

View File

@ -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(),
});

View File

@ -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;

View File

@ -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;
}

View File

@ -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>;

View File

@ -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: {

View File

@ -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({

View File

@ -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`);

View File

@ -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;
}

View File

@ -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";

View File

@ -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;
};

View File

@ -0,0 +1,5 @@
export function hasFilter(filters: Record<string, unknown>) {
return Object.entries(filters).some(([, filter]) => {
return !!filter;
});
}

View File

@ -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;
};

View File

@ -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

View File

@ -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;

View File

@ -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)

View File

@ -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 });
}),
});

View File

@ -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),
})),
];
};

View File

@ -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
}>
{" "}

View File

@ -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} />;
}

View File

@ -1 +1,2 @@
export { CreateButton } from "./CreateButton";
export { CreateButtonWithTeamsList } from "./CreateButtonWithTeamsList";

View File

@ -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>

View File

@ -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}

View File

@ -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";

View File

@ -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,