Merge branch 'main' into feat/organizations

This commit is contained in:
Leo Giovanetti 2023-06-13 14:35:09 -03:00
commit 4eec43dbb5
16 changed files with 196 additions and 50 deletions

View File

@ -57,12 +57,15 @@ const LocationInput = (props: {
<Input {...locationFormMethods.register(eventLocationType.variable)} type="text" {...remainingProps} />
);
} else if (eventLocationType?.organizerInputType === "phone") {
const { defaultValue, ...rest } = remainingProps;
return (
<Controller
name={eventLocationType.variable}
control={control}
defaultValue={defaultValue}
render={({ field: { onChange, value } }) => {
return <PhoneInput onChange={onChange} value={value} {...remainingProps} />;
return <PhoneInput onChange={onChange} value={value} {...rest} />;
}}
/>
);
@ -88,6 +91,9 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
useEffect(() => {
if (selection) {
locationFormMethods.setValue("locationType", selection?.value);
if (selection?.address) {
locationFormMethods.setValue("locationAddress", selection?.address);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selection]);
@ -149,12 +155,25 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
name: "locationType",
});
const selectedAddrValue = useWatch({
control: locationFormMethods.control,
name: "locationAddress",
});
const eventLocationType = getEventLocationType(selectedLocation);
const defaultLocation = defaultValues?.find(
(location: { type: EventLocationType["type"] }) => location.type === eventLocationType?.type
(location: { type: EventLocationType["type"]; address?: string }) => {
if (location.type === LocationType.InPerson) {
return location.type === eventLocationType?.type && location.address === selectedAddrValue;
} else {
return location.type === eventLocationType?.type;
}
}
);
console.log(defaultLocation);
const LocationOptions = (() => {
if (eventLocationType && eventLocationType.organizerInputType && LocationInput) {
if (!eventLocationType.variable) {

View File

@ -136,9 +136,17 @@ export const EventSetupTab = (
selectedMultipleDuration.find((opt) => opt.value === eventType.length) ?? null
);
const openLocationModal = (type: EventLocationType["type"]) => {
const openLocationModal = (type: EventLocationType["type"], address = "") => {
const option = getLocationFromType(type, locationOptions);
setSelectedLocation(option);
if (option && option.value === LocationType.InPerson) {
const inPersonOption = {
...option,
address,
};
setSelectedLocation(inPersonOption);
} else {
setSelectedLocation(option);
}
setShowLocationModal(true);
};
@ -298,10 +306,14 @@ export const EventSetupTab = (
onClick={() => {
locationFormMethods.setValue("locationType", location.type);
locationFormMethods.unregister("locationLink");
locationFormMethods.unregister("locationAddress");
if (location.type === LocationType.InPerson) {
locationFormMethods.setValue("locationAddress", location.address);
} else {
locationFormMethods.unregister("locationAddress");
}
locationFormMethods.unregister("locationPhoneNumber");
setEditingLocationType(location.type);
openLocationModal(location.type);
openLocationModal(location.type, location.address);
}}
aria-label={t("edit")}
className="hover:text-emphasis text-subtle mr-1 p-1">
@ -517,7 +529,18 @@ export const EventSetupTab = (
defaultValues={formMethods.getValues("locations")}
selection={
selectedLocation
? { value: selectedLocation.value, label: t(selectedLocation.label), icon: selectedLocation.icon }
? selectedLocation.address
? {
value: selectedLocation.value,
label: t(selectedLocation.label),
icon: selectedLocation.icon,
address: selectedLocation.address,
}
: {
value: selectedLocation.value,
label: t(selectedLocation.label),
icon: selectedLocation.icon,
}
: undefined
}
setSelectedLocation={setSelectedLocation}

View File

@ -11,6 +11,7 @@ export type LocationOption = {
value: EventLocationType["type"];
icon?: string;
disabled?: boolean;
address?: string;
};
export type SingleValueLocationOption = SingleValue<LocationOption>;

View File

@ -297,6 +297,11 @@ const nextConfig = {
destination: "/teams",
permanent: true,
},
{
source: "/settings/admin",
destination: "/settings/admin/flags",
permanent: true,
},
/* V2 testers get redirected to the new settings */
{
source: "/settings/profile",

View File

@ -170,3 +170,30 @@ export const createNewSeatedEventType = async (page: Page, args: { eventTitle: s
await page.locator('[data-testid="offer-seats-toggle"]').click();
await page.locator('[data-testid="update-eventtype"]').click();
};
export async function gotoRoutingLink({
page,
formId,
queryString = "",
}: {
page: Page;
formId?: string;
queryString?: string;
}) {
let previewLink = null;
if (!formId) {
// Instead of clicking on the preview link, we are going to the preview link directly because the earlier opens a new tab which is a bit difficult to manage with Playwright
const href = await page.locator('[data-testid="form-action-preview"]').getAttribute("href");
if (!href) {
throw new Error("Preview link not found");
}
previewLink = href;
} else {
previewLink = `/forms/${formId}`;
}
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));
}

View File

@ -6,6 +6,7 @@ import {
createHttpServer,
selectFirstAvailableTimeSlotNextMonth,
waitFor,
gotoRoutingLink,
} from "./lib/testUtils";
test.afterEach(({ users }) => users.deleteAll());
@ -387,3 +388,60 @@ test.describe("BOOKING_REQUESTED", async () => {
webhookReceiver.close();
});
});
test.describe("FORM_SUBMITTED", async () => {
test("can submit a form and get a submission event", async ({ page, users }) => {
const webhookReceiver = createHttpServer();
const user = await users.create();
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");
// Install Routing Forms App
await page.goto(`/apps/routing-forms`);
// eslint-disable-next-line playwright/no-conditional-in-test
await page.click('[data-testid="install-app-button"]');
await page.waitForLoadState("networkidle");
await page.goto(`/settings/developer/webhooks/new`);
// Add webhook
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
await page.fill('[name="secret"]', "secret");
await Promise.all([page.click("[type=submit]"), page.goForward()]);
// Page contains the url
expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined();
await page.waitForLoadState("networkidle");
await page.goto("/apps/routing-forms/forms");
await page.click('[data-testid="new-routing-form"]');
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 formId = new URL(url).pathname.split("/").at(-1);
await gotoRoutingLink({ page, formId: formId });
page.click('button[type="submit"]');
await waitFor(() => {
expect(webhookReceiver.requestList.length).toBe(1);
});
webhookReceiver.close();
});
});

View File

@ -148,6 +148,7 @@ export const defaultLocations: DefaultEventLocationType[] = [
export type LocationObject = {
type: string;
address?: string;
displayLocationPublicly?: boolean;
} & Partial<
Record<"address" | "attendeeAddress" | "link" | "hostPhoneNumber" | "hostDefault" | "phone", string>

View File

@ -96,7 +96,7 @@ export default function RoutingForms({
return (
<ShellMain
heading="Routing Forms"
CTA={hasPaidPlan && <NewFormButton />}
CTA={hasPaidPlan && forms?.length && <NewFormButton />}
subtitle={t("routing_forms_description")}>
<UpgradeTip
title={t("teams_plan_required")}

View File

@ -3,6 +3,7 @@ import { expect } from "@playwright/test";
import type { Fixtures } from "@calcom/web/playwright/lib/fixtures";
import { test } from "@calcom/web/playwright/lib/fixtures";
import { gotoRoutingLink } from "@calcom/web/playwright/lib/testUtils";
function todo(title: string) {
// eslint-disable-next-line playwright/no-skipped-test, @typescript-eslint/no-empty-function
@ -595,33 +596,6 @@ async function selectNewRoute(page: Page, { routeSelectNumber = 1 } = {}) {
});
}
async function gotoRoutingLink({
page,
formId,
queryString = "",
}: {
page: Page;
formId?: string;
queryString?: string;
}) {
let previewLink = null;
if (!formId) {
// Instead of clicking on the preview link, we are going to the preview link directly because the earlier opens a new tab which is a bit difficult to manage with Playwright
const href = await page.locator('[data-testid="form-action-preview"]').getAttribute("href");
if (!href) {
throw new Error("Preview link not found");
}
previewLink = href;
} else {
previewLink = `/forms/${formId}`;
}
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));
}
async function saveCurrentForm(page: Page) {
await page.click('[data-testid="update-form"]');
await page.waitForSelector(".data-testid-toast-success");

View File

@ -37,8 +37,8 @@ export const useEvent = () => {
export const useScheduleForEvent = ({ prefetchNextMonth }: { prefetchNextMonth?: boolean } = {}) => {
const { timezone } = useTimePreferences();
const event = useEvent();
const [username, eventSlug, month] = useBookerStore(
(state) => [state.username, state.eventSlug, state.month],
const [username, eventSlug, month, duration] = useBookerStore(
(state) => [state.username, state.eventSlug, state.month, state.selectedDuration],
shallow
);
@ -49,5 +49,6 @@ export const useScheduleForEvent = ({ prefetchNextMonth }: { prefetchNextMonth?:
month,
timezone,
prefetchNextMonth,
duration,
});
};

View File

@ -1,6 +1,7 @@
import { shallow } from "zustand/shallow";
import dayjs from "@calcom/dayjs";
import { useTimePreferences } from "@calcom/features/bookings/lib";
import { classNames } from "@calcom/lib";
import { useCalendarStore } from "../../state/store";
@ -13,6 +14,7 @@ type EmptyCellProps = GridCellToDateProps & {
};
export function EmptyCell(props: EmptyCellProps) {
const timeFormat = useTimePreferences((state) => state.timeFormat);
const { onEmptyCellClick, hoverEventDuration } = useCalendarStore(
(state) => ({
onEmptyCellClick: state.onEmptyCellClick,
@ -40,7 +42,7 @@ export function EmptyCell(props: EmptyCellProps) {
return (
<div
className={classNames(
"group w-full",
"group flex w-full items-center justify-center",
isDisabled && "pointer-events-none",
!isDisabled && "bg-default dark:bg-muted"
)}
@ -54,16 +56,13 @@ export function EmptyCell(props: EmptyCellProps) {
rounded-[4px]
border-[1px] border-gray-900 py-1 px-[6px] text-xs font-semibold leading-5 group-hover:block group-hover:cursor-pointer"
style={{
height: `calc(${hoverEventDuration}*var(--one-minute-height))`,
height: `calc(${hoverEventDuration}*var(--one-minute-height) - 2px)`,
zIndex: 49,
// @TODO: This used to be 90% as per Sean's work. I think this was needed when
// multiple events are stacked next to each other. We might need to add this back later.
width: "100%",
width: "calc(100% - 2px)",
}}>
<div className=" overflow-ellipsis leading-4">
{cellToDate.format("HH:mm")}
<span className="ml-2 inline">Click to select</span>
</div>
<div className=" overflow-ellipsis leading-4">{cellToDate.format(timeFormat)}</div>
</div>
)}
</div>

View File

@ -8,6 +8,7 @@ type UseScheduleWithCacheArgs = {
month?: string | null;
timezone?: string | null;
prefetchNextMonth?: boolean;
duration?: number | null;
};
export const useSchedule = ({
@ -17,6 +18,7 @@ export const useSchedule = ({
eventSlug,
eventId,
prefetchNextMonth,
duration,
}: UseScheduleWithCacheArgs) => {
const monthDayjs = month ? dayjs(month) : dayjs();
const nextMonthDayjs = monthDayjs.add(1, "month");
@ -35,6 +37,7 @@ export const useSchedule = ({
endTime: (prefetchNextMonth ? nextMonthDayjs : monthDayjs).endOf("month").toISOString(),
timeZone: timezone!,
eventTypeId: eventId!,
duration: duration ? `${duration}` : undefined,
},
{
refetchOnWindowFocus: false,

View File

@ -7,6 +7,7 @@ export const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP = {
WebhookTriggerEvents.BOOKING_CANCELLED,
WebhookTriggerEvents.BOOKING_CREATED,
WebhookTriggerEvents.BOOKING_RESCHEDULED,
WebhookTriggerEvents.BOOKING_PAID,
WebhookTriggerEvents.MEETING_ENDED,
WebhookTriggerEvents.BOOKING_REQUESTED,
WebhookTriggerEvents.BOOKING_REJECTED,

View File

@ -0,0 +1,9 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'BOOKING_PAID';

View File

@ -512,6 +512,7 @@ enum PaymentOption {
enum WebhookTriggerEvents {
BOOKING_CREATED
BOOKING_PAID
BOOKING_RESCHEDULED
BOOKING_REQUESTED
BOOKING_CANCELLED

View File

@ -3,7 +3,9 @@ import { z } from "zod";
import appStore from "@calcom/app-store";
import dayjs from "@calcom/dayjs";
import { sendNoShowFeeChargedEmail } from "@calcom/emails";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import { getTranslation } from "@calcom/lib/server/i18n";
import sendPayload from "@calcom/lib/server/webhooks/sendPayload";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { TRPCError } from "@trpc/server";
@ -33,11 +35,13 @@ export const paymentsRouter = router({
},
});
const payment = booking.payment[0];
if (!booking) {
throw new Error("Booking not found");
}
if (booking.payment[0].success) {
if (payment.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `The no show fee for ${booking.id} has already been charged.`,
@ -77,16 +81,16 @@ export const paymentsRouter = router({
},
attendees: attendeesList,
paymentInfo: {
amount: booking.payment[0].amount,
currency: booking.payment[0].currency,
paymentOption: booking.payment[0].paymentOption,
amount: payment.amount,
currency: payment.currency,
paymentOption: payment.paymentOption,
},
};
const paymentCredential = await prisma.credential.findFirst({
where: {
userId: ctx.user.id,
appId: booking.payment[0].appId,
appId: payment.appId,
},
include: {
app: true,
@ -107,12 +111,32 @@ export const paymentsRouter = router({
const paymentInstance = new PaymentService(paymentCredential);
try {
const paymentData = await paymentInstance.chargeCard(booking.payment[0]);
const paymentData = await paymentInstance.chargeCard(payment);
if (!paymentData) {
throw new TRPCError({ code: "NOT_FOUND", message: `Could not generate payment data` });
}
const subscriberOptions = {
userId: ctx.user.id || 0,
eventTypeId: booking.eventTypeId || 0,
triggerEvent: WebhookTriggerEvents.BOOKING_PAID,
};
const subscribers = await getWebhooks(subscriberOptions);
await Promise.all(
subscribers.map(async (subscriber) => {
sendPayload(subscriber.secret, WebhookTriggerEvents.BOOKING_PAID, {
...evt,
bookingId: booking.id,
paymentId: payment.id,
paymentData,
eventTypeId: subscriberOptions.eventTypeId,
});
})
);
await sendNoShowFeeChargedEmail(attendeesListPromises[0], evt);
return paymentData;