Routing Forms Improvements - Rename routing_forms to routing-forms (#4546)

* Animate fields list and routes list

* Rename routing_forms slug to routing-forms

* Add comments

* Fixtypo

* Add dropdown separator for consistency

* Fix missing occurences of routing_forms and improve types for webhooks

* Fix weird error about title child is an array

* Fix webhook issues

* Fix webhook tests and issues found during fixing them

* Fix lint errors and warnings

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
Hariom Balhara
2022-09-22 22:53:43 +05:30
committed by GitHub
co-authored by kodiakhq[bot]
parent 81aba55710
commit ce41397517
54 changed files with 296 additions and 235 deletions
+2 -2
View File
@@ -9,10 +9,10 @@ export const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP = {
WebhookTriggerEvents.BOOKING_RESCHEDULED,
WebhookTriggerEvents.MEETING_ENDED,
] as const,
routing_forms: [WebhookTriggerEvents.FORM_SUBMITTED] as ["FORM_SUBMITTED"],
"routing-forms": [WebhookTriggerEvents.FORM_SUBMITTED] as ["FORM_SUBMITTED"],
};
export const WEBHOOK_TRIGGER_EVENTS = [
...WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP.core,
...WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP.routing_forms,
...WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP["routing-forms"],
] as const;
+10 -1
View File
@@ -24,7 +24,8 @@ const V2_WHITELIST = [
// For pages
// - which has V1 versions being modified as V2
const V2_BLACKLIST = ["/apps/routing_forms/", "/apps/typeform/"];
// - Add routing_forms to keep old links working
const V2_BLACKLIST = ["/apps/routing_forms/", "/apps/routing-forms/", "/apps/typeform/"];
const middleware: NextMiddleware = async (req) => {
const url = req.nextUrl;
@@ -43,6 +44,13 @@ const middleware: NextMiddleware = async (req) => {
return NextResponse.redirect(req.nextUrl);
}
}
// Don't 404 old routing_forms links
if (url.pathname.startsWith("/apps/routing_forms")) {
url.pathname = url.pathname.replace("/apps/routing_forms", "/apps/routing-forms");
return NextResponse.rewrite(url);
}
/** Display available V2 pages */
if (
!V2_BLACKLIST.some((p) => url.pathname.startsWith(p)) &&
@@ -52,6 +60,7 @@ const middleware: NextMiddleware = async (req) => {
url.pathname = `/v2${url.pathname}`;
return NextResponse.rewrite(url);
}
return NextResponse.next();
};
+2 -2
View File
@@ -135,11 +135,11 @@ const nextConfig = {
},
{
source: "/forms/:formId",
destination: "/apps/routing_forms/routing-link/:formId",
destination: "/apps/routing-forms/routing-link/:formId",
},
{
source: "/router",
destination: "/apps/routing_forms/router",
destination: "/apps/routing-forms/router",
},
/* TODO: have these files being served from another deployment or CDN {
source: "/embed/embed.js",
+2 -2
View File
@@ -1,7 +1,7 @@
import { GetServerSidePropsContext } from "next";
import { useRouter } from "next/router";
import RoutingFormsRoutingConfig from "@calcom/app-store/ee/routing_forms/pages/app-routing.config";
import RoutingFormsRoutingConfig from "@calcom/app-store/ee/routing-forms/pages/app-routing.config";
import TypeformRoutingConfig from "@calcom/app-store/typeform/pages/app-routing.config";
import prisma from "@calcom/prisma";
import { AppGetServerSideProps } from "@calcom/types/AppGetServerSideProps";
@@ -28,7 +28,7 @@ type NotFound = {
// TODO: It is a candidate for apps.*.generated.*
const AppsRouting = {
routing_forms: RoutingFormsRoutingConfig,
"routing-forms": RoutingFormsRoutingConfig,
typeform: TypeformRoutingConfig,
};
+2 -2
View File
@@ -10,7 +10,7 @@ export default function Settings() {
const { data: routingForms } = trpc.useQuery([
"viewer.appById",
{
appId: "routing_forms",
appId: "routing-forms",
},
]);
@@ -19,7 +19,7 @@ export default function Settings() {
<WebhookListContainer title="Event Webhooks" subtitle={t("receive_cal_meeting_data")} />
{routingForms && (
<WebhookListContainer
appId="routing_forms"
appId="routing-forms"
title="Routing Webhooks"
subtitle="Receive Routing Form responses at a specified URL, in real-time, when a Routing Form is submitted"
/>
+2 -73
View File
@@ -6,7 +6,7 @@ import { v4 as uuidv4 } from "uuid";
import { prisma } from "@calcom/prisma";
import { test } from "./lib/fixtures";
import { createHttpServer, selectFirstAvailableTimeSlotNextMonth, todo, waitFor } from "./lib/testUtils";
import { todo } from "./lib/testUtils";
declare let global: {
E2E_EMAILS?: ({ text: string } | Record<string, unknown>)[];
@@ -182,7 +182,7 @@ test.fixme("Integrations", () => {
},
token: {
url: "https://zoom.us/oauth/token",
verify({ requestHeaders, code }) {
verify({ requestHeaders }) {
const authorization = requestHeaders.get("authorization").replace("Basic ", "");
const clientPair = Buffer.from(authorization, "base64").toString();
const [clientId, clientSecret] = clientPair.split(":");
@@ -361,75 +361,4 @@ test.fixme("Integrations", () => {
todo("Can add CalDav Calendar");
todo("Can add Apple Calendar");
test("add webhook & test that creating an event triggers a webhook call", async ({
page,
users,
}, testInfo) => {
const webhookReceiver = createHttpServer();
const user = await users.create();
const [eventType] = user.eventTypes;
await user.login();
await page.goto(`/settings/developer`);
// --- add webhook
await page.click('[data-testid="new_webhook"]');
await expect(page.locator(`[data-testid='WebhookDialogForm']`)).toBeVisible();
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
await page.fill('[name="secret"]', "secret");
await page.click("[type=submit]");
// dialog is closed
await expect(page.locator(`[data-testid='WebhookDialogForm']`)).not.toBeVisible();
// page contains the url
expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined();
// --- Book the first available day next month in the pro user's "30min"-event
await page.goto(`/${user.username}/${eventType.slug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
// --- fill form
await page.fill('[name="name"]', "Test Testson");
await page.fill('[name="email"]', "test@example.com");
await page.press('[name="email"]', "Enter");
// --- check that webhook was called
await waitFor(() => {
expect(webhookReceiver.requestList.length).toBe(1);
});
const [request] = webhookReceiver.requestList;
const body = request.body as any;
// remove dynamic properties that differs depending on where you run the tests
const dynamic = "[redacted/dynamic]";
body.createdAt = dynamic;
body.payload.startTime = dynamic;
body.payload.endTime = dynamic;
body.payload.location = dynamic;
for (const attendee of body.payload.attendees) {
attendee.timeZone = dynamic;
attendee.language = dynamic;
}
body.payload.organizer.email = dynamic;
body.payload.organizer.timeZone = dynamic;
body.payload.organizer.language = dynamic;
body.payload.uid = dynamic;
body.payload.bookingId = dynamic;
body.payload.additionalInformation = dynamic;
body.payload.requiresConfirmation = dynamic;
body.payload.eventTypeId = dynamic;
// if we change the shape of our webhooks, we can simply update this by clicking `u`
// console.log("BODY", body);
// Text files shouldn't have platform specific suffixes
testInfo.snapshotSuffix = "";
expect(JSON.stringify(body)).toMatchSnapshot(`webhookResponse.txt`);
webhookReceiver.close();
});
});
+76
View File
@@ -0,0 +1,76 @@
import { expect } from "@playwright/test";
import { test } from "./lib/fixtures";
import { createHttpServer, selectFirstAvailableTimeSlotNextMonth, waitFor } from "./lib/testUtils";
test("add webhook & test that creating an event triggers a webhook call", async ({
page,
users,
}, testInfo) => {
const webhookReceiver = createHttpServer();
const user = await users.create();
const [eventType] = user.eventTypes;
await user.login();
await page.goto(`/settings/developer/webhooks`);
// --- add webhook
await page.click('[data-testid="new_webhook"]');
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
await page.fill('[name="secret"]', "secret");
await Promise.all([
page.click("[type=submit]"),
page.waitForNavigation({
url: (url) => url.pathname.endsWith("/settings/developer/webhooks"),
}),
]);
// page contains the url
expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined();
// --- Book the first available day next month in the pro user's "30min"-event
await page.goto(`/${user.username}/${eventType.slug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
// --- fill form
await page.fill('[name="name"]', "Test Testson");
await page.fill('[name="email"]', "test@example.com");
await page.press('[name="email"]', "Enter");
// --- check that webhook was called
await waitFor(() => {
expect(webhookReceiver.requestList.length).toBe(1);
});
const [request] = webhookReceiver.requestList;
const body = request.body as any;
// remove dynamic properties that differs depending on where you run the tests
const dynamic = "[redacted/dynamic]";
body.createdAt = dynamic;
body.payload.startTime = dynamic;
body.payload.endTime = dynamic;
body.payload.location = dynamic;
for (const attendee of body.payload.attendees) {
attendee.timeZone = dynamic;
attendee.language = dynamic;
}
body.payload.organizer.email = dynamic;
body.payload.organizer.timeZone = dynamic;
body.payload.organizer.language = dynamic;
body.payload.uid = dynamic;
body.payload.bookingId = dynamic;
body.payload.additionalInformation = dynamic;
body.payload.requiresConfirmation = dynamic;
body.payload.eventTypeId = dynamic;
// if we change the shape of our webhooks, we can simply update this by clicking `u`
// console.log("BODY", body);
// Text files shouldn't have platform specific suffixes
testInfo.snapshotSuffix = "";
expect(JSON.stringify(body)).toMatchSnapshot(`webhookResponse.txt`);
webhookReceiver.close();
});
@@ -0,0 +1 @@
{"triggerEvent":"BOOKING_CREATED","createdAt":"[redacted/dynamic]","payload":{"type":"30 min","title":"30 min between PRO and Test Testson","description":"","additionalNotes":"","customInputs":{},"startTime":"[redacted/dynamic]","endTime":"[redacted/dynamic]","organizer":{"name":"PRO","email":"[redacted/dynamic]","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"},"attendees":[{"email":"test@example.com","name":"Test Testson","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"}],"location":"[redacted/dynamic]","destinationCalendar":null,"hideCalendarNotes":false,"requiresConfirmation":"[redacted/dynamic]","eventTypeId":"[redacted/dynamic]","uid":"[redacted/dynamic]","eventTitle":"30 min","eventDescription":null,"price":0,"currency":"usd","length":30,"bookingId":"[redacted/dynamic]","metadata":{},"status":"ACCEPTED","additionalInformation":"[redacted/dynamic]"}}
+1 -1
View File
@@ -12,7 +12,7 @@
},
"include": [
/* Find a way to not require this - App files don't belong here. */
"../../packages/app-store/routing_forms/env.d.ts",
"../../packages/app-store/routing-forms/env.d.ts",
"next-env.d.ts",
"../../packages/types/*.d.ts",
"../../packages/types/next-auth.d.ts",
+10 -3
View File
@@ -13,6 +13,10 @@ if (process.argv[2] === "--watch") {
const formatOutput = (source: string) => prettier.format(source, prettierConfig);
const getVariableName = function (appName) {
return appName.replace("-", "_");
};
const APP_STORE_PATH = path.join(__dirname, "..", "..", "app-store");
type App = {
name: string;
@@ -112,7 +116,7 @@ function generateFiles() {
...getObjectExporter("apiHandlers", {
fileToBeImported: "api/index.ts",
// Import path must have / even for windows and not \
entryBuilder: (app) => ` ${app.name}: import("./${app.path.replace(/\\/g, "/")}/api"),`,
entryBuilder: (app) => ` "${app.name}": import("./${app.path.replace(/\\/g, "/")}/api"),`,
})
);
@@ -121,8 +125,11 @@ function generateFiles() {
fileToBeImported: "_metadata.ts",
// Import path must have / even for windows and not \
importBuilder: (app) =>
`import { metadata as ${app.name}_meta } from "./${app.path.replace(/\\/g, "/")}/_metadata";`,
entryBuilder: (app) => ` ${app.name}:${app.name}_meta,`,
`import { metadata as ${getVariableName(app.name)}_meta } from "./${app.path.replace(
/\\/g,
"/"
)}/_metadata";`,
entryBuilder: (app) => ` "${app.name}":${getVariableName(app.name)}_meta,`,
})
);
@@ -10,7 +10,7 @@ import { metadata as caldavcalendar_meta } from "./caldavcalendar/_metadata";
import { metadata as campfire_meta } from "./campfire/_metadata";
import { metadata as closecomothercalendar_meta } from "./closecomothercalendar/_metadata";
import { metadata as dailyvideo_meta } from "./dailyvideo/_metadata";
import { metadata as routing_forms_meta } from "./ee/routing_forms/_metadata";
import { metadata as routing_forms_meta } from "./ee/routing-forms/_metadata";
import { metadata as exchange2013calendar_meta } from "./exchange2013calendar/_metadata";
import { metadata as exchange2016calendar_meta } from "./exchange2016calendar/_metadata";
import { metadata as exchangecalendar_meta } from "./exchangecalendar/_metadata";
@@ -45,7 +45,7 @@ export const appStoreMetadata = {
campfire: campfire_meta,
closecomothercalendar: closecomothercalendar_meta,
dailyvideo: dailyvideo_meta,
routing_forms: routing_forms_meta,
"routing-forms": routing_forms_meta,
exchange2013calendar: exchange2013calendar_meta,
exchange2016calendar: exchange2016calendar_meta,
exchangecalendar: exchangecalendar_meta,
+1 -1
View File
@@ -8,7 +8,7 @@ export const apiHandlers = {
caldavcalendar: import("./caldavcalendar/api"),
campfire: import("./campfire/api"),
closecomothercalendar: import("./closecomothercalendar/api"),
routing_forms: import("./ee/routing_forms/api"),
"routing-forms": import("./ee/routing-forms/api"),
exchange2013calendar: import("./exchange2013calendar/api"),
exchange2016calendar: import("./exchange2016calendar/api"),
exchangecalendar: import("./exchangecalendar/api"),
@@ -20,7 +20,7 @@ const handler: AppDeclarativeHandler = {
},
});
},
redirectUrl: "/apps/routing_forms/forms",
redirectUrl: "/apps/routing-forms/forms",
};
export default handler;
@@ -86,7 +86,7 @@ function NewFormDialog({ appUrl }: { appUrl: string }) {
{t("add_new_form")}
</h3>
<div>
<p className="text-sm text-gray-500">{t("forms_description")}</p>
<p className="text-sm text-gray-500">{t("form_description")}</p>
</div>
</div>
<Form
@@ -338,7 +338,7 @@ export const FormAction = forwardRef(function FormAction<T extends typeof Button
href: `${appUrl}/form-edit/${routingForm?.id}`,
},
download: {
href: `/api/integrations/routing_forms/responses/${routingForm?.id}`,
href: `/api/integrations/routing-forms/responses/${routingForm?.id}`,
},
_delete: {
onClick: () => _delete.onAction({ routingForm }),
@@ -7,7 +7,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui";
import { Form } from "@calcom/ui/form/fields";
import { showToast } from "@calcom/ui/v2";
import { showToast, DropdownMenuSeparator } from "@calcom/ui/v2";
import { ButtonGroup, TextAreaField, TextField, Tooltip, Button, VerticalDivider } from "@calcom/ui/v2";
import Meta from "@calcom/ui/v2/core/Meta";
import { ShellMain } from "@calcom/ui/v2/core/Shell";
@@ -82,6 +82,7 @@ const Actions = ({
StartIcon={Icon.FiCode}
tooltip={t("embed")}
/>
<DropdownMenuSeparator className="h-px bg-gray-200" />
<FormAction
routingForm={form}
action="_delete"
@@ -1,5 +1,5 @@
import { Settings, Widgets, SelectWidgetProps } from "react-awesome-query-builder";
// Figure out why ee/routing_forms/env.d.ts doesn't work
// Figure out why ee/routing-forms/env.d.ts doesn't work
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
import BasicConfig from "react-awesome-query-builder/lib/config/basic";
@@ -2,11 +2,11 @@
"/*": "Don't modify slug - If required, do it using cli edit command",
"name": "Routing Forms",
"title": "Routing Forms",
"slug": "routing_forms",
"type": "routing_forms_other",
"imageSrc": "/api/app-store/ee/routing_forms/icon.svg",
"logo": "/api/app-store/ee/routing_forms/icon.svg",
"url": "https://cal.com/apps/routing_forms",
"slug": "routing-forms",
"type": "routing-forms_other",
"imageSrc": "/api/app-store/ee/routing-forms/icon.svg",
"logo": "/api/app-store/ee/routing-forms/icon.svg",
"url": "https://cal.com/apps/routing-forms",
"variant": "other",
"categories": ["other"],
"publisher": "Cal.com",
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"name": "@calcom/routing_forms",
"name": "@calcom/routing-forms",
"version": "0.0.0",
"main": "./index.ts",
"description": "It would allow a booker to connect with the right person or choose the right event, faster. It would work by taking inputs from the booker and using that data to route to the correct booker/event as configured by Cal user ",
@@ -1,5 +1,6 @@
import autoAnimate from "@formkit/auto-animate";
import { App_RoutingForms_Form } from "@prisma/client";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Controller, useFieldArray } from "react-hook-form";
import { UseFormReturn } from "react-hook-form";
import { v4 as uuidv4 } from "uuid";
@@ -196,6 +197,12 @@ const FormEdit = ({
name: fieldsNamespace,
});
const animationRef = useRef(null);
useEffect(() => {
animationRef.current && autoAnimate(animationRef.current);
}, [animationRef]);
const addField = () => {
appendHookFormField({
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -214,7 +221,7 @@ const FormEdit = ({
return hookFormFields.length ? (
<div className="flex flex-col-reverse lg:flex-row">
<div className="w-full ltr:mr-2 rtl:ml-2">
<div className="flex w-full flex-col">
<div ref={animationRef} className="flex w-full flex-col">
{hookFormFields.map((field, key) => {
return (
<Field
@@ -1,5 +1,6 @@
import autoAnimate from "@formkit/auto-animate";
import { App_RoutingForms_Form } from "@prisma/client";
import React, { useState, useCallback } from "react";
import React, { useState, useCallback, useRef, useEffect } from "react";
import { Query, Config, Builder, Utils as QbUtils } from "react-awesome-query-builder";
// types
import { JsonTree, ImmutableTree, BuilderProps } from "react-awesome-query-builder";
@@ -325,6 +326,11 @@ const Routes = ({
return transformRoutes().map((route) => deserializeRoute(route, config));
});
const animationRef = useRef(null);
useEffect(() => {
animationRef.current && autoAnimate(animationRef.current);
}, [animationRef]);
const mainRoutes = routes.filter((route) => !route.isFallback);
let fallbackRoute = routes.find((route) => route.isFallback);
@@ -370,7 +376,7 @@ const Routes = ({
hookForm.setValue("routes", routesToSave);
return (
<div className="flex flex-col-reverse md:flex-row">
<div className="w-full ltr:mr-2 rtl:ml-2">
<div ref={animationRef} className="w-full ltr:mr-2 rtl:ml-2">
{mainRoutes.map((route, key) => {
return (
<Route
@@ -100,7 +100,7 @@ function RoutingForm({ form, profile }: inferSSRProps<typeof getServerSideProps>
{!customPageMessage ? (
<>
<Head>
<title>{form.name} | Cal.com Forms</title>
<title>{`${form.name} | Cal.com Forms`}</title>
</Head>
<div className={classNames("mx-auto my-0 max-w-3xl", isEmbed ? "" : "md:my-24")}>
<div className="w-full max-w-4xl ltr:mr-2 rtl:ml-2">
@@ -74,13 +74,13 @@ test.describe("Routing Forms", () => {
});
test.describe("Zero State Routing Forms", () => {
test.beforeEach(async ({ page, users }) => {
const user = await users.create({ username: "routing_forms" });
const user = await users.create({ username: "routing-forms" });
await user.login();
// Install app
await page.goto(`/apps/routing_forms`);
await page.goto(`/apps/routing-forms`);
await page.click('[data-testid="install-app-button"]');
await page.waitForNavigation({
url: (url) => url.pathname === `/apps/routing_forms/forms`,
url: (url) => url.pathname === `/apps/routing-forms/forms`,
});
});
@@ -89,9 +89,9 @@ test.describe("Routing Forms", () => {
const formId = await addForm(page);
await page.click('[href="/apps/routing_forms/forms"]');
await page.click('[href="/apps/routing-forms/forms"]');
// TODO: Workaround for bug in https://github.com/calcom/cal.com/issues/3410
await page.click('[href="/apps/routing_forms/forms"]');
await page.click('[href="/apps/routing-forms/forms"]');
await page.waitForSelector('[data-testid="routing-forms-list"]');
// Ensure that it's visible in forms list
@@ -100,7 +100,7 @@ test.describe("Routing Forms", () => {
await gotoRoutingLink(page, formId);
await page.isVisible("text=Test Form Name");
await page.goto(`apps/routing_forms/route-builder/${formId}`);
await page.goto(`apps/routing-forms/route-builder/${formId}`);
await page.click('[data-testid="toggle-form"] [value="on"]');
await gotoRoutingLink(page, formId);
await page.isVisible("text=ERROR 404");
@@ -129,7 +129,7 @@ test.describe("Routing Forms", () => {
types[field.typeIndex]
);
await page.click('[href*="/apps/routing_forms/route-builder/"]');
await page.click('[href*="/apps/routing-forms/route-builder/"]');
await page.click('[data-testid="add-route"]');
await page.click('[data-testid="add-rule"]');
await verifySelectOptions(
@@ -151,13 +151,13 @@ test.describe("Routing Forms", () => {
users: Fixtures["users"];
page: Page;
}) {
const user = await users.create({ username: "routing_forms" }, { seedRoutingForms: true });
const user = await users.create({ username: "routing-forms" }, { seedRoutingForms: true });
await user.login();
// Install app
await page.goto(`/apps/routing_forms`);
await page.goto(`/apps/routing-forms`);
await page.click('[data-testid="install-app-button"]');
await page.waitForNavigation({
url: (url) => url.pathname === `/apps/routing_forms/forms`,
url: (url) => url.pathname === `/apps/routing-forms/forms`,
});
return user;
};
@@ -197,7 +197,7 @@ test.describe("Routing Forms", () => {
// Log back in to view form responses.
await user.login();
await page.goto(`/apps/routing_forms/route-builder/${routingForm.id}`);
await page.goto(`/apps/routing-forms/route-builder/${routingForm.id}`);
const [download] = await Promise.all([
// Start waiting for the download
page.waitForEvent("download"),

Before

Width:  |  Height:  |  Size: 265 B

After

Width:  |  Height:  |  Size: 265 B

@@ -17,7 +17,7 @@ export default function HowToUse() {
<ol className="mt-5 mb-5 ml-5 mr-5 list-decimal">
<li>
Make sure that you have{" "}
<Link href="/apps/routing_forms" className="text-base text-gray-500">
<Link href="/apps/routing-forms" className="text-base text-gray-500">
<a className="text-base text-gray-500">Routing Forms</a>
</Link>{" "}
app installed
@@ -1,5 +1,5 @@
import { WebhookTriggerEvents } from "@prisma/client";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { classNames } from "@calcom/lib";
@@ -30,7 +30,7 @@ export type WebhookFormSubmitData = WebhookFormData & {
newSecret: string;
};
type WebhookTriggerEventOptions = { value: WebhookTriggerEvents; label: string }[];
type WebhookTriggerEventOptions = readonly { value: WebhookTriggerEvents; label: string }[];
const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2: Record<string, WebhookTriggerEventOptions> = {
core: [
@@ -39,8 +39,8 @@ const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2: Record<string, WebhookTriggerEve
{ value: WebhookTriggerEvents.BOOKING_RESCHEDULED, label: "booking_rescheduled" },
{ value: WebhookTriggerEvents.MEETING_ENDED, label: "meeting_ended" },
],
routing_forms: [{ value: WebhookTriggerEvents.FORM_SUBMITTED, label: "form_submitted" }],
};
"routing-forms": [{ value: WebhookTriggerEvents.FORM_SUBMITTED, label: "form_submitted" }],
} as const;
const WebhookForm = (props: {
webhook?: WebhookFormData;
@@ -49,23 +49,22 @@ const WebhookForm = (props: {
}) => {
const { apps = [] } = props;
const { t } = useLocale();
const triggerOptions = useMemo(
() =>
apps.reduce((acc, app) => {
if (!WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2[app]) return acc;
acc.push(...WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2[app]);
return acc;
}, WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2["core"]),
[apps]
);
const triggerOptions = [...WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2["core"]];
if (apps) {
for (const app of apps) {
triggerOptions.push(...WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2[app]);
}
}
const translatedTriggerOptions = triggerOptions.map((option) => ({ ...option, label: t(option.label) }));
const formMethods = useForm({
defaultValues: {
subscriberUrl: props?.webhook?.subscriberUrl || "",
active: props?.webhook?.active || false,
eventTriggers: props?.webhook?.eventTriggers || [],
subscriberUrl: props.webhook?.subscriberUrl || "",
active: props.webhook ? props.webhook.active : true,
eventTriggers: !props.webhook
? translatedTriggerOptions.map((option) => option.value)
: props.webhook.eventTriggers,
secret: props?.webhook?.secret || "",
payloadTemplate: props?.webhook?.payloadTemplate || undefined,
},
@@ -128,20 +127,24 @@ const WebhookForm = (props: {
<Controller
name="eventTriggers"
control={formMethods.control}
render={({ field: { onChange } }) => (
<div className="mt-8">
<Label className="font-sm mt-8 font-medium text-gray-900">
<>{t("event_triggers")}</>
</Label>
<Select
options={translatedTriggerOptions}
isMulti
onChange={(event) => {
onChange(event.map((selection) => selection.value));
}}
/>
</div>
)}
render={({ field: { onChange, value } }) => {
const selectValue = translatedTriggerOptions.filter((option) => value.includes(option.value));
return (
<div className="mt-8">
<Label className="font-sm mt-8 font-medium text-gray-900">
<>{t("event_triggers")}</>
</Label>
<Select
options={translatedTriggerOptions}
isMulti
value={selectValue}
onChange={(event) => {
onChange(event.map((selection) => selection.value));
}}
/>
</div>
);
}}
/>
<Controller
name="secret"
@@ -1,12 +1,12 @@
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui/Icon";
import Badge from "@calcom/ui/v2/core/Badge";
import Button from "@calcom/ui/v2/core/Button";
import Switch from "@calcom/ui/v2/core/Switch";
import { Tooltip } from "@calcom/ui/v2/core/Tooltip";
import showToast from "@calcom/ui/v2/core/notifications";
export type TWebhook = inferQueryOutput<"viewer.webhook.list">[number];
@@ -21,8 +21,7 @@ export default function WebhookListItem(props: {
const deleteWebhook = trpc.useMutation("viewer.webhook.delete", {
async onSuccess() {
await utils.invalidateQueries(["viewer.webhook.list"]);
// TODO: Better success message
showToast(t("deleted"), "success");
showToast(t("webhook_removed_successfully"), "success");
},
});
const toggleWebhook = trpc.useMutation("viewer.webhook.edit", {
@@ -38,14 +37,13 @@ export default function WebhookListItem(props: {
<div className={classNames("flex w-full justify-between p-4", props.lastItem ? "" : "border-b")}>
<div>
<p className="text-sm font-medium text-gray-900">{webhook.subscriberUrl}</p>
<Tooltip
content={webhook.eventTriggers.map((trigger) => (
<p key={trigger}>{t(`${trigger.toLowerCase()}`)}</p>
))}>
<div className="mt-2.5 w-max">
<Badge variant="gray" bold StartIcon={Icon.FiAlertCircle}>
{t("triggers_when")}
</Badge>
<Tooltip content={t("triggers_when")}>
<div className="mt-2.5 w-4/5">
{webhook.eventTriggers.map((trigger) => (
<Badge key={trigger} className="mr-2" variant="gray" bold StartIcon={Icon.FiAlertCircle}>
{t(`${trigger.toLowerCase()}`)}
</Badge>
))}
</div>
</Tooltip>
</div>
@@ -16,76 +16,90 @@ const EditWebhook = () => {
const { t } = useLocale();
const utils = trpc.useContext();
const router = useRouter();
const { id: webhookId } = querySchema.parse(router.query);
const { data: installedApps, isLoading } = trpc.useQuery(
["viewer.integrations", { variant: "other", onlyInstalled: true }],
{
function Component({ webhookId }: { webhookId: string }) {
const { data: installedApps, isLoading } = trpc.useQuery(
["viewer.integrations", { variant: "other", onlyInstalled: true }],
{
suspense: true,
enabled: router.isReady,
}
);
const { data: webhook } = trpc.useQuery(["viewer.webhook.get", { webhookId }], {
suspense: true,
enabled: router.isReady,
}
);
const { data: webhook } = trpc.useQuery(["viewer.webhook.get", { webhookId }], {
suspense: true,
enabled: router.isReady,
});
const { data: webhooks } = trpc.useQuery(["viewer.webhook.list"], {
suspense: true,
enabled: router.isReady,
});
const editWebhookMutation = trpc.useMutation("viewer.webhook.edit", {
async onSuccess() {
await utils.invalidateQueries(["viewer.webhook.list"]);
},
onError(error) {
showToast(`${error.message}`, "error");
},
});
});
const { data: webhooks } = trpc.useQuery(["viewer.webhook.list"], {
suspense: true,
enabled: router.isReady,
});
const editWebhookMutation = trpc.useMutation("viewer.webhook.edit", {
async onSuccess() {
await utils.invalidateQueries(["viewer.webhook.list"]);
showToast(t("webhook_updated_successfully"), "success");
router.back();
},
onError(error) {
showToast(`${error.message}`, "error");
},
});
const subscriberUrlReserved = (subscriberUrl: string, id: string): boolean => {
return !!webhooks?.find((webhook) => webhook.subscriberUrl === subscriberUrl && webhook.id !== id);
};
const subscriberUrlReserved = (subscriberUrl: string, id: string): boolean => {
return !!webhooks?.find((webhook) => webhook.subscriberUrl === subscriberUrl && webhook.id !== id);
};
if (isLoading || !webhook) return <SkeletonContainer />;
if (isLoading || !webhook) return <SkeletonContainer />;
return (
<>
<Meta
title="Edit Webhook"
description="Receive meeting data in real-time when something happens in Cal.com"
backButton
/>
<WebhookForm
webhook={webhook}
onSubmit={(values: WebhookFormSubmitData) => {
if (subscriberUrlReserved(values.subscriberUrl, webhook.id)) {
showToast(t("webhook_subscriber_url_reserved"), "error");
return;
}
return (
<>
<Meta
title="Edit Webhook"
description="Receive meeting data in real-time when something happens in Cal.com"
backButton
/>
<WebhookForm
webhook={webhook}
onSubmit={(values: WebhookFormSubmitData) => {
if (subscriberUrlReserved(values.subscriberUrl, webhook.id)) {
showToast(t("webhook_subscriber_url_reserved"), "error");
return;
}
if (values.changeSecret) {
values.secret = values.newSecret.length ? values.newSecret : null;
}
if (values.changeSecret) {
values.secret = values.newSecret.length ? values.newSecret : null;
}
if (!values.payloadTemplate) {
values.payloadTemplate = null;
}
if (!values.payloadTemplate) {
values.payloadTemplate = null;
}
editWebhookMutation.mutate({
id: webhook.id,
subscriberUrl: values.subscriberUrl,
eventTriggers: values.eventTriggers,
active: values.active,
payloadTemplate: values.payloadTemplate,
secret: values.secret,
});
showToast(t("webhook_updated_successfully"), "success");
router.back();
}}
apps={installedApps?.items.map((app) => app.slug)}
/>
</>
);
editWebhookMutation.mutate({
id: webhook.id,
subscriberUrl: values.subscriberUrl,
eventTriggers: values.eventTriggers,
active: values.active,
payloadTemplate: values.payloadTemplate,
secret: values.secret,
});
}}
apps={installedApps?.items.map((app) => app.slug)}
/>
</>
);
}
if (!router.isReady) return null;
const parsed = querySchema.safeParse(router.query);
if (!parsed.success) {
throw new Error("Invalid query");
}
// tRPC useQuery needs webhookId to be available and it becomes available only after router.isReady is true
// It causes this requirement of a new component.
// I think we should do SSR for this page
return <Component webhookId={parsed.data.id} />;
};
EditWebhook.getLayout = getLayout;
@@ -27,7 +27,9 @@ const NewWebhookView = () => {
const createWebhookMutation = trpc.useMutation("viewer.webhook.create", {
async onSuccess() {
showToast(t("webhook_created_successfully"), "success");
await utils.invalidateQueries(["viewer.webhook.list"]);
router.back();
},
onError(error) {
showToast(`${error.message}`, "error");
@@ -55,8 +57,6 @@ const NewWebhookView = () => {
payloadTemplate: values.payloadTemplate,
secret: values.secret,
});
showToast(t("webhook_created_successfully"), "success");
router.back();
};
if (isLoading) return <SkeletonContainer />;
@@ -5,7 +5,7 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui";
import { ButtonGroup, EmptyScreen, SkeletonText } from "@calcom/ui/v2";
import { EmptyScreen, SkeletonText } from "@calcom/ui/v2";
import { Button } from "@calcom/ui/v2/core/Button";
import Meta from "@calcom/ui/v2/core/Meta";
import { getLayout } from "@calcom/ui/v2/core/layouts/SettingsLayout";
@@ -31,7 +31,11 @@ const WebhooksView = () => {
const NewWebhookButton = () => {
const { t, isLocaleReady } = useLocale();
return (
<Button color="secondary" StartIcon={Icon.FiPlus} href={`${WEBAPP_URL}/settings/developer/webhooks/new`}>
<Button
color="secondary"
data-testid="new_webhook"
StartIcon={Icon.FiPlus}
href={`${WEBAPP_URL}/settings/developer/webhooks/new`}>
{isLocaleReady ? t("new_webhook") : <SkeletonText className="h-4 w-24" />}
</Button>
);
+2 -2
View File
@@ -9,10 +9,10 @@ export const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP = {
WebhookTriggerEvents.BOOKING_RESCHEDULED,
WebhookTriggerEvents.MEETING_ENDED,
] as const,
routing_forms: [WebhookTriggerEvents.FORM_SUBMITTED] as ["FORM_SUBMITTED"],
"routing-forms": [WebhookTriggerEvents.FORM_SUBMITTED] as const,
};
export const WEBHOOK_TRIGGER_EVENTS = [
...WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP.core,
...WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP.routing_forms,
...WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP["routing-forms"],
] as const;
@@ -0,0 +1,2 @@
UPDATE "App" SET "slug" = 'routing-forms' WHERE "slug" = 'routing_forms';
UPDATE "App" SET "dirName" = 'routing-forms' WHERE "slug" = 'routing_forms';
+3 -3
View File
@@ -1,10 +1,10 @@
[
{
"/*": "This file is auto-generated and updated by `yarn app-store create/edit`. Don't edit it manually",
"dirName": "routing_forms",
"dirName": "routing-forms",
"categories": ["other"],
"slug": "routing_forms",
"type": "routing_forms_other"
"slug": "routing-forms",
"type": "routing-forms_other"
},
{
"dirName": "whereby",
+1 -1
View File
@@ -3,7 +3,7 @@ import _ from "lodash";
import { authenticator } from "otplib";
import { z } from "zod";
import app_RoutingForms from "@calcom/app-store/ee/routing_forms/trpc-router";
import app_RoutingForms from "@calcom/app-store/ee/routing-forms/trpc-router";
import ethRouter from "@calcom/app-store/rainbow/trpc/router";
import { deleteStripeCustomer } from "@calcom/app-store/stripepayment/lib/customer";
import { getCustomerAndCheckoutSession } from "@calcom/app-store/stripepayment/lib/getCustomerAndCheckoutSession";
+5 -5
View File
@@ -427,7 +427,7 @@ const navigation: NavigationItemType[] = [
const path = router.asPath.split("?")[0];
// During Server rendering path is /v2/apps but on client it becomes /apps(weird..)
return (
(path.startsWith(item.href) || path.startsWith("/v2" + item.href)) && !path.includes("routing_forms/")
(path.startsWith(item.href) || path.startsWith("/v2" + item.href)) && !path.includes("routing-forms/")
);
},
child: [
@@ -439,7 +439,7 @@ const navigation: NavigationItemType[] = [
// During Server rendering path is /v2/apps but on client it becomes /apps(weird..)
return (
(path.startsWith(item.href) || path.startsWith("/v2" + item.href)) &&
!path.includes("routing_forms/") &&
!path.includes("routing-forms/") &&
!path.includes("/installed")
);
},
@@ -461,10 +461,10 @@ const navigation: NavigationItemType[] = [
},
{
name: "Routing Forms",
href: "/apps/routing_forms/forms",
href: "/apps/routing-forms/forms",
icon: Icon.FiFileText,
isCurrent: ({ router }) => {
return router.asPath.startsWith("/apps/routing_forms/");
return router.asPath.startsWith("/apps/routing-forms/");
},
},
{
@@ -511,7 +511,7 @@ const Navigation = () => {
function useShouldDisplayNavigationItem(item: NavigationItemType) {
const { status } = useSession();
const { data: routingForms } = trpc.useQuery(["viewer.appById", { appId: "routing_forms" }], {
const { data: routingForms } = trpc.useQuery(["viewer.appById", { appId: "routing-forms" }], {
enabled: status === "authenticated" && requiredCredentialNavigationItems.includes(item.name),
});
return !requiredCredentialNavigationItems.includes(item.name) || !!routingForms;
+5 -1
View File
@@ -187,7 +187,11 @@ const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function InputF
...passThrough
} = props;
const translatedPlaceholder = isLocaleReady ? placeholder : "";
const translatedPlaceholder = isLocaleReady
? !placeholder?.endsWith("_placeholder")
? placeholder
: ""
: "";
return (
<div className={classNames(containerClassName)}>