Skip to content

Commit

Permalink
feat: Routing Forms/Teams Support (calcom#9417)
Browse files Browse the repository at this point in the history
  • Loading branch information
hariombalhara authored Jun 15, 2023
1 parent e513180 commit 5322488
Show file tree
Hide file tree
Showing 45 changed files with 1,046 additions and 219 deletions.
1 change: 1 addition & 0 deletions apps/web/pages/event-types/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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} />}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/playwright/lib/testUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
2 changes: 1 addition & 1 deletion apps/web/playwright/managed-event-types.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions apps/web/playwright/webhook.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]');
Expand Down
5 changes: 5 additions & 0 deletions apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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.",
Expand All @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/server/lib/ssr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
28 changes: 26 additions & 2 deletions packages/app-store/routing-forms/api/responses/[formId].ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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");
}
Expand All @@ -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",
Expand Down
76 changes: 53 additions & 23 deletions packages/app-store/routing-forms/components/FormActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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();
Expand All @@ -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>
Expand All @@ -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">
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -190,23 +199,28 @@ 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();
setDeleteDialogOpen(false);
},
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");
},
Expand Down Expand Up @@ -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 };
},
Expand All @@ -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");
},
});

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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: () => {
Expand All @@ -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 <></>;
}
Expand All @@ -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 })}
Expand Down Expand Up @@ -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) : "" });
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export default function RoutingNavBar({
form,
appUrl,
}: {
form: ReturnType<typeof getSerializableForm>;
form: Awaited<ReturnType<typeof getSerializableForm>>;
appUrl: string;
}) {
const tabs = [
Expand Down
Loading

0 comments on commit 5322488

Please sign in to comment.