fix: `FORM_SUBMITTED` webhook payload change and support for Team Webhooks with it (#10986)
parent
8891641953
commit
41ef354c7b
|
@ -0,0 +1,61 @@
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
|
import { prisma } from "@calcom/prisma";
|
||||||
|
|
||||||
|
type Route = {
|
||||||
|
id: string;
|
||||||
|
action: {
|
||||||
|
type: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
isFallback: boolean;
|
||||||
|
queryValue: {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createRoutingFormsFixture = () => {
|
||||||
|
return {
|
||||||
|
async create({
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
name,
|
||||||
|
fields,
|
||||||
|
routes = [],
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
userId: number;
|
||||||
|
teamId: number | null;
|
||||||
|
routes?: Route[];
|
||||||
|
fields: {
|
||||||
|
type: string;
|
||||||
|
label: string;
|
||||||
|
identifier?: string;
|
||||||
|
required: boolean;
|
||||||
|
}[];
|
||||||
|
}) {
|
||||||
|
return await prisma.app_RoutingForms_Form.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
routes: [
|
||||||
|
...routes,
|
||||||
|
// Add a fallback route always, this is taken care of tRPC route normally but do it manually while running the query directly.
|
||||||
|
{
|
||||||
|
id: "898899aa-4567-489a-bcde-f1823f708646",
|
||||||
|
action: { type: "customPageMessage", value: "Fallback Message" },
|
||||||
|
isFallback: true,
|
||||||
|
queryValue: { id: "898899aa-4567-489a-bcde-f1823f708646", type: "group" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
fields: fields.map((f) => ({
|
||||||
|
id: uuidv4(),
|
||||||
|
...f,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
|
@ -10,6 +10,7 @@ import type { ExpectedUrlDetails } from "../../../../playwright.config";
|
||||||
import { createBookingsFixture } from "../fixtures/bookings";
|
import { createBookingsFixture } from "../fixtures/bookings";
|
||||||
import { createEmbedsFixture, createGetActionFiredDetails } from "../fixtures/embeds";
|
import { createEmbedsFixture, createGetActionFiredDetails } from "../fixtures/embeds";
|
||||||
import { createPaymentsFixture } from "../fixtures/payments";
|
import { createPaymentsFixture } from "../fixtures/payments";
|
||||||
|
import { createRoutingFormsFixture } from "../fixtures/routingForms";
|
||||||
import { createServersFixture } from "../fixtures/servers";
|
import { createServersFixture } from "../fixtures/servers";
|
||||||
import { createUsersFixture } from "../fixtures/users";
|
import { createUsersFixture } from "../fixtures/users";
|
||||||
|
|
||||||
|
@ -23,6 +24,7 @@ export interface Fixtures {
|
||||||
servers: ReturnType<typeof createServersFixture>;
|
servers: ReturnType<typeof createServersFixture>;
|
||||||
prisma: typeof prisma;
|
prisma: typeof prisma;
|
||||||
emails?: API;
|
emails?: API;
|
||||||
|
routingForms: ReturnType<typeof createRoutingFormsFixture>;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
@ -71,6 +73,9 @@ export const test = base.extend<Fixtures>({
|
||||||
prisma: async ({}, use) => {
|
prisma: async ({}, use) => {
|
||||||
await use(prisma);
|
await use(prisma);
|
||||||
},
|
},
|
||||||
|
routingForms: async ({}, use) => {
|
||||||
|
await use(createRoutingFormsFixture());
|
||||||
|
},
|
||||||
emails: async ({}, use) => {
|
emails: async ({}, use) => {
|
||||||
if (IS_MAILHOG_ENABLED) {
|
if (IS_MAILHOG_ENABLED) {
|
||||||
const mailhogAPI = mailhog();
|
const mailhogAPI = mailhog();
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { Page } from "@playwright/test";
|
||||||
import { expect } from "@playwright/test";
|
import { expect } from "@playwright/test";
|
||||||
|
|
||||||
import { test } from "./lib/fixtures";
|
import { test } from "./lib/fixtures";
|
||||||
|
@ -9,6 +10,9 @@ import {
|
||||||
gotoRoutingLink,
|
gotoRoutingLink,
|
||||||
} from "./lib/testUtils";
|
} from "./lib/testUtils";
|
||||||
|
|
||||||
|
// remove dynamic properties that differs depending on where you run the tests
|
||||||
|
const dynamic = "[redacted/dynamic]";
|
||||||
|
|
||||||
test.afterEach(({ users }) => users.deleteAll());
|
test.afterEach(({ users }) => users.deleteAll());
|
||||||
|
|
||||||
test.describe("BOOKING_CREATED", async () => {
|
test.describe("BOOKING_CREATED", async () => {
|
||||||
|
@ -55,8 +59,6 @@ test.describe("BOOKING_CREATED", async () => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const body: any = request.body;
|
const body: any = request.body;
|
||||||
|
|
||||||
// remove dynamic properties that differs depending on where you run the tests
|
|
||||||
const dynamic = "[redacted/dynamic]";
|
|
||||||
body.createdAt = dynamic;
|
body.createdAt = dynamic;
|
||||||
body.payload.startTime = dynamic;
|
body.payload.startTime = dynamic;
|
||||||
body.payload.endTime = dynamic;
|
body.payload.endTime = dynamic;
|
||||||
|
@ -187,8 +189,6 @@ test.describe("BOOKING_REJECTED", async () => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const body = request.body as any;
|
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.createdAt = dynamic;
|
||||||
body.payload.startTime = dynamic;
|
body.payload.startTime = dynamic;
|
||||||
body.payload.endTime = dynamic;
|
body.payload.endTime = dynamic;
|
||||||
|
@ -311,8 +311,6 @@ test.describe("BOOKING_REQUESTED", async () => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const body = request.body as any;
|
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.createdAt = dynamic;
|
||||||
body.payload.startTime = dynamic;
|
body.payload.startTime = dynamic;
|
||||||
body.payload.endTime = dynamic;
|
body.payload.endTime = dynamic;
|
||||||
|
@ -391,54 +389,136 @@ test.describe("BOOKING_REQUESTED", async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("FORM_SUBMITTED", async () => {
|
test.describe("FORM_SUBMITTED", async () => {
|
||||||
test("can submit a form and get a submission event", async ({ page, users }) => {
|
test("on submitting user form, triggers user webhook", async ({ page, users, routingForms }) => {
|
||||||
const webhookReceiver = createHttpServer();
|
const webhookReceiver = createHttpServer();
|
||||||
const user = await users.create();
|
const user = await users.create(null, {
|
||||||
|
hasTeam: true,
|
||||||
|
});
|
||||||
|
|
||||||
await user.apiLogin();
|
await user.apiLogin();
|
||||||
|
|
||||||
await page.goto("/settings/teams/new");
|
|
||||||
await page.waitForLoadState("networkidle");
|
|
||||||
const teamName = `${user.username}'s Team`;
|
|
||||||
// Create a new team
|
|
||||||
await page.locator('input[name="name"]').fill(teamName);
|
|
||||||
await page.locator('input[name="slug"]').fill(teamName);
|
|
||||||
await page.locator('button[type="submit"]').click();
|
|
||||||
|
|
||||||
await page.locator("text=Publish team").click();
|
|
||||||
await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i);
|
|
||||||
|
|
||||||
await page.waitForLoadState("networkidle");
|
|
||||||
|
|
||||||
await page.waitForLoadState("networkidle");
|
|
||||||
await page.goto(`/settings/developer/webhooks/new`);
|
await page.goto(`/settings/developer/webhooks/new`);
|
||||||
|
|
||||||
// Add webhook
|
// Add webhook
|
||||||
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
|
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
|
||||||
await page.fill('[name="secret"]', "secret");
|
await page.fill('[name="secret"]', "secret");
|
||||||
await Promise.all([page.click("[type=submit]"), page.goForward()]);
|
await page.click("[type=submit]");
|
||||||
|
|
||||||
// Page contains the url
|
// Page contains the url
|
||||||
expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined();
|
expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined();
|
||||||
|
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await page.goto("/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"]');
|
|
||||||
|
|
||||||
const url = page.url();
|
const form = await routingForms.create({
|
||||||
const formId = new URL(url).pathname.split("/").at(-1);
|
name: "Test Form",
|
||||||
|
userId: user.id,
|
||||||
|
teamId: null,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
label: "Name",
|
||||||
|
identifier: "name",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
await gotoRoutingLink({ page, formId: formId });
|
await gotoRoutingLink({ page, formId: form.id });
|
||||||
|
const fieldName = "name";
|
||||||
|
await page.fill(`[data-testid="form-field-${fieldName}"]`, "John Doe");
|
||||||
page.click('button[type="submit"]');
|
page.click('button[type="submit"]');
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(webhookReceiver.requestList.length).toBe(1);
|
expect(webhookReceiver.requestList.length).toBe(1);
|
||||||
});
|
});
|
||||||
|
const [request] = webhookReceiver.requestList;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const body = request.body as any;
|
||||||
|
body.createdAt = dynamic;
|
||||||
|
expect(body).toEqual({
|
||||||
|
triggerEvent: "FORM_SUBMITTED",
|
||||||
|
createdAt: dynamic,
|
||||||
|
payload: {
|
||||||
|
formId: form.id,
|
||||||
|
formName: form.name,
|
||||||
|
teamId: null,
|
||||||
|
responses: {
|
||||||
|
name: {
|
||||||
|
value: "John Doe",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
name: "John Doe",
|
||||||
|
});
|
||||||
|
|
||||||
|
webhookReceiver.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("on submitting team form, triggers team webhook", async ({ page, users, routingForms }) => {
|
||||||
|
const webhookReceiver = createHttpServer();
|
||||||
|
const user = await users.create(null, {
|
||||||
|
hasTeam: true,
|
||||||
|
});
|
||||||
|
await user.apiLogin();
|
||||||
|
|
||||||
|
await page.goto(`/settings/developer/webhooks`);
|
||||||
|
const teamId = await clickFirstTeamWebhookCta(page);
|
||||||
|
|
||||||
|
// Add webhook
|
||||||
|
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
|
||||||
|
await page.fill('[name="secret"]', "secret");
|
||||||
|
await page.click("[type=submit]");
|
||||||
|
|
||||||
|
const form = await routingForms.create({
|
||||||
|
name: "Test Form",
|
||||||
|
userId: user.id,
|
||||||
|
teamId: teamId,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
label: "Name",
|
||||||
|
identifier: "name",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await gotoRoutingLink({ page, formId: form.id });
|
||||||
|
const fieldName = "name";
|
||||||
|
await page.fill(`[data-testid="form-field-${fieldName}"]`, "John Doe");
|
||||||
|
page.click('button[type="submit"]');
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(webhookReceiver.requestList.length).toBe(1);
|
||||||
|
});
|
||||||
|
const [request] = webhookReceiver.requestList;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const body = request.body as any;
|
||||||
|
body.createdAt = dynamic;
|
||||||
|
expect(body).toEqual({
|
||||||
|
triggerEvent: "FORM_SUBMITTED",
|
||||||
|
createdAt: dynamic,
|
||||||
|
payload: {
|
||||||
|
formId: form.id,
|
||||||
|
formName: form.name,
|
||||||
|
teamId,
|
||||||
|
responses: {
|
||||||
|
name: {
|
||||||
|
value: "John Doe",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
name: "John Doe",
|
||||||
|
});
|
||||||
|
|
||||||
webhookReceiver.close();
|
webhookReceiver.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function clickFirstTeamWebhookCta(page: Page) {
|
||||||
|
await page.click('[data-testid="new_webhook"]');
|
||||||
|
await page.click('[data-testid="option-team-1"]');
|
||||||
|
await page.waitForURL((u) => u.pathname === "/settings/developer/webhooks/new");
|
||||||
|
const url = page.url();
|
||||||
|
const teamId = Number(new URL(url).searchParams.get("teamId")) as number;
|
||||||
|
return teamId;
|
||||||
|
}
|
||||||
|
|
|
@ -5,6 +5,13 @@ import type { Fixtures } from "@calcom/web/playwright/lib/fixtures";
|
||||||
import { test } from "@calcom/web/playwright/lib/fixtures";
|
import { test } from "@calcom/web/playwright/lib/fixtures";
|
||||||
import { gotoRoutingLink } from "@calcom/web/playwright/lib/testUtils";
|
import { gotoRoutingLink } from "@calcom/web/playwright/lib/testUtils";
|
||||||
|
|
||||||
|
import {
|
||||||
|
addForm,
|
||||||
|
saveCurrentForm,
|
||||||
|
verifySelectOptions,
|
||||||
|
addOneFieldAndDescriptionAndSaveForm,
|
||||||
|
} from "./testUtils";
|
||||||
|
|
||||||
function todo(title: string) {
|
function todo(title: string) {
|
||||||
// eslint-disable-next-line playwright/no-skipped-test, @typescript-eslint/no-empty-function
|
// eslint-disable-next-line playwright/no-skipped-test, @typescript-eslint/no-empty-function
|
||||||
test.skip(title, () => {});
|
test.skip(title, () => {});
|
||||||
|
@ -407,22 +414,6 @@ async function fillSeededForm(page: Page, routingFormId: string) {
|
||||||
await expect(page.locator("text=Custom Page Result")).toBeVisible();
|
await expect(page.locator("text=Custom Page Result")).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addForm(page: Page, { name = "Test Form Name" } = {}) {
|
|
||||||
await page.goto("/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"]');
|
|
||||||
const url = page.url();
|
|
||||||
const formId = new URL(url).pathname.split("/").at(-1);
|
|
||||||
if (!formId) {
|
|
||||||
throw new Error("Form ID couldn't be determined from url");
|
|
||||||
}
|
|
||||||
return formId;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addAllTypesOfFieldsAndSaveForm(
|
async function addAllTypesOfFieldsAndSaveForm(
|
||||||
formId: string,
|
formId: string,
|
||||||
page: Page,
|
page: Page,
|
||||||
|
@ -480,46 +471,6 @@ async function addAllTypesOfFieldsAndSaveForm(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addOneFieldAndDescriptionAndSaveForm(
|
|
||||||
formId: string,
|
|
||||||
page: Page,
|
|
||||||
form: { description?: string; field?: { typeIndex: number; label: string } }
|
|
||||||
) {
|
|
||||||
await page.goto(`apps/routing-forms/form-edit/${formId}`);
|
|
||||||
await page.click('[data-testid="add-field"]');
|
|
||||||
if (form.description) {
|
|
||||||
await page.fill('[data-testid="description"]', form.description);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify all Options of SelectBox
|
|
||||||
const { optionsInUi: types } = await verifySelectOptions(
|
|
||||||
{ selector: ".data-testid-field-type", nth: 0 },
|
|
||||||
["Email", "Long Text", "Multiple Selection", "Number", "Phone", "Single Selection", "Short Text"],
|
|
||||||
page
|
|
||||||
);
|
|
||||||
|
|
||||||
const nextFieldIndex = (await page.locator('[data-testid="field"]').count()) - 1;
|
|
||||||
|
|
||||||
if (form.field) {
|
|
||||||
await page.fill(`[data-testid="fields.${nextFieldIndex}.label"]`, form.field.label);
|
|
||||||
await page
|
|
||||||
.locator('[data-testid="field"]')
|
|
||||||
.nth(nextFieldIndex)
|
|
||||||
.locator(".data-testid-field-type")
|
|
||||||
.click();
|
|
||||||
await page
|
|
||||||
.locator('[data-testid="field"]')
|
|
||||||
.nth(nextFieldIndex)
|
|
||||||
.locator('[id*="react-select-"][aria-disabled]')
|
|
||||||
.nth(form.field.typeIndex)
|
|
||||||
.click();
|
|
||||||
}
|
|
||||||
await saveCurrentForm(page);
|
|
||||||
return {
|
|
||||||
types,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectOption({
|
async function selectOption({
|
||||||
page,
|
page,
|
||||||
selector,
|
selector,
|
||||||
|
@ -551,26 +502,6 @@ async function verifyFieldOptionsInRule(options: string[], page: Page) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function verifySelectOptions(
|
|
||||||
selector: { selector: string; nth: number },
|
|
||||||
expectedOptions: string[],
|
|
||||||
page: Page
|
|
||||||
) {
|
|
||||||
await page.locator(selector.selector).nth(selector.nth).click();
|
|
||||||
const selectOptions = await page
|
|
||||||
.locator(selector.selector)
|
|
||||||
.nth(selector.nth)
|
|
||||||
.locator('[id*="react-select-"][aria-disabled]')
|
|
||||||
.allInnerTexts();
|
|
||||||
|
|
||||||
const sortedSelectOptions = [...selectOptions].sort();
|
|
||||||
const sortedExpectedOptions = [...expectedOptions].sort();
|
|
||||||
expect(sortedSelectOptions).toEqual(sortedExpectedOptions);
|
|
||||||
return {
|
|
||||||
optionsInUi: selectOptions,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectNewRoute(page: Page, { routeSelectNumber = 1 } = {}) {
|
async function selectNewRoute(page: Page, { routeSelectNumber = 1 } = {}) {
|
||||||
await selectOption({
|
await selectOption({
|
||||||
selector: {
|
selector: {
|
||||||
|
@ -581,8 +512,3 @@ async function selectNewRoute(page: Page, { routeSelectNumber = 1 } = {}) {
|
||||||
page,
|
page,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveCurrentForm(page: Page) {
|
|
||||||
await page.click('[data-testid="update-form"]');
|
|
||||||
await page.waitForSelector(".data-testid-toast-success");
|
|
||||||
}
|
|
||||||
|
|
|
@ -0,0 +1,83 @@
|
||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
import { expect } from "@playwright/test";
|
||||||
|
|
||||||
|
export async function addForm(page: Page, { name = "Test Form Name" } = {}) {
|
||||||
|
await page.goto("/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"]');
|
||||||
|
const url = page.url();
|
||||||
|
const formId = new URL(url).pathname.split("/").at(-1);
|
||||||
|
if (!formId) {
|
||||||
|
throw new Error("Form ID couldn't be determined from url");
|
||||||
|
}
|
||||||
|
return formId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addOneFieldAndDescriptionAndSaveForm(
|
||||||
|
formId: string,
|
||||||
|
page: Page,
|
||||||
|
form: { description?: string; field?: { typeIndex: number; label: string } }
|
||||||
|
) {
|
||||||
|
await page.goto(`apps/routing-forms/form-edit/${formId}`);
|
||||||
|
await page.click('[data-testid="add-field"]');
|
||||||
|
if (form.description) {
|
||||||
|
await page.fill('[data-testid="description"]', form.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all Options of SelectBox
|
||||||
|
const { optionsInUi: types } = await verifySelectOptions(
|
||||||
|
{ selector: ".data-testid-field-type", nth: 0 },
|
||||||
|
["Email", "Long Text", "Multiple Selection", "Number", "Phone", "Single Selection", "Short Text"],
|
||||||
|
page
|
||||||
|
);
|
||||||
|
|
||||||
|
const nextFieldIndex = (await page.locator('[data-testid="field"]').count()) - 1;
|
||||||
|
|
||||||
|
if (form.field) {
|
||||||
|
await page.fill(`[data-testid="fields.${nextFieldIndex}.label"]`, form.field.label);
|
||||||
|
await page
|
||||||
|
.locator('[data-testid="field"]')
|
||||||
|
.nth(nextFieldIndex)
|
||||||
|
.locator(".data-testid-field-type")
|
||||||
|
.click();
|
||||||
|
await page
|
||||||
|
.locator('[data-testid="field"]')
|
||||||
|
.nth(nextFieldIndex)
|
||||||
|
.locator('[id*="react-select-"][aria-disabled]')
|
||||||
|
.nth(form.field.typeIndex)
|
||||||
|
.click();
|
||||||
|
}
|
||||||
|
await saveCurrentForm(page);
|
||||||
|
return {
|
||||||
|
types,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveCurrentForm(page: Page) {
|
||||||
|
await page.click('[data-testid="update-form"]');
|
||||||
|
await page.waitForSelector(".data-testid-toast-success");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifySelectOptions(
|
||||||
|
selector: { selector: string; nth: number },
|
||||||
|
expectedOptions: string[],
|
||||||
|
page: Page
|
||||||
|
) {
|
||||||
|
await page.locator(selector.selector).nth(selector.nth).click();
|
||||||
|
const selectOptions = await page
|
||||||
|
.locator(selector.selector)
|
||||||
|
.nth(selector.nth)
|
||||||
|
.locator('[id*="react-select-"][aria-disabled]')
|
||||||
|
.allInnerTexts();
|
||||||
|
|
||||||
|
const sortedSelectOptions = [...selectOptions].sort();
|
||||||
|
const sortedExpectedOptions = [...expectedOptions].sort();
|
||||||
|
expect(sortedSelectOptions).toEqual(sortedExpectedOptions);
|
||||||
|
return {
|
||||||
|
optionsInUi: selectOptions,
|
||||||
|
};
|
||||||
|
}
|
|
@ -12,32 +12,50 @@ export async function onFormSubmission(
|
||||||
form: Ensure<SerializableForm<App_RoutingForms_Form> & { user: User }, "fields">,
|
form: Ensure<SerializableForm<App_RoutingForms_Form> & { user: User }, "fields">,
|
||||||
response: Response
|
response: Response
|
||||||
) {
|
) {
|
||||||
const fieldResponsesByName: Record<string, (typeof response)[keyof typeof response]["value"]> = {};
|
const fieldResponsesByName: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
value: Response[keyof Response]["value"];
|
||||||
|
}
|
||||||
|
> = {};
|
||||||
|
|
||||||
for (const [fieldId, fieldResponse] of Object.entries(response)) {
|
for (const [fieldId, fieldResponse] of Object.entries(response)) {
|
||||||
// Use the label lowercased as the key to identify a field.
|
// Use the label lowercased as the key to identify a field.
|
||||||
const key =
|
const key =
|
||||||
form.fields.find((f) => f.id === fieldId)?.identifier ||
|
form.fields.find((f) => f.id === fieldId)?.identifier ||
|
||||||
(fieldResponse.label as keyof typeof fieldResponsesByName);
|
(fieldResponse.label as keyof typeof fieldResponsesByName);
|
||||||
fieldResponsesByName[key] = fieldResponse.value;
|
fieldResponsesByName[key] = {
|
||||||
|
value: fieldResponse.value,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscriberOptions = {
|
const subscriberOptions = {
|
||||||
userId: form.user.id,
|
|
||||||
triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED,
|
triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED,
|
||||||
// When team routing forms are implemented, we need to make sure to add the teamId here
|
...getWebhookTargetEntity(form),
|
||||||
teamId: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const webhooks = await getWebhooks(subscriberOptions);
|
const webhooks = await getWebhooks(subscriberOptions);
|
||||||
|
|
||||||
const promises = webhooks.map((webhook) => {
|
const promises = webhooks.map((webhook) => {
|
||||||
sendGenericWebhookPayload(
|
sendGenericWebhookPayload({
|
||||||
webhook.secret,
|
secretKey: webhook.secret,
|
||||||
"FORM_SUBMITTED",
|
triggerEvent: "FORM_SUBMITTED",
|
||||||
new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
webhook,
|
webhook,
|
||||||
fieldResponsesByName
|
data: {
|
||||||
).catch((e) => {
|
formId: form.id,
|
||||||
|
formName: form.name,
|
||||||
|
teamId: form.teamId,
|
||||||
|
responses: fieldResponsesByName,
|
||||||
|
},
|
||||||
|
rootData: {
|
||||||
|
// Send responses unwrapped at root level for backwards compatibility
|
||||||
|
...Object.entries(fieldResponsesByName).reduce((acc, [key, value]) => {
|
||||||
|
acc[key] = value.value;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, Response[keyof Response]["value"]>),
|
||||||
|
},
|
||||||
|
}).catch((e) => {
|
||||||
console.error(`Error executing routing form webhook`, webhook, e);
|
console.error(`Error executing routing form webhook`, webhook, e);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -66,3 +84,10 @@ export const sendResponseEmail = async (
|
||||||
logger.error("Error sending response email", e);
|
logger.error("Error sending response email", e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getWebhookTargetEntity(form: { teamId?: number | null; user: { id: number } }) {
|
||||||
|
// If it's a team form, the target must be team webhook
|
||||||
|
// If it's a user form, the target must be user webhook
|
||||||
|
const isTeamForm = form.teamId;
|
||||||
|
return { userId: isTeamForm ? null : form.user.id, teamId: isTeamForm ? form.teamId : null };
|
||||||
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { expect } from "@playwright/test";
|
||||||
import {
|
import {
|
||||||
addForm as addRoutingForm,
|
addForm as addRoutingForm,
|
||||||
addOneFieldAndDescriptionAndSaveForm,
|
addOneFieldAndDescriptionAndSaveForm,
|
||||||
} from "@calcom/app-store/routing-forms/playwright/tests/basic.e2e";
|
} from "@calcom/app-store/routing-forms/playwright/tests/testUtils";
|
||||||
import { CAL_URL } from "@calcom/lib/constants";
|
import { CAL_URL } from "@calcom/lib/constants";
|
||||||
import type { Fixtures } from "@calcom/web/playwright/lib/fixtures";
|
import type { Fixtures } from "@calcom/web/playwright/lib/fixtures";
|
||||||
import { test } from "@calcom/web/playwright/lib/fixtures";
|
import { test } from "@calcom/web/playwright/lib/fixtures";
|
||||||
|
|
|
@ -122,24 +122,37 @@ const sendPayload = async (
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return _sendPayload(secretKey, triggerEvent, createdAt, webhook, body, contentType);
|
return _sendPayload(secretKey, webhook, body, contentType);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sendGenericWebhookPayload = async (
|
export const sendGenericWebhookPayload = async ({
|
||||||
secretKey: string | null,
|
secretKey,
|
||||||
triggerEvent: string,
|
triggerEvent,
|
||||||
createdAt: string,
|
createdAt,
|
||||||
webhook: Pick<Webhook, "subscriberUrl" | "appId" | "payloadTemplate">,
|
webhook,
|
||||||
data: Record<string, unknown>
|
data,
|
||||||
) => {
|
rootData,
|
||||||
const body = JSON.stringify(data);
|
}: {
|
||||||
return _sendPayload(secretKey, triggerEvent, createdAt, webhook, body, "application/json");
|
secretKey: string | null;
|
||||||
|
triggerEvent: string;
|
||||||
|
createdAt: string;
|
||||||
|
webhook: Pick<Webhook, "subscriberUrl" | "appId" | "payloadTemplate">;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
rootData?: Record<string, unknown>;
|
||||||
|
}) => {
|
||||||
|
const body = JSON.stringify({
|
||||||
|
// Added rootData props first so that using the known(i.e. triggerEvent, createdAt, payload) properties in rootData doesn't override the known properties
|
||||||
|
...rootData,
|
||||||
|
triggerEvent: triggerEvent,
|
||||||
|
createdAt: createdAt,
|
||||||
|
payload: data,
|
||||||
|
});
|
||||||
|
|
||||||
|
return _sendPayload(secretKey, webhook, body, "application/json");
|
||||||
};
|
};
|
||||||
|
|
||||||
const _sendPayload = async (
|
const _sendPayload = async (
|
||||||
secretKey: string | null,
|
secretKey: string | null,
|
||||||
triggerEvent: string,
|
|
||||||
createdAt: string,
|
|
||||||
webhook: Pick<Webhook, "subscriberUrl" | "appId" | "payloadTemplate">,
|
webhook: Pick<Webhook, "subscriberUrl" | "appId" | "payloadTemplate">,
|
||||||
body: string,
|
body: string,
|
||||||
contentType: "application/json" | "application/x-www-form-urlencoded"
|
contentType: "application/json" | "application/x-www-form-urlencoded"
|
||||||
|
|
|
@ -63,8 +63,8 @@ function Component({ webhookId }: { webhookId: string }) {
|
||||||
backButton
|
backButton
|
||||||
/>
|
/>
|
||||||
<WebhookForm
|
<WebhookForm
|
||||||
|
noRoutingFormTriggers={false}
|
||||||
webhook={webhook}
|
webhook={webhook}
|
||||||
noRoutingFormTriggers={!!webhook.teamId}
|
|
||||||
onSubmit={(values: WebhookFormSubmitData) => {
|
onSubmit={(values: WebhookFormSubmitData) => {
|
||||||
if (
|
if (
|
||||||
subscriberUrlReserved({
|
subscriberUrlReserved({
|
||||||
|
|
|
@ -81,9 +81,9 @@ const NewWebhookView = () => {
|
||||||
backButton
|
backButton
|
||||||
/>
|
/>
|
||||||
<WebhookForm
|
<WebhookForm
|
||||||
|
noRoutingFormTriggers={false}
|
||||||
onSubmit={onCreateWebhook}
|
onSubmit={onCreateWebhook}
|
||||||
apps={installedApps?.items.map((app) => app.slug)}
|
apps={installedApps?.items.map((app) => app.slug)}
|
||||||
noRoutingFormTriggers={!!teamId}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
Loading…
Reference in New Issue