From 53224886e39dac1a86e6c9b270c2af4c71b05176 Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Thu, 15 Jun 2023 14:28:07 +0530 Subject: [PATCH] feat: Routing Forms/Teams Support (#9417) --- apps/web/pages/event-types/index.tsx | 1 + apps/web/playwright/lib/testUtils.ts | 2 +- .../web/playwright/managed-event-types.e2e.ts | 2 +- apps/web/playwright/webhook.e2e.ts | 2 + apps/web/public/static/locales/en/common.json | 5 + apps/web/server/lib/ssr.ts | 2 + .../routing-forms/api/responses/[formId].ts | 28 +++- .../routing-forms/components/FormActions.tsx | 76 ++++++--- .../components/RoutingNavBar.tsx | 2 +- .../routing-forms/components/SingleForm.tsx | 33 +++- .../routing-forms/lib/getSerializableForm.ts | 20 ++- .../lib/isFormCreateEditAllowed.ts | 41 +++++ .../routing-forms/lib/isFormEditAllowed.ts | 25 --- .../pages/forms/[...appPages].tsx | 156 ++++++++++-------- .../pages/route-builder/[...appPages].tsx | 39 ++++- .../pages/router/[...appPages].tsx | 2 +- .../pages/routing-link/[...appPages].tsx | 2 +- .../playwright/tests/basic.e2e.ts | 8 +- .../app-store/routing-forms/trpc/_router.ts | 5 +- .../routing-forms/trpc/deleteForm.handler.ts | 19 ++- .../trpc/formMutation.handler.ts | 51 +++++- .../routing-forms/trpc/formMutation.schema.ts | 1 + .../routing-forms/trpc/formQuery.handler.ts | 13 +- .../routing-forms/trpc/forms.handler.ts | 117 ++++++++++++- .../routing-forms/trpc/forms.schema.ts | 11 ++ .../routing-forms/trpc/report.handler.ts | 2 +- .../routing-forms/trpc/response.handler.ts | 2 +- .../embed-core/playwright/lib/testUtils.ts | 57 ++++--- .../filters/components/FilterResults.tsx | 25 +++ .../filters/components/TeamsFilter.tsx | 141 ++++++++++++++++ .../filters/lib/getTeamsFiltersFromQuery.ts | 28 ++++ packages/features/filters/lib/hasFilter.ts | 5 + packages/lib/entityPermissionUtils.ts | 138 ++++++++++++++++ packages/lib/hooks/useTypedQuery.ts | 3 +- .../migration.sql | 5 + packages/prisma/schema.prisma | 5 + .../server/routers/loggedInViewer/_router.tsx | 15 ++ .../teamsAndUserProfilesQuery.handler.ts | 74 +++++++++ .../components/createButton/CreateButton.tsx | 53 +++--- .../CreateButtonWithTeamsList.tsx | 22 +++ packages/ui/components/createButton/index.ts | 1 + packages/ui/components/list/List.tsx | 17 +- .../ui/components/popover/AnimatedPopover.tsx | 4 +- packages/ui/index.tsx | 3 +- playwright.config.ts | 2 +- 45 files changed, 1046 insertions(+), 219 deletions(-) create mode 100644 packages/app-store/routing-forms/lib/isFormCreateEditAllowed.ts delete mode 100644 packages/app-store/routing-forms/lib/isFormEditAllowed.ts create mode 100644 packages/app-store/routing-forms/trpc/forms.schema.ts create mode 100644 packages/features/filters/components/FilterResults.tsx create mode 100644 packages/features/filters/components/TeamsFilter.tsx create mode 100644 packages/features/filters/lib/getTeamsFiltersFromQuery.ts create mode 100644 packages/features/filters/lib/hasFilter.ts create mode 100644 packages/lib/entityPermissionUtils.ts create mode 100644 packages/prisma/migrations/20230605142353_team_routing_forms/migration.sql create mode 100644 packages/trpc/server/routers/loggedInViewer/teamsAndUserProfilesQuery.handler.ts create mode 100644 packages/ui/components/createButton/CreateButtonWithTeamsList.tsx diff --git a/apps/web/pages/event-types/index.tsx b/apps/web/pages/event-types/index.tsx index 32666d43d4..af8bdb5f72 100644 --- a/apps/web/pages/event-types/index.tsx +++ b/apps/web/pages/event-types/index.tsx @@ -789,6 +789,7 @@ const CTA = () => { return ( } diff --git a/apps/web/playwright/lib/testUtils.ts b/apps/web/playwright/lib/testUtils.ts index 10ee7d6b25..f9a999880c 100644 --- a/apps/web/playwright/lib/testUtils.ts +++ b/apps/web/playwright/lib/testUtils.ts @@ -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)); } diff --git a/apps/web/playwright/managed-event-types.e2e.ts b/apps/web/playwright/managed-event-types.e2e.ts index c111be43dc..52e6bf86c6 100644 --- a/apps/web/playwright/managed-event-types.e2e.ts +++ b/apps/web/playwright/managed-event-types.e2e.ts @@ -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(); diff --git a/apps/web/playwright/webhook.e2e.ts b/apps/web/playwright/webhook.e2e.ts index 9c4175b2cc..cee6639f6d 100644 --- a/apps/web/playwright/webhook.e2e.ts +++ b/apps/web/playwright/webhook.e2e.ts @@ -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"]'); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 7909b256e0..db1f66368c 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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", diff --git a/apps/web/server/lib/ssr.ts b/apps/web/server/lib/ssr.ts index ef834f8174..1afc0c5f62 100644 --- a/apps/web/server/lib/ssr.ts +++ b/apps/web/server/lib/ssr.ts @@ -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; } diff --git a/packages/app-store/routing-forms/api/responses/[formId].ts b/packages/app-store/routing-forms/api/responses/[formId].ts index 040623ac56..0320b5d66f 100644 --- a/packages/app-store/routing-forms/api/responses/[formId].ts +++ b/packages/app-store/routing-forms/api/responses/[formId].ts @@ -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", diff --git a/packages/app-store/routing-forms/components/FormActions.tsx b/packages/app-store/routing-forms/components/FormActions.tsx index 483e31014f..357763416c 100644 --- a/packages/app-store/routing-forms/components/FormActions.tsx +++ b/packages/app-store/routing-forms/components/FormActions.tsx @@ -39,11 +39,11 @@ import type { SerializableForm } from "../types/types"; type RoutingForm = SerializableForm; 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) => { 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; + const formToDuplicate = action === "duplicate" ? target : null; + const teamId = action === "new" ? Number(target) : null; + const { register } = hookForm; return (

{t("form_description")}

@@ -105,7 +108,8 @@ function NewFormDialog({ appUrl }: { appUrl: string }) { id: formId, ...values, addFallback: true, - duplicateFrom: action === "duplicate" ? target : null, + teamId, + duplicateFrom: formToDuplicate, }); }}>
@@ -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 ( - + ) : ( -
{props.subtitle}
+
{subtitle}
- {props.options.map((option, idx) => ( + {options.map((option, idx) => ( !!CreateDialog ? openModal(option) - : props.createFunction - ? props.createFunction(option.teamId || undefined) + : createFunction + ? createFunction(option.teamId || undefined) : null }> {" "} diff --git a/packages/ui/components/createButton/CreateButtonWithTeamsList.tsx b/packages/ui/components/createButton/CreateButtonWithTeamsList.tsx new file mode 100644 index 0000000000..5aabdfb5e7 --- /dev/null +++ b/packages/ui/components/createButton/CreateButtonWithTeamsList.tsx @@ -0,0 +1,22 @@ +import { trpc } from "@calcom/trpc/react"; + +import type { CreateBtnProps } from "./CreateButton"; +import { CreateButton } from "./CreateButton"; + +export function CreateButtonWithTeamsList(props: Omit) { + 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 ; +} diff --git a/packages/ui/components/createButton/index.ts b/packages/ui/components/createButton/index.ts index b73942be77..5f8370f991 100644 --- a/packages/ui/components/createButton/index.ts +++ b/packages/ui/components/createButton/index.ts @@ -1 +1,2 @@ export { CreateButton } from "./CreateButton"; +export { CreateButtonWithTeamsList } from "./CreateButtonWithTeamsList"; diff --git a/packages/ui/components/list/List.tsx b/packages/ui/components/list/List.tsx index 9289ef57aa..b34856cfb9 100644 --- a/packages/ui/components/list/List.tsx +++ b/packages/ui/components/list/List.tsx @@ -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 =
, className = "" } = props; + const { t } = useLocale(); let subHeading = props.subHeading; if (!subHeading) { subHeading = ""; @@ -77,7 +81,7 @@ export function ListLinkItem(props: ListLinkItemProps) { return (
  • @@ -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" : "" )}> -

    {heading}

    -

    +
    +

    {heading}

    + {disabled && ( + + {t("readonly")} + + )} +
    +

    {subHeading.substring(0, 100)} {subHeading.length > 100 && "..."}

    diff --git a/packages/ui/components/popover/AnimatedPopover.tsx b/packages/ui/components/popover/AnimatedPopover.tsx index 1d30874b15..0c282a5af0 100644 --- a/packages/ui/components/popover/AnimatedPopover.tsx +++ b/packages/ui/components/popover/AnimatedPopover.tsx @@ -47,7 +47,7 @@ export const AnimatedPopover = ({
    @@ -68,7 +68,7 @@ export const AnimatedPopover = ({
    {children} diff --git a/packages/ui/index.tsx b/packages/ui/index.tsx index e53d0005af..d8cd305f16 100644 --- a/packages/ui/index.tsx +++ b/packages/ui/index.tsx @@ -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"; diff --git a/playwright.config.ts b/playwright.config.ts index 9d0188c508..7b45b1f672 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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,