Routing Forms (#2785)

* Add Routing logic to Query builder

* Make a working redirect

* Make it an app

* Move pages and components to App

* Integrate all pages in the app

* Integrate prisma everywhere

* Fix Routing Link

* Add routing preview

* Fixes

* Get deplouyed on preview with ts disabled

* Fix case

* add reordering for routes

* Move away from react DnD

* Add sidebar

* Add sidebar support and select support

* Various fixes and improvements

* Ignore eslint temporarly

* Route might be falsy

* Make CalNumber support required validation

* Loader improvements

* Add SSR support

* Fix few typescript issues

* More typesafety, download csv, bug fiees

* Add seo friendly link

* Avoid seding credebtials to frontend

* Self review fixes

* Improvements in app-store

* Cahnge Form layout

* Add scaffolding for app tests

* Add playwright tests and add user check in serving data

* Add CI tests

* Add route builder test

* Styling

* Apply suggestions from code review

Co-authored-by: Agusti Fernandez Pardo <6601142+agustif@users.noreply.github.com>

* Changes as per loom feedback

* Increase time for tests

* Fix PR suggestions

* Import CSS only in the module

* Fix codacy issues

* Move the codebbase to ee and add PRO and license check

* Add Badge

* Avoid lodash import

* Fix TS error

* Fix lint errors

* Fix bug to merge conflicts resolution - me query shouldnt cause the Shell to go in loading state

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: Agusti Fernandez Pardo <6601142+agustif@users.noreply.github.com>
This commit is contained in:
Hariom Balhara 2022-07-14 18:10:53 +05:30 committed by GitHub
parent 7ec5f01647
commit 58d1c28e9d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
80 changed files with 4223 additions and 567 deletions

99
.github/workflows/e2e-app-store.yml vendored Normal file
View File

@ -0,0 +1,99 @@
name: E2E App-Store Apps
on:
push:
branches: [ feature/event-routing ]
pull_request_target: # So we can test on forks
branches:
- main
paths-ignore:
- apps/api/**
- apps/console/**
- apps/docs/**
- apps/swagger/**
- apps/website/**
- apps/web/public/**
jobs:
test:
timeout-minutes: 20
name: E2E App-Store Apps
strategy:
matrix:
node: ["16.x"]
os: [ubuntu-latest]
runs-on: ${{ matrix.os }}
env:
DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
NEXT_PUBLIC_WEBAPP_URL: http://localhost:3000
NEXT_PUBLIC_WEBSITE_URL: http://localhost:3000
NEXTAUTH_SECRET: secret
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
GOOGLE_LOGIN_ENABLED: true
# CRON_API_KEY: xxx
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
PAYMENT_FEE_PERCENTAGE: 0.005
PAYMENT_FEE_FIXED: 10
SAML_DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
SAML_ADMINS: pro@example.com
NEXTAUTH_URL: http://localhost:3000/api/auth
NEXT_PUBLIC_IS_E2E: 1
# EMAIL_FROM: e2e@cal.com
# EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }}
# EMAIL_SERVER_PORT: ${{ secrets.CI_EMAIL_SERVER_PORT }}
# EMAIL_SERVER_USER: ${{ secrets.CI_EMAIL_SERVER_USER }}
# EMAIL_SERVER_PASSWORD: ${{ secrets.CI_EMAIL_SERVER_PASSWORD }}
# MS_GRAPH_CLIENT_ID: xxx
# MS_GRAPH_CLIENT_SECRET: xxx
# ZOOM_CLIENT_ID: xxx
# ZOOM_CLIENT_SECRET: xxx
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
services:
postgres:
image: postgres:12.1
env:
POSTGRES_USER: postgres
POSTGRES_DB: calendso
ports:
- 5432:5432
steps:
- name: Checkout repo
uses: actions/checkout@v2
with:
ref: ${{ github.event.pull_request.head.sha }} # So we can test on forks
fetch-depth: 2
- run: echo 'NODE_OPTIONS="--max_old_space_size=4096"' >> $GITHUB_ENV
- name: Use Node ${{ matrix.node }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
cache: "yarn"
- name: Cache playwright binaries
uses: actions/cache@v2
id: playwright-cache
with:
path: |
~/Library/Caches/ms-playwright
~/.cache/ms-playwright
${{ github.workspace }}/node_modules/playwright
key: cache-playwright-${{ hashFiles('**/yarn.lock') }}
restore-keys: cache-playwright-
- run: yarn --frozen-lockfile
- name: Install playwright deps
# if: steps.playwright-cache.outputs.cache-hit != 'true'
run: yarn playwright install --with-deps
- name: Run Tests
run: yarn app-e2e-quick
- name: Upload Test Results
if: ${{ always() }}
uses: actions/upload-artifact@v2
with:
name: test-results-core
path: packages/app-store/**/playwright/results

4
.vscode/tasks.json vendored
View File

@ -22,10 +22,6 @@
"group": {
"kind": "build",
"isDefault": true
},
// Try start the task on folder open
"runOptions": {
"runOn": "folderOpen"
}
},
{

View File

@ -12,15 +12,20 @@ import { ChevronLeftIcon } from "@heroicons/react/solid";
import Link from "next/link";
import React, { useEffect, useState } from "react";
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
import { InstallAppButton } from "@calcom/app-store/components";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import { App as AppType } from "@calcom/types/App";
import { Button, SkeletonButton } from "@calcom/ui";
import LicenseRequired from "@ee/components/LicenseRequired";
import { trpc } from "@lib/trpc";
import Shell from "@components/Shell";
import Badge from "@components/ui/Badge";
export default function App({
const Component = ({
name,
type,
logo,
@ -36,25 +41,19 @@ export default function App({
email,
tos,
privacy,
}: {
name: string;
type: AppType["type"];
isGlobal?: AppType["isGlobal"];
logo: string;
body: React.ReactNode;
categories: string[];
author: string;
pro?: boolean;
price?: number;
commission?: number;
feeType?: AppType["feeType"];
docs?: string;
website?: string;
email: string; // required
tos?: string;
privacy?: string;
}) {
isProOnly,
}: Parameters<typeof App>[0]) => {
const { t } = useLocale();
const { data: user } = trpc.useQuery(["viewer.me"]);
const mutation = useAddAppMutation(null, {
onSuccess: () => {
showToast("App successfully installed", "success");
},
onError: (error) => {
if (error instanceof Error) showToast(error.message || "App could not be installed", "error");
},
});
const priceInDollar = Intl.NumberFormat("en-US", {
style: "currency",
@ -90,176 +89,233 @@ export default function App({
getInstalledApp(type);
}, [type]);
const allowedMultipleInstalls = categories.indexOf("calendar") > -1;
return (
<>
<Shell large isPublic>
<div className="-mx-4 md:-mx-8">
<div className="bg-gray-50 px-8">
<Link href="/apps">
<a className="mt-2 inline-flex px-1 py-2 text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-800">
<ChevronLeftIcon className="h-5 w-5" /> {t("browse_apps")}
</a>
</Link>
<div className="items-center justify-between py-4 sm:flex sm:py-8">
<div className="flex">
<img className="h-16 w-16 rounded-sm" src={logo} alt={name} />
<header className="px-4 py-2">
<h1 className="font-cal text-xl text-gray-900">{name}</h1>
<h2 className="text-sm text-gray-500">
<span className="capitalize">{categories[0]}</span> {t("published_by", { author })}
</h2>
</header>
</div>
<div className="mt-4 sm:mt-0 sm:text-right">
{!isLoading ? (
isGlobal || (installedAppCount > 0 && allowedMultipleInstalls) ? (
<div className="space-x-3">
<Button StartIcon={CheckIcon} color="secondary" disabled>
{installedAppCount > 0
? t("active_install", { count: installedAppCount })
: t("globally_install")}
</Button>
<InstallAppButton
type={type}
render={(buttonProps) => (
<Button StartIcon={PlusIcon} data-testid="install-app-button" {...buttonProps}>
{t("add_another")}
</Button>
)}
/>
</div>
) : (
<InstallAppButton
type={type}
render={(buttonProps) => (
<Button data-testid="install-app-button" {...buttonProps}>
{t("install_app")}
</Button>
)}
/>
)
) : (
<SkeletonButton width="24" height="10" />
)}
{price !== 0 && (
<small className="block text-right">
{feeType === "usage-based"
? commission + "% + " + priceInDollar + "/booking"
: priceInDollar}
{feeType === "monthly" && "/" + t("month")}
</small>
)}
return (
<div className="-mx-4 md:-mx-8">
<div className="bg-gray-50 px-8">
<Link href="/apps">
<a className="mt-2 inline-flex px-1 py-2 text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-800">
<ChevronLeftIcon className="h-5 w-5" /> {t("browse_apps")}
</a>
</Link>
<div className="items-center justify-between py-4 sm:flex sm:py-8">
<div className="flex">
<img className="h-16 w-16 rounded-sm" src={logo} alt={name} />
<header className="px-4 py-2">
<div className="flex items-center">
<h1 className="font-cal text-xl text-gray-900">{name}</h1>
{isProOnly && user?.plan === "FREE" ? (
<Badge className="ml-2" variant="default">
PRO
</Badge>
) : null}
</div>
</div>
{/* reintroduce once we show permissions and features
<NavTabs tabs={tabs} linkProps={{ shallow: true }} /> */}
<h2 className="text-sm text-gray-500">
<span className="capitalize">{categories[0]}</span> {t("published_by", { author })}
</h2>
</header>
</div>
<div className="justify-between px-8 py-10 md:flex">
<div className="prose-sm prose mb-6">{body}</div>
<div className="md:max-w-80 flex-1 md:ml-8">
<h4 className="font-medium text-gray-900 ">{t("categories")}</h4>
<div className="space-x-2">
{categories.map((category) => (
<Link href={"/apps/categories/" + category} key={category}>
<a>
<Badge variant="success">{category}</Badge>
</a>
</Link>
))}
</div>
<h4 className="mt-8 font-medium text-gray-900 ">{t("pricing")}</h4>
<small>
{price === 0 ? (
"Free"
) : (
<>
{Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
useGrouping: false,
}).format(price)}
{feeType === "monthly" && "/" + t("month")}
</>
)}
<div className="mt-4 sm:mt-0 sm:text-right">
{!isLoading ? (
isGlobal || (installedAppCount > 0 && allowedMultipleInstalls) ? (
<div className="space-x-3">
<Button StartIcon={CheckIcon} color="secondary" disabled>
{installedAppCount > 0
? t("active_install", { count: installedAppCount })
: t("globally_install")}
</Button>
<InstallAppButton
type={type}
isProOnly={isProOnly}
render={({ useDefaultComponent, ...props }) => {
if (useDefaultComponent) {
props = {
onClick: () => {
mutation.mutate({ type });
},
loading: mutation.isLoading,
};
}
return (
<Button StartIcon={PlusIcon} {...props} data-testid="install-app-button">
{t("add_another")}
</Button>
);
}}
/>
</div>
) : (
<InstallAppButton
type={type}
isProOnly={isProOnly}
render={({ useDefaultComponent, ...props }) => {
if (useDefaultComponent) {
props = {
onClick: () => {
mutation.mutate({ type });
},
loading: mutation.isLoading,
};
}
return (
<Button data-testid="install-app-button" {...props}>
{t("install_app")}
</Button>
);
}}
/>
)
) : (
<SkeletonButton width="24" height="10" />
)}
{price !== 0 && (
<small className="block text-right">
{feeType === "usage-based" ? commission + "% + " + priceInDollar + "/booking" : priceInDollar}
{feeType === "monthly" && "/" + t("month")}
</small>
<h4 className="mt-8 mb-2 font-medium text-gray-900 ">{t("learn_more")}</h4>
<ul className="prose -ml-1 -mr-1 text-xs leading-5">
{docs && (
<li>
<a
target="_blank"
rel="noreferrer"
className="text-blue-500 no-underline hover:underline"
href={docs}>
<BookOpenIcon className="mr-1 -mt-1 inline h-4 w-4" />
{t("documentation")}
</a>
</li>
)}
{website && (
<li>
<a
target="_blank"
rel="noreferrer"
className="text-blue-500 no-underline hover:underline"
href={website}>
<ExternalLinkIcon className="mr-1 -mt-px inline h-4 w-4" />
{website.replace("https://", "")}
</a>
</li>
)}
{email && (
<li>
<a
target="_blank"
rel="noreferrer"
className="text-blue-500 no-underline hover:underline"
href={"mailto:" + email}>
<MailIcon className="mr-1 -mt-px inline h-4 w-4" />
{email}
</a>
</li>
)}
{tos && (
<li>
<a
target="_blank"
rel="noreferrer"
className="text-blue-500 no-underline hover:underline"
href={tos}>
<DocumentTextIcon className="mr-1 -mt-px inline h-4 w-4" />
{t("terms_of_service")}
</a>
</li>
)}
{privacy && (
<li>
<a
target="_blank"
rel="noreferrer"
className="text-blue-500 no-underline hover:underline"
href={privacy}>
<ShieldCheckIcon className="mr-1 -mt-px inline h-4 w-4" />
{t("privacy_policy")}
</a>
</li>
)}
</ul>
<hr className="my-6" />
<small className="leading-1 block text-gray-500">
Every app published on the Cal.com App Store is open source and thoroughly tested via peer
reviews. Nevertheless, Cal.com, Inc. does not endorse or certify these apps unless they are
published by Cal.com. If you encounter inappropriate content or behaviour please report it.
</small>
<a className="mt-2 block text-xs text-red-500" href="mailto:help@cal.com">
<FlagIcon className="inline h-3 w-3" /> Report App
</a>
</div>
)}
</div>
</div>
</Shell>
</>
{/* reintroduce once we show permissions and features
<NavTabs tabs={tabs} linkProps={{ shallow: true }} /> */}
</div>
<div className="justify-between px-8 py-10 md:flex">
<div className="prose-sm prose mb-6">{body}</div>
<div className="md:max-w-80 flex-1 md:ml-8">
<h4 className="font-medium text-gray-900 ">{t("categories")}</h4>
<div className="space-x-2">
{categories.map((category) => (
<Link href={"/apps/categories/" + category} key={category}>
<a>
<Badge variant="success">{category}</Badge>
</a>
</Link>
))}
</div>
<h4 className="mt-8 font-medium text-gray-900 ">{t("pricing")}</h4>
<small>
{price === 0 ? (
"Free"
) : (
<>
{Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
useGrouping: false,
}).format(price)}
{feeType === "monthly" && "/" + t("month")}
</>
)}
</small>
<h4 className="mt-8 mb-2 font-medium text-gray-900 ">{t("learn_more")}</h4>
<ul className="prose -ml-1 -mr-1 text-xs leading-5">
{docs && (
<li>
<a
target="_blank"
rel="noreferrer"
className="text-blue-500 no-underline hover:underline"
href={docs}>
<BookOpenIcon className="mr-1 -mt-1 inline h-4 w-4" />
{t("documentation")}
</a>
</li>
)}
{website && (
<li>
<a
target="_blank"
rel="noreferrer"
className="text-blue-500 no-underline hover:underline"
href={website}>
<ExternalLinkIcon className="mr-1 -mt-px inline h-4 w-4" />
{website.replace("https://", "")}
</a>
</li>
)}
{email && (
<li>
<a
target="_blank"
rel="noreferrer"
className="text-blue-500 no-underline hover:underline"
href={"mailto:" + email}>
<MailIcon className="mr-1 -mt-px inline h-4 w-4" />
{email}
</a>
</li>
)}
{tos && (
<li>
<a
target="_blank"
rel="noreferrer"
className="text-blue-500 no-underline hover:underline"
href={tos}>
<DocumentTextIcon className="mr-1 -mt-px inline h-4 w-4" />
{t("terms_of_service")}
</a>
</li>
)}
{privacy && (
<li>
<a
target="_blank"
rel="noreferrer"
className="text-blue-500 no-underline hover:underline"
href={privacy}>
<ShieldCheckIcon className="mr-1 -mt-px inline h-4 w-4" />
{t("privacy_policy")}
</a>
</li>
)}
</ul>
<hr className="my-6" />
<small className="leading-1 block text-gray-500">
Every app published on the Cal.com App Store is open source and thoroughly tested via peer
reviews. Nevertheless, Cal.com, Inc. does not endorse or certify these apps unless they are
published by Cal.com. If you encounter inappropriate content or behaviour please report it.
</small>
<a className="mt-2 block text-xs text-red-500" href="mailto:help@cal.com">
<FlagIcon className="inline h-3 w-3" /> Report App
</a>
</div>
</div>
</div>
);
};
export default function App(props: {
name: string;
type: AppType["type"];
isGlobal?: AppType["isGlobal"];
logo: string;
body: React.ReactNode;
categories: string[];
author: string;
pro?: boolean;
price?: number;
commission?: number;
feeType?: AppType["feeType"];
docs?: string;
website?: string;
email: string; // required
tos?: string;
privacy?: string;
licenseRequired: AppType["licenseRequired"];
isProOnly: AppType["isProOnly"];
}) {
return (
<Shell large isPublic>
{props.licenseRequired ? (
<LicenseRequired>
<Component {...props} />
</LicenseRequired>
) : (
<Component {...props} />
)}
</Shell>
);
}

View File

@ -18,6 +18,7 @@ export interface NavTabProps {
tabName?: string;
icon?: SVGComponent;
adminRequired?: boolean;
className?: string;
}[];
linkProps?: Omit<LinkProps, "href">;
}
@ -58,7 +59,7 @@ const NavTabs: FC<NavTabProps> = ({ tabs, linkProps, ...props }) => {
: noop;
const Component = tab.adminRequired ? AdminRequired : Fragment;
const className = tab.className || "";
return (
<Component key={tab.name}>
<Link key={tab.name} href={href} {...linkProps}>
@ -68,7 +69,8 @@ const NavTabs: FC<NavTabProps> = ({ tabs, linkProps, ...props }) => {
isCurrent
? "border-neutral-900 text-neutral-900"
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700",
"group inline-flex items-center border-b-2 py-4 px-1 text-sm font-medium"
"group inline-flex items-center border-b-2 py-4 px-1 text-sm font-medium",
className
)}
aria-current={isCurrent ? "page" : undefined}>
{tab.icon && (

View File

@ -0,0 +1,56 @@
// This component is abstracted from /event-types/[type] for common usecase.
import { PencilIcon } from "@heroicons/react/solid";
import { useState } from "react";
export default function PencilEdit({
value,
// eslint-disable-next-line @typescript-eslint/no-empty-function
onChange = () => {},
placeholder = "",
readOnly = false,
}: {
value: string;
onChange?: (value: string) => void;
placeholder?: string;
readOnly?: boolean;
}) {
const [editIcon, setEditIcon] = useState(true);
const onDivClick = !readOnly
? () => {
return setEditIcon(false);
}
: // eslint-disable-next-line @typescript-eslint/no-empty-function
() => {};
return (
<div className="group relative min-h-[28px] cursor-pointer" onClick={onDivClick}>
{editIcon ? (
<>
<h1
style={{ fontSize: 22, letterSpacing: "-0.0009em" }}
className="inline-block pl-0 text-gray-900 focus:text-black group-hover:text-gray-500">
{value}
</h1>
{!readOnly ? (
<PencilIcon className="ml-1 -mt-1 inline h-4 w-4 text-gray-700 group-hover:text-gray-500" />
) : null}
</>
) : (
<div style={{ marginBottom: -11 }}>
<input
type="text"
autoFocus
style={{ top: -6, fontSize: 22 }}
required
className="relative h-10 w-full cursor-pointer border-none bg-transparent pl-0 text-gray-900 hover:text-gray-700 focus:text-black focus:outline-none focus:ring-0"
placeholder={placeholder}
defaultValue={value}
onBlur={(e) => {
setEditIcon(true);
onChange(e.target.value);
}}
/>
</div>
)}
</div>
);
}

View File

@ -1,4 +1,5 @@
import { SelectorIcon } from "@heroicons/react/outline";
import { CollectionIcon } from "@heroicons/react/solid";
import {
ArrowLeftIcon,
CalendarIcon,
@ -125,6 +126,12 @@ const Layout = ({
}: LayoutProps & { status: SessionContextValue["status"]; plan?: UserPlan; isLoading: boolean }) => {
const isEmbed = useIsEmbed();
const router = useRouter();
const { data: routingForms } = trpc.useQuery([
"viewer.appById",
{
appId: "routing_forms",
},
]);
const { t } = useLocale();
const navigation = [
@ -146,6 +153,14 @@ const Layout = ({
icon: ClockIcon,
current: router.asPath.startsWith("/availability"),
},
routingForms
? {
name: "Routing Forms",
href: "/apps/routing_forms/forms",
icon: CollectionIcon,
current: router.asPath.startsWith("/apps/routing_forms/"),
}
: null,
{
name: t("workflows"),
href: "/workflows",
@ -157,7 +172,7 @@ const Layout = ({
name: t("apps"),
href: "/apps",
icon: ViewGridIcon,
current: router.asPath.startsWith("/apps"),
current: router.asPath.startsWith("/apps") && !router.asPath.startsWith("/apps/routing_forms/"),
child: [
{
name: t("app_store"),
@ -212,7 +227,6 @@ const Layout = ({
<KBarTrigger />
</div>
</div>
{/* logo icon for tablet */}
<Link href="/event-types">
<a className="text-center md:inline lg:hidden">
@ -220,53 +234,55 @@ const Layout = ({
</a>
</Link>
<nav className="mt-2 flex-1 space-y-1 bg-white px-2 lg:mt-5">
{navigation.map((item) => (
<Fragment key={item.name}>
<Link href={item.href}>
<a
aria-label={item.name}
className={classNames(
item.current
? "bg-neutral-100 text-neutral-900"
: "text-neutral-500 hover:bg-gray-50 hover:text-neutral-900",
"group flex items-center rounded-sm px-2 py-2 text-sm font-medium"
)}>
<item.icon
{navigation.map((item) =>
!item ? null : (
<Fragment key={item.name}>
<Link href={item.href}>
<a
aria-label={item.name}
className={classNames(
item.current
? "text-neutral-500"
: "text-neutral-400 group-hover:text-neutral-500",
"h-5 w-5 flex-shrink-0 ltr:mr-3 rtl:ml-3"
? "bg-neutral-100 text-neutral-900"
: "text-neutral-500 hover:bg-gray-50 hover:text-neutral-900",
"group flex items-center rounded-sm px-2 py-2 text-sm font-medium"
)}>
<item.icon
className={classNames(
item.current
? "text-neutral-500"
: "text-neutral-400 group-hover:text-neutral-500",
"h-5 w-5 flex-shrink-0 ltr:mr-3 rtl:ml-3"
)}
aria-hidden="true"
/>
<span className="hidden lg:inline">{item.name}</span>
{item.pro && (
<span className="ml-1">
{plan === "FREE" && <Badge variant="default">PRO</Badge>}
</span>
)}
aria-hidden="true"
/>
<span className="hidden lg:inline">{item.name}</span>
{item.pro && (
<span className="ml-1">
{plan === "FREE" && <Badge variant="default">PRO</Badge>}
</span>
)}
</a>
</Link>
{item.child &&
router.asPath.startsWith(item.href) &&
item.child.map((item) => {
return (
<Link key={item.name} href={item.href}>
<a
className={classNames(
item.current
? "text-neutral-900"
: "text-neutral-500 hover:text-neutral-900",
"group hidden items-center rounded-sm px-2 py-2 pl-10 text-sm font-medium lg:flex"
)}>
<span className="hidden lg:inline">{item.name}</span>
</a>
</Link>
);
})}
</Fragment>
))}
</a>
</Link>
{item.child &&
router.asPath.startsWith(item.href) &&
item.child.map((item) => {
return (
<Link key={item.name} href={item.href}>
<a
className={classNames(
item.current
? "text-neutral-900"
: "text-neutral-500 hover:text-neutral-900",
"group hidden items-center rounded-sm px-2 py-2 pl-10 text-sm font-medium lg:flex"
)}>
<span className="hidden lg:inline">{item.name}</span>
</a>
</Link>
);
})}
</Fragment>
)
)}
<span className="group flex items-center rounded-sm px-2 py-2 text-sm font-medium text-neutral-500 hover:bg-gray-50 hover:text-neutral-900 lg:hidden">
<KBarTrigger />
</span>
@ -316,7 +332,6 @@ const Layout = ({
<span className="group flex items-center rounded-full p-2.5 text-sm font-medium text-neutral-500 hover:bg-gray-50 hover:text-neutral-900 lg:hidden">
<KBarTrigger />
</span>
<button className="rounded-full bg-white p-2 text-gray-400 hover:bg-gray-50 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2">
<span className="sr-only">{t("settings")}</span>
<Link href="/settings/profile">
@ -350,7 +365,7 @@ const Layout = ({
<div
className={classNames(
props.large && "bg-gray-100 py-8 lg:mb-8 lg:pt-16 lg:pb-7",
"block min-h-[80px] justify-between px-4 sm:flex sm:px-6 md:px-8"
"block justify-between px-4 sm:flex sm:px-6 md:px-8"
)}>
{props.HeadingLeftIcon && <div className="ltr:mr-4">{props.HeadingLeftIcon}</div>}
<div className="mb-8 w-full">
@ -364,9 +379,7 @@ const Layout = ({
<h1 className="font-cal mb-1 text-xl font-bold capitalize tracking-wide text-gray-900">
{props.heading}
</h1>
<p className="min-h-10 text-sm text-neutral-500 ltr:mr-4 rtl:ml-4">
{props.subtitle}
</p>
<p className="text-sm text-neutral-500 ltr:mr-4 rtl:ml-4">{props.subtitle}</p>
</>
)}
</div>
@ -386,8 +399,11 @@ const Layout = ({
style={isEmbed ? { display: "none" } : {}}
className="bottom-nav fixed bottom-0 z-30 flex w-full bg-white shadow md:hidden">
{/* note(PeerRich): using flatMap instead of map to remove settings from bottom nav */}
{navigation.flatMap((item, itemIdx) =>
item.href === "/settings/profile" ? (
{navigation.flatMap((item, itemIdx) => {
if (!item) {
return null;
}
return item.href === "/settings/profile" ? (
[]
) : (
<Link key={item.name} href={item.href}>
@ -406,11 +422,11 @@ const Layout = ({
)}
aria-hidden="true"
/>
<span className="truncate">{item.name}</span>
<span className="block truncate">{item.name}</span>
</a>
</Link>
)
)}
);
})}
</nav>
)}
{/* add padding to content for mobile navigation*/}
@ -453,7 +469,7 @@ export default function Shell(props: LayoutProps) {
const i18n = useViewerI18n();
const { status } = useSession();
const isLoading = query.status === "loading" || isRedirectingToOnboarding || loading || !isReady;
const isLoading = isRedirectingToOnboarding || loading || !isReady;
// Don't show any content till translations are loaded.
// As they are cached infintely, this status would be loading just once for the app's lifetime until refresh
@ -490,7 +506,9 @@ function UserDropdown({ small }: { small?: boolean }) {
const utils = trpc.useContext();
const [helpOpen, setHelpOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
if (!user) {
return null;
}
const onHelpItemSelect = () => {
setHelpOpen(false);
setMenuOpen(false);
@ -514,14 +532,14 @@ function UserDropdown({ small }: { small?: boolean }) {
// eslint-disable-next-line @next/next/no-img-element
<img
className="rounded-full"
src={WEBAPP_URL + "/" + user?.username + "/avatar.png"}
alt={user?.username || "Nameless User"}
src={WEBAPP_URL + "/" + user.username + "/avatar.png"}
alt={user.username || "Nameless User"}
/>
}
{!user?.away && (
{!user.away && (
<div className="absolute bottom-0 right-0 h-3 w-3 rounded-full border-2 border-white bg-green-500" />
)}
{user?.away && (
{user.away && (
<div className="absolute bottom-0 right-0 h-3 w-3 rounded-full border-2 border-white bg-yellow-500" />
)}
</span>
@ -529,10 +547,10 @@ function UserDropdown({ small }: { small?: boolean }) {
<span className="flex flex-grow items-center truncate">
<span className="flex-grow truncate text-sm">
<span className="block truncate font-medium text-gray-900">
{user?.name || "Nameless User"}
{user.name || "Nameless User"}
</span>
<span className="block truncate font-normal text-neutral-500">
{user?.username
{user.username
? process.env.NEXT_PUBLIC_WEBSITE_URL === "https://cal.com"
? `cal.com/${user.username}`
: `/${user.username}`
@ -555,24 +573,24 @@ function UserDropdown({ small }: { small?: boolean }) {
<DropdownMenuItem>
<a
onClick={() => {
mutation.mutate({ away: !user?.away });
mutation.mutate({ away: user?.away });
utils.invalidateQueries("viewer.me");
}}
className="flex min-w-max cursor-pointer px-4 py-2 text-sm hover:bg-gray-100 hover:text-gray-900">
<MoonIcon
className={classNames(
user?.away
user.away
? "text-purple-500 group-hover:text-purple-700"
: "text-gray-500 group-hover:text-gray-700",
"h-5 w-5 flex-shrink-0 ltr:mr-3 rtl:ml-3"
)}
aria-hidden="true"
/>
{user?.away ? t("set_as_free") : t("set_as_away")}
{user.away ? t("set_as_free") : t("set_as_away")}
</a>
</DropdownMenuItem>
<DropdownMenuSeparator className="h-px bg-gray-200" />
{user?.username && (
{user.username && (
<DropdownMenuItem>
<a
target="_blank"

View File

@ -19,6 +19,7 @@ export default function AllApps({ apps }: { apps: App[] }) {
logo={app.logo}
rating={app.rating}
reviews={app.reviews}
isProOnly={app.isProOnly}
/>
))}
</div>

View File

@ -2,6 +2,10 @@ import Link from "next/link";
import Button from "@calcom/ui/Button";
import { trpc } from "@lib/trpc";
import Badge from "@components/ui/Badge";
interface AppCardProps {
logo: string;
name: string;
@ -10,9 +14,11 @@ interface AppCardProps {
description: string;
rating: number;
reviews?: number;
isProOnly?: boolean;
}
export default function AppCard(props: AppCardProps) {
const { data: user } = trpc.useQuery(["viewer.me"]);
return (
<Link href={"/apps/" + props.slug}>
<a
@ -32,7 +38,14 @@ export default function AppCard(props: AppCardProps) {
Add
</Button>
</div>
<h3 className="font-medium">{props.name}</h3>
<div className="flex items-center">
<h3 className="font-medium">{props.name}</h3>
{props.isProOnly && user?.plan === "FREE" ? (
<Badge className="ml-2" variant="default">
PRO
</Badge>
) : null}
</div>
{/* TODO: add reviews <div className="flex text-sm text-gray-800">
<span>{props.rating} stars</span> <StarIcon className="ml-1 mt-0.5 h-4 w-4 text-yellow-600" />
<span className="pl-1 text-gray-500">{props.reviews} reviews</span>

View File

@ -30,6 +30,7 @@ const TrendingAppsSlider = <T extends App>({ items }: { items: T[] }) => {
logo={app.logo}
rating={app.rating}
reviews={app.reviews}
isProOnly={app.isProOnly}
/>
)}
/>

View File

@ -1,5 +1,5 @@
import React from "react";
import ReactSelect, { components, GroupBase, Props, InputProps } from "react-select";
import React, { useCallback, useEffect, useState } from "react";
import ReactSelect, { components, GroupBase, Props, InputProps, SingleValue, MultiValue } from "react-select";
import classNames from "@lib/classNames";
@ -61,4 +61,71 @@ function Select<
);
}
export function SelectWithValidation<
Option extends { label: string; value: string },
isMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>
>({
required = false,
onChange,
value,
...remainingProps
}: SelectProps<Option, isMulti, Group> & { required?: boolean }) {
const [hiddenInputValue, _setHiddenInputValue] = useState(() => {
if (value instanceof Array || !value) {
return;
}
return value.value || "";
});
const setHiddenInputValue = useCallback((value: MultiValue<Option> | SingleValue<Option>) => {
let hiddenInputValue = "";
if (value instanceof Array) {
hiddenInputValue = value.map((val) => val.value).join(",");
} else {
hiddenInputValue = value?.value || "";
}
_setHiddenInputValue(hiddenInputValue);
}, []);
useEffect(() => {
if (!value) {
return;
}
setHiddenInputValue(value);
}, [value, setHiddenInputValue]);
return (
<div className={classNames("relative", remainingProps.className)}>
<Select
value={value}
{...remainingProps}
onChange={(value, ...remainingArgs) => {
setHiddenInputValue(value);
if (onChange) {
onChange(value, ...remainingArgs);
}
}}
/>
{required && (
<input
tabIndex={-1}
autoComplete="off"
style={{
opacity: 0,
width: "100%",
height: 1,
position: "absolute",
}}
value={hiddenInputValue}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onChange={() => {}}
// TODO:Not able to get focus to work
// onFocus={() => selectRef.current?.focus()}
required={required}
/>
)}
</div>
);
}
export default Select;

View File

@ -95,6 +95,11 @@ const nextConfig = {
source: "/team/:teamname/avatar.png",
destination: "/api/user/avatar?teamname=:teamname",
},
// TODO: We can expose these rewrites in packages/app-store/*.generated.ts
{
source: "/forms/:formId",
destination: "/apps/routing_forms/routing-link/:formId",
},
/* TODO: have these files being served from another deployment or CDN {
source: "/embed/embed.js",
destination: process.env.NEXT_PUBLIC_EMBED_LIB_URL?,

View File

@ -56,6 +56,7 @@
"@radix-ui/react-radio-group": "^0.1.1",
"@radix-ui/react-slider": "^0.1.1",
"@radix-ui/react-switch": "^0.1.1",
"@radix-ui/react-toggle-group": "^0.1.5",
"@radix-ui/react-tooltip": "^0.1.0",
"@stripe/react-stripe-js": "^1.8.0",
"@stripe/stripe-js": "^1.29.0",

View File

@ -14,19 +14,28 @@ import path from "path";
* This will allow us to keep all app-specific static assets in the same directory.
*/
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const [appName, fileName] = Array.isArray(req.query.static) ? req.query.static : [req.query.static];
const queryParts = Array.isArray(req.query.static) ? req.query.static : [req.query.static];
let appPath, fileName;
if (queryParts[0] === "ee") {
const appName = queryParts[1];
if (!appName) {
return res.status(400).json({ error: true, message: "No app name provided" });
}
appPath = path.join("ee", appName);
fileName = queryParts[2];
} else {
[appPath, fileName] = queryParts;
}
if (!fileName) {
return res.status(400).json({ error: true, message: "No file name provided" });
}
if (!appName) {
if (!appPath) {
return res.status(400).json({ error: true, message: "No app name provided" });
}
const fileNameParts = fileName.split(".");
const { [fileNameParts.length - 1]: fileExtension } = fileNameParts;
const STATIC_PATH = path.join(process.cwd(), "..", "..", "packages/app-store", appName, "static", fileName);
const STATIC_PATH = path.join(process.cwd(), "..", "..", "packages/app-store", appPath, "static", fileName);
try {
const imageBuffer = fs.readFileSync(STATIC_PATH);
const mimeType = mime.lookup(fileExtension);

View File

@ -1,10 +1,43 @@
import { NextApiHandler, NextApiRequest, NextApiResponse } from "next";
import { NextApiRequest, NextApiResponse } from "next";
import type { Session } from "next-auth";
import { deriveAppDictKeyFromType } from "@calcom/lib/deriveAppDictKeyFromType";
import prisma from "@calcom/prisma";
import type { AppDeclarativeHandler, AppHandler } from "@calcom/types/AppHandler";
import { getSession } from "@lib/auth";
import { HttpError } from "@lib/core/http/error";
const defaultIntegrationAddHandler = async ({
slug,
supportsMultipleInstalls,
appType,
user,
createCredential,
}: {
slug: string;
supportsMultipleInstalls: boolean;
appType: string;
user?: Session["user"];
createCredential: AppDeclarativeHandler["createCredential"];
}) => {
if (!user?.id) {
throw new HttpError({ statusCode: 401, message: "You must be logged in to do this" });
}
if (!supportsMultipleInstalls) {
const alreadyInstalled = await prisma.credential.findFirst({
where: {
appId: slug,
userId: user.id,
},
});
if (alreadyInstalled) {
throw new Error("App is already installed");
}
}
await createCredential({ user: user, appType, slug });
};
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
// Check that user is authenticated
req.session = await getSession({ req });
@ -22,18 +55,28 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const handlerKey = deriveAppDictKeyFromType(appName, handlerMap);
const handlers = await handlerMap[handlerKey as keyof typeof handlerMap];
const handler = handlers[apiEndpoint as keyof typeof handlers] as NextApiHandler;
if (typeof handler !== "function")
const handler = handlers[apiEndpoint as keyof typeof handlers] as AppHandler;
let redirectUrl = "/apps/installed";
if (typeof handler === "undefined")
throw new HttpError({ statusCode: 404, message: `API handler not found` });
await handler(req, res);
if (typeof handler === "function") {
await handler(req, res);
} else {
await defaultIntegrationAddHandler({ user: req.session?.user, ...handler });
redirectUrl = handler.redirectUrl;
res.json({ url: redirectUrl });
}
return res.status(200);
} catch (error) {
console.error(error);
if (error instanceof HttpError) {
return res.status(error.statusCode).json({ message: error.message });
}
if (error instanceof Error) {
return res.status(400).json({ message: error.message });
}
return res.status(404).json({ message: `API handler not found` });
}
};

View File

@ -0,0 +1,124 @@
import { GetServerSidePropsContext } from "next";
import { useRouter } from "next/router";
import RoutingFormsRoutingConfig from "@calcom/app-store/ee/routing_forms/pages/app-routing.config";
import prisma from "@calcom/prisma";
import { AppGetServerSideProps } from "@calcom/types/AppGetServerSideProps";
import { inferSSRProps } from "@calcom/types/inferSSRProps";
import { getSession } from "@lib/auth";
// TODO: It is a candidate for apps.*.generated.*
const AppsRouting = {
routing_forms: RoutingFormsRoutingConfig,
};
function getRoute(appName: string, pages: string[]) {
const routingConfig = AppsRouting[appName as keyof typeof AppsRouting];
type NotFound = {
notFound: true;
};
if (!routingConfig) {
return {
notFound: true,
} as NotFound;
}
const mainPage = pages[0];
const appPage = routingConfig[mainPage as keyof typeof routingConfig];
type Found = {
notFound: false;
// A component than can accept any properties
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Component: (props: any) => JSX.Element;
getServerSideProps: AppGetServerSideProps;
};
if (!appPage) {
return {
notFound: true,
} as NotFound;
}
return { notFound: false, Component: appPage.default, ...appPage } as Found;
}
export default function AppPage(props: inferSSRProps<typeof getServerSideProps>) {
const appName = props.appName;
const router = useRouter();
const pages = router.query.pages as string[];
const route = getRoute(appName, pages);
const componentProps = {
...props,
pages: pages.slice(1),
};
if (!route || route.notFound) {
throw new Error("Route can't be undefined");
}
return <route.Component {...componentProps} />;
}
export async function getServerSideProps(
context: GetServerSidePropsContext<{
slug: string;
pages: string[];
appPages?: string[];
}>
) {
const { params } = context;
if (!params) {
return {
notFound: true,
};
}
const appName = params.slug;
const pages = params.pages;
const route = getRoute(appName, pages);
if (route.notFound) {
return route;
}
if (route.getServerSideProps) {
// TODO: Document somewhere that right now it is just a convention that filename should have appPages in it's name.
// appPages is actually hardcoded here and no matter the fileName the same variable would be used.
// We can write some validation logic later on that ensures that [...appPages].tsx file exists
params.appPages = pages.slice(1);
const session = await getSession({ req: context.req });
const user = session?.user;
const result = await route.getServerSideProps(
context as GetServerSidePropsContext<{
slug: string;
pages: string[];
appPages: string[];
}>,
prisma,
user
);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
if (result.notFound) {
return {
notFound: true,
};
}
if (result.redirect) {
return {
redirect: result.redirect,
};
}
return {
props: {
appName,
appUrl: `/apps/${appName}`,
...result.props,
},
};
} else {
return {
props: {
appName,
},
};
}
}

View File

@ -78,6 +78,8 @@ function SingleAppPage({ data, source }: inferSSRProps<typeof getStaticProps>) {
docs={data.docsUrl}
website={data.url}
email={data.email}
licenseRequired={data.licenseRequired}
isProOnly={data.isProOnly}
// tos="https://zoom.us/terms"
// privacy="https://zoom.us/privacy"
body={<MDXRemote {...source} components={components} />}

View File

@ -39,6 +39,7 @@ export default function Apps({ apps }: InferGetStaticPropsType<typeof getStaticP
description={app.description}
logo={app.logo}
rating={app.rating}
isProOnly={app.isProOnly}
/>
);
})}
@ -77,12 +78,12 @@ export const getStaticProps = async (context: GetStaticPropsContext) => {
slug: true,
},
});
const appSlugs = appQuery.map((category) => category.slug);
const dbAppsSlugs = appQuery.map((category) => category.slug);
const appStore = await getAppRegistry();
const apps = appStore.filter((app) => appSlugs.includes(app.slug));
const apps = appStore.filter((app) => dbAppsSlugs.includes(app.slug));
return {
props: {
apps,

View File

@ -985,6 +985,7 @@
"no_active_event_types": "No active event types",
"new_seat_subject": "New Attendee {{name}} on {{eventType}} at {{date}}",
"new_seat_title": "Someone has added themselves to an event",
"app_upgrade_description": "In order to use this feature, you need to upgrade to a Pro account.",
"invalid_number": "Invalid phone number",
"navigate": "Navigate",
"open": "Open",

View File

@ -3,6 +3,7 @@ import _ from "lodash";
import { JSONObject } from "superjson/dist/types";
import { z } from "zod";
import app_RoutingForms from "@calcom/app-store/ee/routing_forms/trpc-router";
import getApps, { getLocationOptions } from "@calcom/app-store/utils";
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
import dayjs from "@calcom/dayjs";
@ -650,6 +651,24 @@ const loggedInViewerRouter = createProtectedRouter()
};
},
})
.query("appById", {
input: z.object({
appId: z.string(),
}),
async resolve({ ctx, input }) {
const { user } = ctx;
const appId = input.appId;
const { credentials } = user;
const apps = getApps(credentials);
const appFromDb = apps.find((app) => app.credential?.appId === appId);
if (!appFromDb) {
return appFromDb;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { credential: _, credentials: _1, ...app } = appFromDb;
return app;
},
})
.query("web3Integration", {
async resolve({ ctx }) {
const { user } = ctx;
@ -1211,5 +1230,10 @@ export const viewerRouter = createRouter()
.merge("availability.", availabilityRouter)
.merge("teams.", viewerTeamsRouter)
.merge("webhook.", webhookRouter)
.merge("apiKeys.", apiKeysRouter)
.merge("slots.", slotsRouter)
.merge("workflows.", workflowsRouter)
.merge("apiKeys.", apiKeysRouter);
// NOTE: Add all app related routes in the bottom till the problem described in @calcom/app-store/trpc-routers.ts is solved.
// After that there would just one merge call here for all the apps.
.merge("app_routing_forms.", app_RoutingForms);

View File

@ -12,6 +12,8 @@
}
},
"include": [
/* Find a way to not require this - App files don't belong here. */
"../../packages/app-store/routing_forms/env.d.ts",
"next-env.d.ts",
"../../packages/types/*.d.ts",
"../../packages/types/next-auth.d.ts",

View File

@ -6,7 +6,8 @@
"apps/*",
"packages/*",
"packages/embeds/*",
"packages/app-store/*"
"packages/app-store/*",
"packages/app-store/ee/*"
],
"scripts": {
"build": "turbo run build --scope=\"@calcom/web\" --include-dependencies",
@ -45,7 +46,8 @@
"type-check": "turbo run type-check",
"app-store": "yarn workspace @calcom/app-store-cli cli",
"app-store:build": "yarn workspace @calcom/app-store-cli build",
"turbo-w": "node turbo-wrapper.js"
"turbo-w": "node turbo-wrapper.js",
"app-e2e-quick": "turbo run app-e2e-quick"
},
"devDependencies": {
"@snaplet/copycat": "^0.3.0",

View File

@ -9,7 +9,8 @@
"build": "ts-node --transpile-only src/app-store.ts",
"cli": "ts-node --transpile-only src/cli.tsx",
"watch": "ts-node --transpile-only src/app-store.ts --watch",
"generate": "ts-node --transpile-only src/app-store.ts"
"generate": "ts-node --transpile-only src/app-store.ts",
"post-install": "yarn build"
},
"files": [
"dist/cli.js"

View File

@ -1,19 +1,29 @@
const fs = require("fs");
const path = require("path");
import chokidar from "chokidar";
import fs from "fs";
import { debounce } from "lodash";
import path from "path";
let isInWatchMode = false;
if (process.argv[2] === "--watch") {
isInWatchMode = true;
}
const chokidar = require("chokidar");
const { debounce } = require("lodash");
const APP_STORE_PATH = path.join(__dirname, "..", "..", "app-store");
type App = {
name: string;
path: string;
};
function getAppName(candidatePath) {
function isValidAppName(candidatePath) {
if (!candidatePath.startsWith("_") && !candidatePath.includes("/") && !candidatePath.includes("\\")) {
if (
!candidatePath.startsWith("_") &&
candidatePath !== "ee" &&
!candidatePath.includes("/") &&
!candidatePath.includes("\\")
) {
return candidatePath;
}
}
if (isValidAppName(candidatePath)) {
// Already a dirname of an app
return candidatePath;
@ -26,36 +36,63 @@ function getAppName(candidatePath) {
function generateFiles() {
const browserOutput = [`import dynamic from "next/dynamic"`];
const serverOutput = [];
const appDirs = [];
const appDirs: App[] = [];
fs.readdirSync(`${APP_STORE_PATH}`).forEach(function (dir) {
if (fs.statSync(`${APP_STORE_PATH}/${dir}`).isDirectory()) {
if (!getAppName(dir)) {
return;
if (dir === "ee") {
fs.readdirSync(path.join(APP_STORE_PATH, dir)).forEach(function (eeDir) {
if (fs.statSync(path.join(APP_STORE_PATH, dir, eeDir)).isDirectory()) {
if (!getAppName(path.resolve(eeDir))) {
appDirs.push({
name: eeDir,
path: path.join(dir, eeDir),
});
}
}
});
} else {
if (fs.statSync(path.join(APP_STORE_PATH, dir)).isDirectory()) {
if (!getAppName(dir)) {
return;
}
appDirs.push({
name: dir,
path: dir,
});
}
appDirs.push(dir);
}
});
function forEachAppDir(callback) {
function forEachAppDir(callback: (arg: App) => void) {
for (let i = 0; i < appDirs.length; i++) {
callback(appDirs[i]);
}
}
function getObjectExporter(objectName, { fileToBeImported, importBuilder, entryBuilder }) {
function getObjectExporter(
objectName,
{
fileToBeImported,
importBuilder,
entryBuilder,
}: {
fileToBeImported: string;
importBuilder: (arg: App) => string;
entryBuilder: (arg: App) => string;
}
) {
const output = [];
forEachAppDir((appName) => {
if (fs.existsSync(path.join(APP_STORE_PATH, appName, fileToBeImported))) {
output.push(importBuilder(appName));
forEachAppDir((app) => {
if (fs.existsSync(path.join(APP_STORE_PATH, app.path, fileToBeImported))) {
output.push(importBuilder(app));
}
});
output.push(`export const ${objectName} = {`);
forEachAppDir((dirName) => {
if (fs.existsSync(path.join(APP_STORE_PATH, dirName, fileToBeImported))) {
output.push(entryBuilder(dirName));
forEachAppDir((app) => {
if (fs.existsSync(path.join(APP_STORE_PATH, app.path, fileToBeImported))) {
output.push(entryBuilder(app));
}
});
@ -66,25 +103,25 @@ function generateFiles() {
serverOutput.push(
...getObjectExporter("apiHandlers", {
fileToBeImported: "api/index.ts",
importBuilder: (appName) => `const ${appName}_api = import("./${appName}/api");`,
entryBuilder: (appName) => `${appName}:${appName}_api,`,
importBuilder: (app) => `const ${app.name}_api = import("./${app.path}/api");`,
entryBuilder: (app) => `${app.name}:${app.name}_api,`,
})
);
browserOutput.push(
...getObjectExporter("appStoreMetadata", {
fileToBeImported: "_metadata.ts",
importBuilder: (appName) => `import { metadata as ${appName}_meta } from "./${appName}/_metadata";`,
entryBuilder: (appName) => `${appName}:${appName}_meta,`,
importBuilder: (app) => `import { metadata as ${app.name}_meta } from "./${app.path}/_metadata";`,
entryBuilder: (app) => `${app.name}:${app.name}_meta,`,
})
);
browserOutput.push(
...getObjectExporter("InstallAppButtonMap", {
fileToBeImported: "components/InstallAppButton.tsx",
importBuilder: (appName) =>
`const ${appName}_installAppButton = dynamic(() =>import("./${appName}/components/InstallAppButton"));`,
entryBuilder: (appName) => `${appName}:${appName}_installAppButton,`,
importBuilder: (app) =>
`const ${app.name}_installAppButton = dynamic(() =>import("./${app.path}/components/InstallAppButton"));`,
entryBuilder: (app) => `${app.name}:${app.name}_installAppButton,`,
})
);
const banner = `/**

View File

@ -6,13 +6,18 @@ export async function getAppWithMetadata(app: { dirName: string }) {
try {
appMetadata = (await import(`./${app.dirName}/_metadata`)).default as App;
} catch (error) {
if (error instanceof Error) {
console.error(`No metadata found for: "${app.dirName}". Message:`, error.message);
try {
appMetadata = (await import(`./ee/${app.dirName}/_metadata`)).default as App;
} catch (e) {
if (error instanceof Error) {
console.error(`No metadata found for: "${app.dirName}". Message:`, error.message);
}
return null;
}
return null;
}
if (!appMetadata) return null;
// Let's not leak api keys to the front end
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { key, ...metadata } = appMetadata;
return metadata;
}

View File

@ -0,0 +1,19 @@
import { Page } from "@playwright/test";
export async function loginAsUser(username: string, page: Page) {
// Skip if file exists
await page.goto(`${process.env.PLAYWRIGHT_TEST_BASE_URL}/auth/login`);
// Click input[name="email"]
await page.click('input[name="email"]');
// Fill input[name="email"]
await page.fill('input[name="email"]', `${username}@example.com`);
// Press Tab
await page.press('input[name="email"]', "Tab");
// Fill input[name="password"]
await page.fill('input[name="password"]', username);
// Press Enter
await page.press('input[name="password"]', "Enter");
await page.waitForSelector("[data-testid=dashboard-shell]");
// Save signed-in state to '${username}StorageState.json'.
await page.context().storageState({ path: `playwright/artifacts/${username}StorageState.json` });
}

View File

@ -0,0 +1,57 @@
import { PlaywrightTestConfig, devices } from "@playwright/test";
import { config as dotEnvConfig } from "dotenv";
import * as path from "path";
// TODO: May be derive it automatically, so that moving the file to another location doesn't require changing the code
dotEnvConfig({ path: "../../../../../.env" });
const DEFAULT_NAVIGATION_TIMEOUT = 15000;
// Paths are relative to main playwright config.
const outputDir = path.join("../results");
const testDir = path.join("../tests");
// Quick Mode has no retries to fail fast and quickly re-iterate
// Also, it runs the tests only one browser for the same reason
const quickMode = process.env.QUICK === "true";
const CI = process.env.CI;
export const config: PlaywrightTestConfig = {
forbidOnly: !!CI,
retries: quickMode && !CI ? 0 : 1,
workers: 1,
timeout: 60_000,
reporter: [
[CI ? "github" : "list"],
[
"html",
{
outputFolder: path.join(process.cwd(), "playwright", "reports", "playwright-html-report"),
open: "never",
},
],
["junit", { outputFile: path.join(process.cwd(), "playwright", "reports", "results.xml") }],
],
outputDir,
webServer: {
command: "NEXT_PUBLIC_IS_E2E=1 yarn workspace @calcom/web start -p 3000",
port: 3000,
timeout: 60_000,
reuseExistingServer: !process.env.CI,
},
use: {
baseURL: "http://localhost:3000",
locale: "en-US",
trace: "retain-on-failure",
headless: !!process.env.CI || !!process.env.PLAYWRIGHT_HEADLESS,
},
projects: [
{
name: "chromium",
testDir,
use: {
...devices["Desktop Chrome"],
/** If navigation takes more than this, then something's wrong, let's fail fast. */
navigationTimeout: DEFAULT_NAVIGATION_TIMEOUT,
},
},
],
};

View File

@ -0,0 +1 @@
export * from "../../../../apps/web/playwright/lib/testUtils";

View File

@ -1,10 +1,8 @@
import type { App } from "@calcom/types/App";
import config from "./config.json";
import _package from "./package.json";
export const metadata = {
description: _package.description,
category: "other",
// FIXME: Currently for an app to be shown as installed, it must have this variable set. Either hardcoded or if it depends on some env variable, that should be checked here
installed: true,

View File

@ -4,19 +4,25 @@ import type { IntegrationOAuthCallbackState } from "@calcom/app-store/types";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { App } from "@calcom/types/App";
function useAddAppMutation(type: App["type"], options?: Parameters<typeof useMutation>[2]) {
const appName = type;
const mutation = useMutation(async () => {
function useAddAppMutation(_type: App["type"] | null, options?: Parameters<typeof useMutation>[2]) {
const mutation = useMutation<unknown, Error, { type?: App["type"] } | "">(async (variables) => {
let type: string | null | undefined;
if (variables === "") {
type = _type;
} else {
type = variables.type;
}
const state: IntegrationOAuthCallbackState = {
returnTo: WEBAPP_URL + "/apps/installed" + location.search,
};
const stateStr = encodeURIComponent(JSON.stringify(state));
const searchParams = `?state=${stateStr}`;
const res = await fetch(`/api/integrations/${appName}/add` + searchParams);
const res = await fetch(`/api/integrations/${type}/add` + searchParams);
if (!res.ok) {
throw new Error("Something went wrong");
const errorBody = await res.json();
throw new Error(errorBody.message || "Something went wrong");
}
const json = await res.json();

View File

@ -7,6 +7,7 @@ import { metadata as applecalendar_meta } from "./applecalendar/_metadata";
import { metadata as around_meta } from "./around/_metadata";
import { metadata as caldavcalendar_meta } from "./caldavcalendar/_metadata";
import { metadata as dailyvideo_meta } from "./dailyvideo/_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 giphy_meta } from "./giphy/_metadata";
@ -32,6 +33,7 @@ applecalendar:applecalendar_meta,
around:around_meta,
caldavcalendar:caldavcalendar_meta,
dailyvideo:dailyvideo_meta,
routing_forms:routing_forms_meta,
exchange2013calendar:exchange2013calendar_meta,
exchange2016calendar:exchange2016calendar_meta,
giphy:giphy_meta,

View File

@ -5,6 +5,7 @@
const applecalendar_api = import("./applecalendar/api");
const around_api = import("./around/api");
const caldavcalendar_api = import("./caldavcalendar/api");
const routing_forms_api = import("./ee/routing_forms/api");
const exchange2013calendar_api = import("./exchange2013calendar/api");
const exchange2016calendar_api = import("./exchange2016calendar/api");
const giphy_api = import("./giphy/api");
@ -28,6 +29,7 @@ export const apiHandlers = {
applecalendar:applecalendar_api,
around:around_api,
caldavcalendar:caldavcalendar_api,
routing_forms:routing_forms_api,
exchange2013calendar:exchange2013calendar_api,
exchange2016calendar:exchange2016calendar_api,
giphy:giphy_api,

View File

@ -1,39 +1,77 @@
import { useSession } from "next-auth/react";
import { useRouter } from "next/router";
import { useState, useEffect, useRef } from "react";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { deriveAppDictKeyFromType } from "@calcom/lib/deriveAppDictKeyFromType";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { App } from "@calcom/types/App";
import Button from "@calcom/ui/Button";
import { trpc } from "@lib/trpc";
import { UpgradeToProDialog } from "@components/UpgradeToProDialog";
import { InstallAppButtonMap } from "./apps.browser.generated";
import { InstallAppButtonProps } from "./types";
export const InstallAppButton = (
function InstallAppButtonWithoutPlanCheck(
props: {
type: App["type"];
} & InstallAppButtonProps
) => {
const { status } = useSession();
const { t } = useLocale();
) {
const key = deriveAppDictKeyFromType(props.type, InstallAppButtonMap);
const InstallAppButtonComponent = InstallAppButtonMap[key as keyof typeof InstallAppButtonMap];
if (!InstallAppButtonComponent) return null;
if (status === "unauthenticated")
return (
<InstallAppButtonComponent
render={() => (
<Button
data-testid="install-app-button"
color="primary"
href={`${WEBAPP_URL}/auth/login?callbackUrl=${WEBAPP_URL + location.pathname + location.search}`}>
{t("install_app")}
</Button>
)}
onChanged={props.onChanged}
/>
);
if (!InstallAppButtonComponent) return <>{props.render({ useDefaultComponent: true })}</>;
return <InstallAppButtonComponent render={props.render} onChanged={props.onChanged} />;
}
export const InstallAppButton = (
props: {
isProOnly?: App["isProOnly"];
type: App["type"];
} & InstallAppButtonProps
) => {
const { isLoading, data: user } = trpc.useQuery(["viewer.me"]);
const { t } = useLocale();
const [modalOpen, setModalOpen] = useState(false);
const router = useRouter();
const proProtectionElementRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const el = proProtectionElementRef.current;
if (!el) {
return;
}
el.addEventListener(
"click",
(e) => {
if (!user) {
router.push(
`${WEBAPP_URL}/auth/login?callbackUrl=${WEBAPP_URL + location.pathname + location.search}`
);
e.stopPropagation();
return;
}
if (user.plan === "FREE" && props.isProOnly) {
setModalOpen(true);
e.stopPropagation();
return;
}
},
true
);
}, [isLoading, user, router, props.isProOnly]);
if (isLoading) {
return null;
}
return (
<div ref={proProtectionElementRef}>
<InstallAppButtonWithoutPlanCheck {...props} />
<UpgradeToProDialog modalOpen={modalOpen} setModalOpen={setModalOpen}>
{t("app_upgrade_description")}
</UpgradeToProDialog>
</div>
);
};
export { AppConfiguration } from "./_components/AppConfiguration";

View File

@ -0,0 +1,18 @@
import type { App } from "@calcom/types/App";
import config from "./config.json";
export const metadata = {
category: "other",
// FIXME: Currently for an app to be shown as installed, it must have this variable set. Either hardcoded or if it depends on some env variable, that should be checked here
installed: true,
rating: 0,
reviews: 0,
trending: true,
verified: true,
licenseRequired: true,
isProOnly: true,
...config,
} as App;
export default metadata;

View File

@ -0,0 +1,25 @@
import prisma from "@calcom/prisma";
import { AppDeclarativeHandler } from "@calcom/types/AppHandler";
import appConfig from "../config.json";
const handler: AppDeclarativeHandler = {
// Instead of passing appType and slug from here, api/integrations/[..args] should be able to derive and pass these directly to createCredential
appType: appConfig.type,
slug: appConfig.slug,
supportsMultipleInstalls: false,
handlerType: "add",
createCredential: async ({ user, appType, slug }) => {
return await prisma.credential.create({
data: {
type: appType,
key: {},
userId: user.id,
appId: slug,
},
});
},
redirectUrl: "/apps/routing_forms/forms",
};
export default handler;

View File

@ -0,0 +1,2 @@
export { default as add } from "./add";
export { default as responses } from "./responses/[formId]";

View File

@ -0,0 +1,68 @@
import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@calcom/prisma";
import { Response } from "../../pages/routing-link/[...appPages]";
function escapeCsvText(str: string) {
return str.replace(/,/, "%2C");
}
async function* getResponses(formId: string) {
let responses;
let skip = 0;
const take = 100;
while (
(responses = await prisma.app_RoutingForms_FormResponse.findMany({
where: {
formId,
},
take: take,
skip: skip,
})) &&
responses.length
) {
const csv: string[] = [];
// Because attributes can be added or removed at any time we can't have fixed columns.
// Because there can be huge amount of data we can't keep all that in memory to identify columns from all the data at once.
// TODO: So, for now add the field label in front of it. It certainly needs improvement.
// TODO: Email CSV when we need to scale it.
responses.forEach((response) => {
const fieldResponses = response.response as Response;
const csvLineColumns = [];
for (const [, fieldResponse] of Object.entries(fieldResponses)) {
const label = escapeCsvText(fieldResponse.label);
const value = fieldResponse.value;
let serializedValue = "";
if (value instanceof Array) {
serializedValue = value.map((val) => escapeCsvText(val)).join(" | ");
} else {
serializedValue = escapeCsvText(value);
}
csvLineColumns.push(`"Attribute Label :=> Value"`);
csvLineColumns.push(`"${label} :=> ${serializedValue}"`);
}
csv.push(csvLineColumns.join(","));
});
skip += take;
yield csv.join("\n");
}
return "";
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { args } = req.query;
if (!args) {
throw new Error("args must be set");
}
const formId = args[2];
if (!formId) {
throw new Error("formId must be provided");
}
res.setHeader("Content-Type", "text/csv; charset=UTF-8");
res.setHeader("Transfer-Encoding", "chunked");
const csvIterator = getResponses(formId);
for await (const partialCsv of csvIterator) {
res.write(partialCsv);
res.write("\n");
}
res.end();
}

View File

@ -0,0 +1,28 @@
import NavTabs from "@components/NavTabs";
import { getSerializableForm } from "../utils";
export default function RoutingNavBar({
form,
appUrl,
}: {
form: ReturnType<typeof getSerializableForm>;
appUrl: string;
}) {
const tabs = [
{
name: "Form",
href: `${appUrl}/form-edit/${form?.id}`,
},
{
name: "Routing",
href: `${appUrl}/route-builder/${form?.id}`,
className: "hidden lg:block",
},
];
return (
<div className="mb-4">
<NavTabs tabs={tabs} linkProps={{ shallow: true }} />
</div>
);
}

View File

@ -0,0 +1,33 @@
import { ReactNode } from "react";
import { Alert } from "@calcom/ui/Alert";
import Shell from "@components/Shell";
import RoutingNavBar from "../components/RoutingNavBar";
import { getSerializableForm } from "../utils";
const RoutingShell: React.FC<{
form: ReturnType<typeof getSerializableForm>;
heading: ReactNode;
appUrl: string;
children: ReactNode;
}> = function RoutingShell({ children, form, heading, appUrl }) {
return (
<Shell heading={heading} subtitle={form.description || ""}>
<div className="-mx-4 px-4 sm:px-6 md:-mx-8 md:px-8">
{!form.routes?.length ? (
<Alert severity="warning" title="No routes defined yet" message="" className="mb-4" />
) : null}
{!form.fields.length ? (
<Alert severity="warning" title="No attributes defined yet" message="" className="mb-4" />
) : null}
<div className="bg-gray-50">
<RoutingNavBar appUrl={appUrl} form={form} />
{children}
</div>
</div>
</Shell>
);
};
export default RoutingShell;

View File

@ -0,0 +1,105 @@
import { ExternalLinkIcon, LinkIcon, DownloadIcon, TrashIcon } from "@heroicons/react/solid";
import { useRouter } from "next/router";
import { CAL_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import { Switch } from "@calcom/ui";
import { DialogTrigger, Dialog } from "@calcom/ui/Dialog";
import { trpc } from "@calcom/web/lib/trpc";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import { getSerializableForm } from "../utils";
export default function SideBar({
form,
appUrl,
}: {
form: ReturnType<typeof getSerializableForm>;
appUrl: string;
}) {
const { t } = useLocale();
const utils = trpc.useContext();
const router = useRouter();
const mutation = trpc.useMutation("viewer.app_routing_forms.form", {
onSuccess() {
router.replace(router.asPath);
},
onError() {
showToast(`Something went wrong`, "error");
},
onSettled() {
utils.invalidateQueries(["viewer.app_routing_forms.form"]);
},
});
const deleteMutation = trpc.useMutation("viewer.app_routing_forms.deleteForm", {
onError() {
showToast(`Something went wrong`, "error");
},
onSuccess() {
router.push(`/${appUrl}/forms`);
},
});
const formLink = `${CAL_URL}/forms/${form.id}`;
return (
<div className="m-0 mt-1 mb-4 w-full lg:w-3/12 lg:px-2 lg:ltr:ml-2 lg:rtl:mr-2">
<div className="px-2">
<Switch
checked={!form.disabled}
onCheckedChange={(isChecked) => {
mutation.mutate({ ...form, disabled: !isChecked });
}}
label={!form.disabled ? t("Disable Form") : t("Enable Form")}
/>
</div>
<div className="mt-4 space-y-1.5">
<a
href={formLink}
target="_blank"
rel="noreferrer"
className="text-md inline-flex items-center rounded-sm px-2 py-1 text-sm font-medium text-neutral-700 hover:bg-gray-200 hover:text-gray-900">
<ExternalLinkIcon className="h-4 w-4 text-neutral-500 ltr:mr-2 rtl:ml-2" aria-hidden="true" />
{t("preview")}
</a>
<button
onClick={() => {
navigator.clipboard.writeText(formLink);
showToast("Link copied!", "success");
}}
type="button"
className="text-md flex items-center rounded-sm px-2 py-1 text-sm font-medium text-gray-700 hover:bg-gray-200 hover:text-gray-900">
<LinkIcon className="h-4 w-4 text-neutral-500 ltr:mr-2 rtl:ml-2" />
{t("Copy link to form")}
</button>
<a
href={"/api/integrations/routing_forms/responses/" + form.id}
download={`${form.name}-${form.id}.csv`}
className="text-md flex items-center rounded-sm px-2 py-1 text-sm font-medium text-gray-700 hover:bg-gray-200 hover:text-gray-900">
<DownloadIcon className="h-4 w-4 text-neutral-500 ltr:mr-2 rtl:ml-2" />
{t("Download responses (CSV)")}
</a>
<Dialog>
<DialogTrigger className="text-md flex items-center rounded-sm px-2 py-1 text-sm font-medium text-red-500 hover:bg-gray-200">
<TrashIcon className="h-4 w-4 text-red-500 ltr:mr-2 rtl:ml-2" />
{t("delete")}
</DialogTrigger>
<ConfirmationDialogContent
isLoading={deleteMutation.isLoading}
variety="danger"
title="Delete Form"
confirmBtnText="Yes, delete form"
onConfirm={() => {
deleteMutation.mutate({ id: form.id });
}}>
Are you sure you want to delete this form? Anyone who you&apos;ve shared the link with will no
longer be able to book using it.
</ConfirmationDialogContent>
</Dialog>
</div>
</div>
);
}

View File

@ -0,0 +1,2 @@
export { default as InstallAppButton } from "./InstallAppButton";
export { default as Icon } from "./icon";

View File

@ -0,0 +1,133 @@
import { Settings, Widgets, SelectWidgetProps } from "react-awesome-query-builder";
// 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";
import widgetsComponents from "../widgets";
const {
TextWidget,
TextAreaWidget,
MultiSelectWidget,
SelectWidget,
NumberWidget,
FieldSelect,
Conjs,
Button,
ButtonGroup,
Provider,
} = widgetsComponents;
const renderComponent = function <T1>(props: T1 | undefined, Component: React.FC<T1>) {
if (!props) {
return <div />;
}
return <Component {...props} />;
};
const settings: Settings = {
...BasicConfig.settings,
renderField: (props) => renderComponent(props, FieldSelect),
renderOperator: (props) => renderComponent(props, FieldSelect),
renderFunc: (props) => renderComponent(props, FieldSelect),
renderConjs: (props) => renderComponent(props, Conjs),
renderButton: (props) => renderComponent(props, Button),
renderButtonGroup: (props) => renderComponent(props, ButtonGroup),
renderProvider: (props) => renderComponent(props, Provider),
groupActionsPosition: "bottomCenter",
// Disable groups
maxNesting: 1,
};
// react-query-builder types have missing type property on Widget
const widgets: Widgets & { [key in keyof Widgets]: Widgets[key] & { type: string } } = {
...BasicConfig.widgets,
text: {
...BasicConfig.widgets.text,
factory: (props) => renderComponent(props, TextWidget),
},
textarea: {
...BasicConfig.widgets.textarea,
factory: (props) => renderComponent(props, TextAreaWidget),
},
number: {
...BasicConfig.widgets.number,
factory: (props) => renderComponent(props, NumberWidget),
},
multiselect: {
...BasicConfig.widgets.multiselect,
factory: (
props: SelectWidgetProps & {
listValues: { title: string; value: string }[];
}
) => renderComponent(props, MultiSelectWidget),
},
select: {
...BasicConfig.widgets.select,
factory: (
props: SelectWidgetProps & {
listValues: { title: string; value: string }[];
}
) => renderComponent(props, SelectWidget),
},
phone: {
...BasicConfig.widgets.text,
factory: (props) => {
if (!props) {
return <div />;
}
return <TextWidget type="tel" {...props} />;
},
valuePlaceholder: "Enter Phone Number",
},
email: {
...BasicConfig.widgets.text,
factory: (props) => {
if (!props) {
return <div />;
}
return <TextWidget type="email" {...props} />;
},
},
};
const types = {
...BasicConfig.types,
phone: {
...BasicConfig.types.text,
widgets: {
...BasicConfig.types.text.widgets,
},
},
email: {
...BasicConfig.types.text,
widgets: {
...BasicConfig.types.text.widgets,
},
},
};
const operators = BasicConfig.operators;
operators.equal.label = operators.select_equals.label = "Equals";
operators.greater_or_equal.label = "Greater than or equal to";
operators.greater.label = "Greater than";
operators.less_or_equal.label = "Less than or equal to";
operators.less.label = "Less than";
operators.not_equal.label = operators.select_not_equals.label = "Does not equal";
operators.between.label = "Between";
delete operators.proximity;
delete operators.is_null;
delete operators.is_not_null;
const config = {
conjunctions: BasicConfig.conjunctions,
operators,
types,
widgets,
settings,
};
export default config;

View File

@ -0,0 +1,125 @@
.cal-query-builder .query-builder,
.cal-query-builder .qb-draggable,
.cal-query-builder .qb-drag-handler {
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
/* hide connectors */
.cal-query-builder .group-or-rule::before,
.cal-query-builder .group-or-rule::after {
/* !important to ensure that styles added by react-query-awesome-builder are overriden */
content: unset !important;
}
.cal-query-builder .group--children {
/* !important to ensure that styles added by react-query-awesome-builder are overriden */
padding-left: 0 !important;
}
/* Hide "and" for between numbers */
.cal-query-builder .widget--sep {
/* !important to ensure that styles added by react-query-awesome-builder are overriden */
display: none !important;
}
/* Layout of all fields- Distance b/w them, positioning, width */
.cal-query-builder .rule--body--wrapper {
flex: 1;
display: flex;
flex-direction: column;
}
.cal-query-builder .rule--body {
display: flex;
align-items: center;
}
.cal-query-builder .rule--field,
.cal-query-builder .rule--operator,
.cal-query-builder .rule--value {
display: flex;
flex-grow: 1;
}
.cal-query-builder .rule--widget {
display: "inline-block";
width: 100%;
}
.cal-query-builder .widget--widget,
.cal-query-builder .widget--widget,
.cal-query-builder .widget--widget > * {
width: 100%;
}
.cal-query-builder .rule--drag-handler,
.cal-query-builder .rule--header {
display: flex;
align-items: center;
margin-right: 8px;
/* !important to ensure that styles added by react-query-awesome-builder are overriden */
opacity: 1 !important;
}
.cal-query-builder .rule--func--wrapper,
.cal-query-builder .rule--func,
.cal-query-builder .rule--func--args,
.cal-query-builder .rule--func--arg,
.cal-query-builder .rule--func--arg-value,
.cal-query-builder .rule--func--bracket-before,
.cal-query-builder .rule--func--bracket-after,
.cal-query-builder .rule--func--arg-sep,
.cal-query-builder .rule--func--arg-label,
.cal-query-builder .rule--func--arg-label-sep {
display: inline-block;
}
.cal-query-builder .rule--field,
.cal-query-builder .group--field,
.cal-query-builder .rule--operator,
.cal-query-builder .rule--value,
.cal-query-builder .rule--operator-options,
.cal-query-builder .widget--widget,
.cal-query-builder .widget--valuesrc,
.cal-query-builder .operator--options--sep,
.cal-query-builder .rule--before-widget,
.cal-query-builder .rule--after-widget {
display: inline-block;
}
.cal-query-builder .rule--operator,
.cal-query-builder .widget--widget,
.cal-query-builder .widget--valuesrc,
.cal-query-builder .widget--sep {
margin-left: 10px;
}
.cal-query-builder .widget--valuesrc {
margin-right: -8px;
}
.cal-query-builder .group--header,
.cal-query-builder .group--footer {
padding-left: 10px;
padding-right: 10px;
margin-top: 10px;
margin-bottom: 10px;
}
.cal-query-builder .group-or-rule-container {
margin-top: 10px;
margin-bottom: 10px;
padding-right: 10px;
}
.cal-query-builder .rule {
background-color: white;
border: 1px solid transparent;
padding: 10px;
flex: 1;
display: flex;
}

View File

@ -0,0 +1,300 @@
import { TrashIcon } from "@heroicons/react/solid";
import { ChangeEvent } from "react";
import {
FieldProps,
ConjsProps,
ButtonProps,
ButtonGroupProps,
ProviderProps,
SelectWidgetProps,
NumberWidgetProps,
TextWidgetProps,
} from "react-awesome-query-builder";
import { Button as CalButton } from "@calcom/ui";
import { Input } from "@calcom/ui/form/fields";
// import { mapListValues } from "../../../../utils/stuff";
import { SelectWithValidation as Select } from "@components/ui/form/Select";
const TextAreaWidget = (props: TextWidgetProps) => {
const { value, setValue, readonly, placeholder, maxLength, customProps, ...remainingProps } = props;
const onChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
const val = e.target.value;
setValue(val);
};
const textValue = value || "";
return (
<textarea
value={textValue}
placeholder={placeholder}
disabled={readonly}
onChange={onChange}
maxLength={maxLength}
className="flex flex-grow border-gray-300 text-sm"
{...customProps}
{...remainingProps}
/>
);
};
const TextWidget = (props: TextWidgetProps & { type?: string }) => {
const { value, setValue, readonly, placeholder, customProps, ...remainingProps } = props;
let { type } = props;
type = type || "text";
const onChange = (e: ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setValue(val);
};
const textValue = value || "";
return (
<input
type={type}
className="flex flex-grow border-gray-300 text-sm"
value={textValue}
placeholder={placeholder}
disabled={readonly}
onChange={onChange}
{...remainingProps}
{...customProps}
/>
);
};
function NumberWidget({ value, setValue, ...remainingProps }: NumberWidgetProps) {
return (
<Input
name="query-builder"
type="number"
className="mt-0"
value={value}
onChange={(e) => {
setValue(e.target.value);
}}
{...remainingProps}
/>
);
}
const MultiSelectWidget = ({
listValues,
setValue,
value,
...remainingProps
}: SelectWidgetProps & {
listValues: { title: string; value: string }[];
}) => {
//TODO: Use Select here.
//TODO: Let's set listValue itself as label and value instead of using title.
if (!listValues) {
return null;
}
const selectItems = listValues.map((item) => {
return {
label: item.title,
value: item.value,
};
});
const defaultValue = selectItems.filter((item) => value?.value?.includes(item.value));
return (
<Select
className="block w-full min-w-0 flex-1 rounded-none rounded-r-sm border-gray-300 sm:text-sm"
menuPosition="fixed"
onChange={(items) => {
setValue(items?.map((item) => item.value));
}}
defaultValue={defaultValue}
isMulti={true}
options={selectItems}
{...remainingProps}
/>
);
};
function SelectWidget({
listValues,
setValue,
value,
...remainingProps
}: SelectWidgetProps & {
listValues: { title: string; value: string }[];
}) {
if (!listValues) {
return null;
}
const selectItems = listValues.map((item) => {
return {
label: item.title,
value: item.value,
};
});
const defaultValue = selectItems.find((item) => item.value === value);
return (
<Select
className="data-testid-select block w-full min-w-0 flex-1 rounded-none rounded-r-sm border-gray-300 sm:text-sm"
menuPosition="fixed"
onChange={(item) => {
if (!item) {
return;
}
setValue(item.value);
}}
defaultValue={defaultValue}
options={selectItems}
{...remainingProps}
/>
);
}
function Button({ type, label, onClick, readonly }: ButtonProps) {
if (type === "delRule" || type == "delGroup") {
return (
<button className="ml-5">
<TrashIcon className="m-0 h-4 w-4 text-neutral-500" onClick={onClick} />
</button>
);
}
let dataTestId = "";
if (type === "addRule") {
label = "Add rule";
dataTestId = "add-rule";
} else if (type == "addGroup") {
label = "Add rule group";
dataTestId = "add-rule-group";
}
return (
<CalButton
data-testid={dataTestId}
type="button"
color="secondary"
size="sm"
disabled={readonly}
onClick={onClick}>
{label}
</CalButton>
);
}
function ButtonGroup({ children }: ButtonGroupProps) {
if (!(children instanceof Array)) {
return null;
}
return (
<>
{children.map((button) => {
if (!button) {
return null;
}
return button;
})}
</>
);
}
function Conjs({ not, setNot, config, conjunctionOptions, setConjunction, disabled }: ConjsProps) {
if (!config || !conjunctionOptions) {
return null;
}
const conjsCount = Object.keys(conjunctionOptions).length;
const lessThenTwo = disabled;
const { forceShowConj } = config.settings;
const showConj = forceShowConj || (conjsCount > 1 && !lessThenTwo);
const options = [
{ label: "All", value: "all" },
{ label: "Any", value: "any" },
{ label: "None", value: "none" },
];
const renderOptions = () => {
const { checked: andSelected } = conjunctionOptions["AND"];
const { checked: orSelected } = conjunctionOptions["OR"];
const notSelected = not;
// Default to All
let value = andSelected ? "all" : orSelected ? "any" : "all";
if (notSelected) {
// not of All -> None
// not of Any -> All
value = value == "any" ? "none" : "all";
}
const selectValue = options.find((option) => option.value === value);
return (
<div className="flex items-center text-sm">
<span>Rule group when</span>
<Select
className="flex px-2"
defaultValue={selectValue}
options={options}
onChange={(option) => {
if (!option) return;
if (option.value === "all") {
setConjunction("AND");
setNot(false);
} else if (option.value === "any") {
setConjunction("OR");
setNot(false);
} else if (option.value === "none") {
setConjunction("OR");
setNot(true);
}
}}
/>
<span>match</span>
</div>
);
};
return showConj ? renderOptions() : null;
}
const FieldSelect = function FieldSelect(props: FieldProps) {
const { items, setField, selectedKey } = props;
const selectItems = items.map((item) => {
return {
...item,
value: item.key,
};
});
const defaultValue = selectItems.find((item) => {
return item.value === selectedKey;
});
return (
<Select
className="data-testid-field-select"
menuPosition="fixed"
onChange={(item) => {
if (!item) {
return;
}
setField(item.value);
}}
defaultValue={defaultValue}
options={selectItems}
/>
);
};
const Provider = ({ children }: ProviderProps) => children;
const widgets = {
TextWidget,
TextAreaWidget,
SelectWidget,
NumberWidget,
MultiSelectWidget,
FieldSelect,
Button,
ButtonGroup,
Conjs,
Provider,
};
export default widgets;

View File

@ -0,0 +1,16 @@
{
"/*": "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",
"variant": "other",
"categories": ["other"],
"publisher": "Cal.com",
"email": "help@cal.com",
"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",
"__createdUsingCli": true
}

View File

@ -0,0 +1 @@
declare module "react-awesome-query-builder/lib/config/basic";

View File

@ -0,0 +1,3 @@
export * as api from "./api";
export * as components from "./components";
export { metadata } from "./_metadata";

View File

@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"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 ",
"scripts": {
"app-e2e": "yarn playwright test --config=playwright/config/playwright.config.ts",
"app-e2e-quick": "QUICK=true yarn app-e2e"
},
"dependencies": {
"@calcom/lib": "*",
"dotenv": "^16.0.1",
"json-logic-js": "^2.0.2",
"playwright": "^1.22.2",
"react-awesome-query-builder": "^5.1.2"
},
"devDependencies": {
"@calcom/types": "*",
"@types/json-logic-js": "^1.2.1"
}
}

View File

@ -0,0 +1,14 @@
//TODO: Generate this file automatically so that like in Next.js file based routing can work automatically
import * as formEdit from "./form-edit/[...appPages]";
import * as forms from "./forms/[...appPages]";
import * as RouteBuilder from "./route-builder/[...appPages]";
import * as RoutingLink from "./routing-link/[...appPages]";
const routingConfig = {
"form-edit": formEdit,
"route-builder": RouteBuilder,
forms: forms,
"routing-link": RoutingLink,
};
export default routingConfig;

View File

@ -0,0 +1,402 @@
import { TrashIcon, PlusIcon, ArrowUpIcon, CollectionIcon, ArrowDownIcon } from "@heroicons/react/solid";
import { useRouter } from "next/router";
import { useForm, UseFormReturn, useFieldArray, Controller } from "react-hook-form";
import { v4 as uuidv4 } from "uuid";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import { AppGetServerSidePropsContext, AppPrisma, AppUser } from "@calcom/types/AppGetServerSideProps";
import { Button, Select, BooleanToggleGroup, EmptyScreen } from "@calcom/ui";
import { Form, TextArea } from "@calcom/ui/form/fields";
import { trpc } from "@calcom/web/lib/trpc";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import PencilEdit from "@components/PencilEdit";
import RoutingShell from "../../components/RoutingShell";
import SideBar from "../../components/SideBar";
import { getSerializableForm } from "../../utils";
export const FieldTypes = [
{
label: "Short Text",
value: "text",
},
{
label: "Number",
value: "number",
},
{
label: "Long Text",
value: "textarea",
},
{
label: "Select",
value: "select",
},
{
label: "MultiSelect",
value: "multiselect",
},
{
label: "Phone",
value: "phone",
},
{
label: "Email",
value: "email",
},
];
function Field({
hookForm,
hookFieldNamespace,
deleteField,
moveUp,
moveDown,
}: {
hookForm: UseFormReturn<inferSSRProps<typeof getServerSideProps>["form"]>;
hookFieldNamespace: `fields.${number}`;
deleteField: {
check: () => boolean;
fn: () => void;
};
moveUp: {
check: () => boolean;
fn: () => void;
};
moveDown: {
check: () => boolean;
fn: () => void;
};
}) {
return (
<div
data-testid="attribute"
className="group mb-4 flex w-full items-center justify-between hover:bg-neutral-50 ltr:mr-2 rtl:ml-2">
{moveUp.check() ? (
<button
type="button"
className="invisible absolute left-1/2 -mt-4 mb-4 -ml-4 hidden h-7 w-7 scale-0 rounded-full border bg-white p-1 text-gray-400 transition-all hover:border-transparent hover:text-black hover:shadow group-hover:visible group-hover:scale-100 sm:left-[19px] sm:ml-0 sm:block"
onClick={() => moveUp.fn()}>
<ArrowUpIcon />
</button>
) : null}
{moveDown.check() ? (
<button
type="button"
className="invisible absolute left-1/2 mt-8 -ml-4 hidden h-7 w-7 scale-0 rounded-full border bg-white p-1 text-gray-400 transition-all hover:border-transparent hover:text-black hover:shadow group-hover:visible group-hover:scale-100 sm:left-[19px] sm:ml-0 sm:block"
onClick={() => moveDown.fn()}>
<ArrowDownIcon />
</button>
) : null}
<div className="-mx-4 flex flex-1 items-center rounded-sm border border-neutral-200 bg-white p-4 py-6 sm:mx-0 sm:px-8">
<div className="w-full">
<div className="mt-2 block items-center sm:flex">
<div className="min-w-48 mb-4 sm:mb-0">
<label htmlFor="label" className="mt-0 flex text-sm font-medium text-neutral-700">
Label
</label>
</div>
<div className="w-full">
<input
type="text"
required
{...hookForm.register(`${hookFieldNamespace}.label`)}
className="block w-full rounded-sm border-gray-300 text-sm"
/>
</div>
</div>
<div className="mt-2 block items-center sm:flex">
<div className="min-w-48 mb-4 sm:mb-0">
<label htmlFor="label" className="mt-0 flex text-sm font-medium text-neutral-700">
Type
</label>
</div>
<div className="w-full">
<Controller
name={`${hookFieldNamespace}.type`}
control={hookForm.control}
render={({ field: { value, onChange } }) => {
const defaultValue = FieldTypes.find((fieldType) => fieldType.value === value);
return (
<Select
className="data-testid-attribute-type"
options={FieldTypes}
onChange={(option) => {
if (!option) {
return;
}
onChange(option.value);
}}
defaultValue={defaultValue}
/>
);
}}
/>
</div>
</div>
{["select", "multiselect"].includes(hookForm.watch(`${hookFieldNamespace}.type`)) ? (
<div className="mt-2 block items-center sm:flex">
<div className="min-w-48 mb-4 sm:mb-0">
<label htmlFor="label" className="mt-0 flex text-sm font-medium text-neutral-700">
Options
</label>
</div>
<div className="w-full">
<TextArea
placeholder="Add 1 option per line"
{...hookForm.register(`${hookFieldNamespace}.selectText`)}
/>
</div>
</div>
) : null}
<div className="mt-2 block items-center sm:flex">
<div className="min-w-48 mb-4 sm:mb-0">
<label htmlFor="label" className="mt-0 flex text-sm font-medium text-neutral-700">
Required
</label>
</div>
<div className="w-full">
<Controller
name={`${hookFieldNamespace}.required`}
control={hookForm.control}
render={({ field: { value, onChange } }) => {
return <BooleanToggleGroup value={value} onValueChange={onChange} />;
}}
/>
</div>
</div>
</div>
{deleteField.check() ? (
<button
className="float-right ml-5"
onClick={() => {
deleteField.fn();
}}
color="secondary">
<TrashIcon className="h-4 w-4 text-gray-400" />
</button>
) : null}
</div>
</div>
);
}
export default function FormEdit({
form,
appUrl,
}: inferSSRProps<typeof getServerSideProps> & { appUrl: string }) {
const { t } = useLocale();
const utils = trpc.useContext();
const router = useRouter();
const mutation = trpc.useMutation("viewer.app_routing_forms.form", {
onError() {
showToast(`Something went wrong`, "error");
},
onSettled() {
utils.invalidateQueries([
"viewer.app_routing_forms.form",
{
id: form.id,
},
]);
},
onSuccess() {
showToast(`Form updated successfully.`, "success");
router.replace(router.asPath);
},
});
const fieldsNamespace = "fields";
const hookForm = useForm({
defaultValues: form,
});
const {
fields: hookFormFields,
append: appendHookFormField,
remove: removeHookFormField,
swap: swapHookFormField,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore https://github.com/react-hook-form/react-hook-form/issues/6679
} = useFieldArray({
control: hookForm.control,
name: fieldsNamespace,
});
// hookForm.reset(form);
if (!form.fields) {
form.fields = [];
}
const addAttribute = () => {
appendHookFormField({
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
id: uuidv4(),
// This is same type from react-awesome-query-builder
type: "text",
label: "",
});
};
return (
<RoutingShell
form={form}
appUrl={appUrl}
heading={
<PencilEdit
value={
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
hookForm.watch("name")
}
onChange={(value) => {
hookForm.setValue("name", value);
}}
/>
}>
{hookFormFields.length ? (
<div className="flex flex-col-reverse lg:flex-row">
<Form
className="w-full max-w-4xl ltr:mr-2 rtl:ml-2 md:w-9/12"
form={hookForm}
handleSubmit={(data) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
mutation.mutate({
...data,
});
}}>
<div className="mb-5">
<h3 className="mb-2 text-base font-medium leading-6 text-gray-900">Description</h3>
<div className="w-full">
<textarea
id="description"
data-testid="description"
className="block w-full rounded-sm border-gray-300 text-sm "
placeholder="Form Description"
{...hookForm.register("description")}
defaultValue={form.description || ""}
/>
</div>
</div>
<hr className="mb-5 border-neutral-200" />
<h3 className="mb-2 text-base font-medium leading-6 text-gray-900">Attributes</h3>
<div className="flex flex-col">
{hookFormFields.map((field, key) => {
return (
<Field
hookForm={hookForm}
hookFieldNamespace={`${fieldsNamespace}.${key}`}
deleteField={{
check: () => hookFormFields.length > 1,
fn: () => {
removeHookFormField(key);
},
}}
moveUp={{
check: () => key !== 0,
fn: () => {
swapHookFormField(key, key - 1);
},
}}
moveDown={{
check: () => key !== hookFormFields.length - 1,
fn: () => {
if (key === hookFormFields.length - 1) {
return;
}
swapHookFormField(key, key + 1);
},
}}
key={key}
/>
);
})}
</div>
{hookFormFields.length ? (
<div className={classNames("flex")}>
<Button
data-testid="add-attribute"
type="button"
StartIcon={PlusIcon}
color="secondary"
onClick={addAttribute}>
Add Attribute
</Button>
</div>
) : null}
{hookFormFields.length ? (
<div className="mt-4 flex justify-end space-x-2 rtl:space-x-reverse">
<Button href="/apps/routing_forms/forms" color="secondary" tabIndex={-1}>
{t("cancel")}
</Button>
<Button type="submit" data-testid="update-form" disabled={mutation.isLoading}>
{t("update")}
</Button>
</div>
) : null}
</Form>
<SideBar form={form} appUrl={appUrl} />
</div>
) : (
<button data-testid="add-attribute" onClick={addAttribute} className="w-full">
<EmptyScreen
Icon={CollectionIcon}
headline="Create your first attribute"
description="Attributes are the form fields that the booker would see."
/>
</button>
)}
</RoutingShell>
);
}
export const getServerSideProps = async function getServerSideProps(
context: AppGetServerSidePropsContext,
prisma: AppPrisma,
user: AppUser
) {
if (!user) {
return {
redirect: {
permanent: false,
destination: "/auth/login",
},
};
}
const { params } = context;
if (!params) {
return {
notFound: true,
};
}
const formId = params.appPages[0];
if (!formId || params.appPages.length > 1) {
return {
notFound: true,
};
}
const form = await prisma.app_RoutingForms_Form.findUnique({
where: {
id: formId,
},
});
if (!form) {
return {
notFound: true,
};
}
return {
props: {
form: getSerializableForm(form),
},
};
};

View File

@ -0,0 +1,267 @@
import {
TrashIcon,
DotsHorizontalIcon,
DuplicateIcon,
PencilIcon,
PlusIcon,
LinkIcon,
ExternalLinkIcon,
CollectionIcon,
} from "@heroicons/react/solid";
import Link from "next/link";
import { useRouter } from "next/router";
import { v4 as uuidv4 } from "uuid";
import classNames from "@calcom/lib/classNames";
import { CAL_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import { AppGetServerSidePropsContext, AppPrisma, AppUser } from "@calcom/types/AppGetServerSideProps";
import { Button, EmptyScreen, Tooltip } from "@calcom/ui";
import Dropdown, {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@calcom/ui/Dropdown";
import { trpc } from "@calcom/web/lib/trpc";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import Shell from "@components/Shell";
import { getSerializableForm } from "../../utils";
export default function RoutingForms({
forms,
appUrl,
}: inferSSRProps<typeof getServerSideProps> & { appUrl: string }) {
const router = useRouter();
const deleteMutation = trpc.useMutation("viewer.app_routing_forms.deleteForm", {
onSuccess: () => {
showToast("Form deleted", "success");
router.replace(router.asPath);
},
onSettled: () => {
utils.invalidateQueries(["viewer.app_routing_forms.forms"]);
},
onError: () => {
showToast("Something went wrong", "error");
},
});
const utils = trpc.useContext();
const { t } = useLocale();
const mutation = trpc.useMutation("viewer.app_routing_forms.form", {
onSuccess: (_data, variables) => {
utils.invalidateQueries("viewer.app_routing_forms.forms");
router.push(`${appUrl}/form-edit/${variables.id}`);
},
onError: () => {
showToast(`Something went wrong`, "error");
},
});
const formId = uuidv4();
return (
<Shell
heading="Routing Forms"
CTA={
<Button
onClick={() => {
const form = {
id: formId,
name: `Form-${formId.slice(0, 8)}`,
};
mutation.mutate(form);
}}
data-testid="new-routing-form"
StartIcon={PlusIcon}>
New Form
</Button>
}
subtitle="You can see all routing forms and create one here.">
<div className="-mx-4 md:-mx-8">
<div className="mb-10 w-full bg-gray-50 px-4 pb-2 sm:px-6 md:px-8">
{!forms.length ? (
<EmptyScreen
Icon={CollectionIcon}
headline="Create your first form"
description="Forms enable you to 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"
/>
) : null}
{forms.length ? (
<div className="-mx-4 mb-16 overflow-hidden rounded-sm border border-gray-200 bg-white sm:mx-0">
<ul data-testid="routing-forms-list" className="divide-y divide-neutral-200">
{forms.map((form, index) => {
if (!form) {
return null;
}
const formLink = `${CAL_URL}/forms/${form.id}`;
const description = form.description || "";
const disabled = form.disabled;
form.routes = form.routes || [];
const fields = form.fields || [];
return (
<li key={index}>
<div
className={classNames(
"flex items-center justify-between hover:bg-neutral-50",
disabled ? "hover:bg-white" : ""
)}>
<div
className={classNames(
"group flex w-full items-center justify-between px-4 py-4 hover:bg-neutral-50 sm:px-6",
disabled ? "hover:bg-white" : ""
)}>
<Link href={appUrl + "/form-edit/" + form.id}>
<a
className={classNames(
"flex-grow truncate text-sm",
disabled ? "pointer-events-none cursor-not-allowed opacity-30" : ""
)}>
<div className="font-medium text-neutral-900 ltr:mr-1 rtl:ml-1">
{form.name}
</div>
<div className="text-neutral-500 dark:text-white">
<h2 className="max-w-[280px] overflow-hidden text-ellipsis pb-2 opacity-60 sm:max-w-[500px]">
{description.substring(0, 100)}
{description.length > 100 && "..."}
</h2>
<div className="mt-2 text-neutral-500 dark:text-white">
{fields.length} attributes, {form.routes.length} routes &{" "}
{form._count.responses} Responses
</div>
</div>
</a>
</Link>
<div className="mt-4 hidden flex-shrink-0 sm:mt-0 sm:ml-5 sm:flex">
<div
className={classNames(
"flex justify-between space-x-2 rtl:space-x-reverse ",
disabled && "pointer-events-none cursor-not-allowed"
)}>
<Tooltip content={t("preview") as string}>
<a
href={formLink}
target="_blank"
rel="noreferrer"
className={classNames(
"btn-icon appearance-none",
disabled && " opacity-30"
)}>
<ExternalLinkIcon
className={classNames("h-5 w-5", !disabled && "group-hover:text-black")}
/>
</a>
</Tooltip>
<Tooltip content={t("copy_link") as string}>
<button
onClick={() => {
showToast(t("link_copied"), "success");
navigator.clipboard.writeText(formLink);
}}
className={classNames("btn-icon", disabled && " opacity-30")}>
<LinkIcon
className={classNames("h-5 w-5", !disabled && "group-hover:text-black")}
/>
</button>
</Tooltip>
</div>
</div>
<Dropdown>
<DropdownMenuTrigger className="h-10 w-10 cursor-pointer rounded-sm border border-transparent text-neutral-500 hover:border-gray-300 hover:text-neutral-900 focus:border-gray-300">
<DotsHorizontalIcon className="h-5 w-5 group-hover:text-gray-800" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<Link href={appUrl + "/form-edit/" + form.id} passHref={true}>
<Button
type="button"
size="sm"
color="minimal"
className={classNames("w-full rounded-none")}
StartIcon={PencilIcon}>
{t("edit")}
</Button>
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<Button
type="button"
color="minimal"
size="sm"
className={classNames("hidden w-full rounded-none")}
StartIcon={DuplicateIcon}>
{t("duplicate")}
</Button>
</DropdownMenuItem>
<DropdownMenuSeparator className="h-px bg-gray-200" />
<DropdownMenuItem>
<Button
onClick={() => {
deleteMutation.mutate({
id: form.id,
});
}}
color="warn"
size="sm"
StartIcon={TrashIcon}
className="w-full rounded-none">
{t("delete")}
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</div>
</div>
</li>
);
})}
</ul>
</div>
) : null}
</div>
</div>
</Shell>
);
}
export const getServerSideProps = async function getServerSideProps(
context: AppGetServerSidePropsContext,
prisma: AppPrisma,
user: AppUser
) {
if (!user) {
return {
redirect: {
permanent: false,
destination: "/auth/login",
},
};
}
const forms = await prisma.app_RoutingForms_Form.findMany({
where: {
userId: user.id,
},
orderBy: {
createdAt: "desc",
},
include: {
_count: {
select: {
responses: true,
},
},
},
});
const serializableForms = forms.map((form) => getSerializableForm(form));
return {
props: {
forms: serializableForms,
},
};
};

View File

@ -0,0 +1,518 @@
import { PlusIcon, TrashIcon, ArrowUpIcon, ArrowDownIcon } from "@heroicons/react/solid";
import { useRouter } from "next/router";
import React, { useState, useCallback } from "react";
import { Query, Config, Builder, Utils as QbUtils } from "react-awesome-query-builder";
// types
import { JsonTree, ImmutableTree, BuilderProps } from "react-awesome-query-builder";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import { AppGetServerSidePropsContext, AppPrisma, AppUser } from "@calcom/types/AppGetServerSideProps";
import { inferSSRProps } from "@calcom/types/inferSSRProps";
import { Button } from "@calcom/ui";
import { Label } from "@calcom/ui/form/fields";
import { trpc } from "@calcom/web/lib/trpc";
import PencilEdit from "@components/PencilEdit";
import { SelectWithValidation as Select } from "@components/ui/form/Select";
import RoutingShell from "../../components/RoutingShell";
import SideBar from "../../components/SideBar";
import QueryBuilderInitialConfig from "../../components/react-awesome-query-builder/config/config";
import "../../components/react-awesome-query-builder/styles.css";
import { getSerializableForm } from "../../utils";
import { FieldTypes } from "../form-edit/[...appPages]";
const InitialConfig = QueryBuilderInitialConfig;
type QueryBuilderUpdatedConfig = typeof QueryBuilderInitialConfig & { fields: Config["fields"] };
export function getQueryBuilderConfig(form: inferSSRProps<typeof getServerSideProps>["form"]) {
const fields: Record<string, any> = {};
form.fields?.forEach((field) => {
if (FieldTypes.map((f) => f.value).includes(field.type)) {
const optionValues = field.selectText?.trim().split("\n");
const options = optionValues?.map((value) => {
const title = value;
return {
value,
title,
};
});
const widget = InitialConfig.widgets[field.type];
const widgetType = widget.type;
fields[field.id] = {
label: field.label,
type: widgetType,
valueSources: ["value"],
fieldSettings: {
listValues: options,
},
// preferWidgets: field.type === "textarea" ? ["textarea"] : [],
};
} else {
throw new Error("Unsupported field type:" + field.type);
}
});
// You need to provide your own config. See below 'Config format'
const config: QueryBuilderUpdatedConfig = {
...InitialConfig,
fields: fields,
};
return config;
}
const getEmptyRoute = (): SerializableRoute => {
const uuid = QbUtils.uuid();
return {
id: uuid,
action: {
type: "eventTypeRedirectUrl",
value: "",
},
queryValue: { id: uuid, type: "group" },
};
};
const createFallbackRoute = (): SerializableRoute => {
const uuid = QbUtils.uuid();
return {
id: uuid,
isFallback: true,
action: {
type: "customPageMessage",
value: "Thank you for your interest! We will be in touch soon.",
},
queryValue: { id: uuid, type: "group" },
};
};
type Route = {
id: string;
isFallback?: boolean;
action: {
type: "customPageMessage" | "externalRedirectUrl" | "eventTypeRedirectUrl";
value: string;
};
// This is what's persisted
queryValue: JsonTree;
// `queryValue` is parsed to create state
state: {
tree: ImmutableTree;
config: QueryBuilderUpdatedConfig;
};
};
type SerializableRoute = Pick<Route, "id" | "action"> & {
queryValue: Route["queryValue"];
isFallback?: Route["isFallback"];
};
const Route = ({
route,
routes,
setRoute,
config,
setRoutes,
moveUp,
moveDown,
}: {
route: Route;
routes: Route[];
setRoute: (id: string, route: Partial<Route>) => void;
config: QueryBuilderUpdatedConfig;
setRoutes: React.Dispatch<React.SetStateAction<Route[]>>;
moveUp?: { fn: () => void; check: () => boolean } | null;
moveDown?: { fn: () => void; check: () => boolean } | null;
}) => {
const index = routes.indexOf(route);
const RoutingPages: { label: string; value: Route["action"]["type"] }[] = [
{
label: "Custom Page",
value: "customPageMessage",
},
{
label: "External Redirect",
value: "externalRedirectUrl",
},
{
label: "Event Redirect",
value: "eventTypeRedirectUrl",
},
];
const { data: eventTypesByGroup } = trpc.useQuery(["viewer.eventTypes"]);
const eventOptions: { label: string; value: string }[] = [];
eventTypesByGroup?.eventTypeGroups.forEach((group) => {
group.eventTypes.forEach((eventType) => {
const uniqueSlug = `${group.profile.slug}/${eventType.slug}`;
eventOptions.push({
label: uniqueSlug,
value: uniqueSlug,
});
});
});
const onChange = (route: Route, immutableTree: ImmutableTree, config: QueryBuilderUpdatedConfig) => {
const jsonTree = QbUtils.getTree(immutableTree);
setRoute(route.id, {
state: { tree: immutableTree, config: config },
queryValue: jsonTree,
});
};
const renderBuilder = useCallback(
(props: BuilderProps) => (
<div className="query-builder-container">
<div className="query-builder qb-lite">
<Builder {...props} />
</div>
</div>
),
[]
);
return (
<div className="group mb-4 flex w-full flex-row items-center justify-between hover:bg-neutral-50 ltr:mr-2 rtl:ml-2">
{!route.isFallback ? (
<>
{moveUp?.check() ? (
<button
type="button"
className="invisible absolute left-1/2 -mt-4 mb-4 -ml-4 hidden h-7 w-7 scale-0 rounded-full border bg-white p-1 text-gray-400 transition-all hover:border-transparent hover:text-black hover:shadow group-hover:visible group-hover:scale-100 sm:left-[19px] sm:ml-0 sm:block"
onClick={() => moveUp?.fn()}>
<ArrowUpIcon />
</button>
) : null}
{moveDown?.check() ? (
<button
type="button"
className="invisible absolute left-1/2 mt-8 -ml-4 hidden h-7 w-7 scale-0 rounded-full border bg-white p-1 text-gray-400 transition-all hover:border-transparent hover:text-black hover:shadow group-hover:visible group-hover:scale-100 sm:left-[19px] sm:ml-0 sm:block"
onClick={() => moveDown?.fn()}>
<ArrowDownIcon />
</button>
) : null}
</>
) : null}
<div className="-mx-4 mb-4 flex w-full items-center rounded-sm border border-neutral-200 bg-white sm:mx-0 xl:px-8">
<div className="cal-query-builder m-4 my-8 w-full ">
<div>
<div className="flex w-full items-center text-sm text-gray-900">
<div className="flex flex-grow-0 whitespace-nowrap">
<Label>{route.isFallback ? "Fallback Route" : `Route ${index + 1}`}</Label>
<span>: Send Booker to</span>
</div>
<Select
className="block w-full flex-grow px-2"
required
value={RoutingPages.find((page) => page.value === route.action.type)}
onChange={(item) => {
if (!item) {
return;
}
const action: Route["action"] = {
type: item.value,
value: "",
};
if (action.type === "customPageMessage") {
action.value = "We are not ready for you yet :(";
} else {
action.value = "";
}
setRoute(route.id, { action });
}}
options={RoutingPages}
/>
{route.action.type ? (
route.action.type === "customPageMessage" ? (
<textarea
required
className="flex w-full flex-grow border-gray-300"
value={route.action.value}
onChange={(e) => {
setRoute(route.id, { action: { ...route.action, value: e.target.value } });
}}
/>
) : route.action.type === "externalRedirectUrl" ? (
<input
className="flex w-full flex-grow border-gray-300 text-sm"
type="text"
required
value={route.action.value}
onChange={(e) => {
setRoute(route.id, { action: { ...route.action, value: e.target.value } });
}}
placeholder="Enter External Redirect URL"
/>
) : (
<div className="block w-full">
<Select
required
options={eventOptions}
onChange={(option) => {
if (!option) {
return;
}
setRoute(route.id, { action: { ...route.action, value: option.value } });
}}
value={eventOptions.find((eventOption) => eventOption.value === route.action.value)}
/>
</div>
)
) : null}
{routes.length !== 1 && !route.isFallback ? (
<button className="ml-5" type="button">
<TrashIcon
className="m-0 h-4 w-4 text-neutral-500"
onClick={() => {
const newRoutes = routes.filter((r) => r.id !== route.id);
setRoutes(newRoutes);
}}
/>
</button>
) : null}
</div>
<hr className="my-6 text-gray-200" />
<Query
{...config}
value={route.state.tree}
onChange={(immutableTree, config) => {
onChange(route, immutableTree, config as QueryBuilderUpdatedConfig);
}}
renderBuilder={renderBuilder}
/>
</div>
</div>
</div>
</div>
);
};
const deserializeRoute = (route: SerializableRoute, config: QueryBuilderUpdatedConfig): Route => {
return {
...route,
state: {
tree: QbUtils.checkTree(QbUtils.loadTree(route.queryValue), config),
config: config,
},
};
};
const Routes = ({
form,
appUrl,
}: {
form: inferSSRProps<typeof getServerSideProps>["form"];
appUrl: string;
}) => {
const { routes: serializedRoutes } = form;
const { t } = useLocale();
const router = useRouter();
const config = getQueryBuilderConfig(form);
const [routes, setRoutes] = useState(() => {
const transformRoutes = () => {
const _routes = serializedRoutes || [getEmptyRoute()];
_routes.forEach((r) => {
if (!r.queryValue?.id) {
r.queryValue = { id: QbUtils.uuid(), type: "group" };
}
});
return _routes;
};
return transformRoutes().map((route) => deserializeRoute(route, config));
});
const mutation = trpc.useMutation("viewer.app_routing_forms.form", {
onSuccess: () => {
showToast("Form routes saved successfully.", "success");
router.replace(router.asPath);
},
onError: () => {
showToast("Something went wrong", "error");
},
});
const mainRoutes = routes.filter((route) => !route.isFallback);
let fallbackRoute = routes.find((route) => route.isFallback);
if (!fallbackRoute) {
fallbackRoute = deserializeRoute(createFallbackRoute(), config);
setRoutes((routes) => {
// Even though it's obvious that fallbackRoute is defined here but TypeScript just can't figure it out.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return [...routes, fallbackRoute!];
});
return null;
} else if (routes.indexOf(fallbackRoute) !== routes.length - 1) {
// Ensure fallback is last
setRoutes((routes) => {
// Even though it's obvious that fallbackRoute is defined here but TypeScript just can't figure it out.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return [...routes.filter((route) => route.id !== fallbackRoute!.id), fallbackRoute!];
});
}
const setRoute = (id: string, route: Partial<Route>) => {
const index = routes.findIndex((route) => route.id === id);
const newRoutes = [...routes];
newRoutes[index] = { ...routes[index], ...route };
setRoutes(newRoutes);
};
const swap = (from: number, to: number) => {
setRoutes((routes) => {
const newRoutes = [...routes];
const routeToSwap = newRoutes[from];
newRoutes[from] = newRoutes[to];
newRoutes[to] = routeToSwap;
return newRoutes;
});
};
return (
<div className="flex flex-col-reverse md:flex-row">
<form
className="w-full max-w-4xl ltr:mr-2 rtl:ml-2 md:w-9/12"
onSubmit={(e) => {
const serializedRoutes: SerializableRoute[] = routes.map((route) => ({
id: route.id,
action: route.action,
isFallback: route.isFallback,
queryValue: route.queryValue,
}));
const updatedForm = {
...form,
routes: serializedRoutes,
};
mutation.mutate(updatedForm);
e.preventDefault();
}}>
{mainRoutes.map((route, key) => {
const jsonLogicQuery = QbUtils.jsonLogicFormat(route.state.tree, route.state.config);
console.log(`Route: ${JSON.stringify({ action: route.action, jsonLogicQuery })}`);
return (
<Route
key={key}
config={config}
route={route}
moveUp={{
check: () => key !== 0,
fn: () => {
swap(key, key - 1);
},
}}
moveDown={{
// routes.length - 1 is fallback route always. So, routes.length - 2 is the last item that can be moved down
check: () => key !== routes.length - 2,
fn: () => {
swap(key, key + 1);
},
}}
routes={routes}
setRoute={setRoute}
setRoutes={setRoutes}
/>
);
})}
<Button
type="button"
className="mb-8"
color="secondary"
StartIcon={PlusIcon}
size="sm"
onClick={() => {
const newEmptyRoute = getEmptyRoute();
const newRoutes = [
...routes,
{
...newEmptyRoute,
state: {
tree: QbUtils.checkTree(QbUtils.loadTree(newEmptyRoute.queryValue), config),
config,
},
},
];
setRoutes(newRoutes);
}}>
Add New Route
</Button>
<div>
<Route
config={config}
route={fallbackRoute}
routes={routes}
setRoute={setRoute}
setRoutes={setRoutes}
/>
</div>
<div className="mt-4 flex justify-end space-x-2 rtl:space-x-reverse">
<Button href="/apps/routing_forms/forms" color="secondary" tabIndex={-1}>
{t("cancel")}
</Button>
<Button type="submit" disabled={mutation.isLoading}>
{t("update")}
</Button>
</div>
</form>
<SideBar form={form} appUrl={appUrl} />
</div>
);
};
export default function RouteBuilder({
form,
appUrl,
}: inferSSRProps<typeof getServerSideProps> & { appUrl: string }) {
return (
<RoutingShell appUrl={appUrl} heading={<PencilEdit value={form?.name} readOnly={true} />} form={form}>
<div className="route-config">
<Routes form={form} appUrl={appUrl} />
</div>
</RoutingShell>
);
}
export const getServerSideProps = async function getServerSideProps(
context: AppGetServerSidePropsContext,
prisma: AppPrisma,
user: AppUser
) {
if (!user) {
return {
redirect: {
permanent: false,
destination: "/auth/login",
},
};
}
const { params } = context;
if (!params) {
return {
notFound: true,
};
}
const formId = params.appPages[0];
if (!formId || params.appPages.length > 1) {
return {
notFound: true,
};
}
const form = await prisma.app_RoutingForms_Form.findUnique({
where: {
id: formId,
},
});
if (!form) {
return {
notFound: true,
};
}
return {
props: {
form: getSerializableForm(form),
},
};
};

View File

@ -0,0 +1,266 @@
import jsonLogic from "json-logic-js";
import { useRouter } from "next/router";
import { useState, useRef, FormEvent } from "react";
import { Utils as QbUtils } from "react-awesome-query-builder";
import { Toaster } from "react-hot-toast";
import { v4 as uuidv4 } from "uuid";
import showToast from "@calcom/lib/notification";
import { AppGetServerSidePropsContext, AppPrisma } from "@calcom/types/AppGetServerSideProps";
import { inferSSRProps } from "@calcom/types/inferSSRProps";
import { Button } from "@calcom/ui";
import { trpc } from "@calcom/web/lib/trpc";
import { getSerializableForm } from "../../utils";
import { getQueryBuilderConfig } from "../route-builder/[...appPages]";
export type Response = Record<
string,
{
value: string | string[];
label: string;
}
>;
type Form = inferSSRProps<typeof getServerSideProps>["form"];
type Route = NonNullable<Form["routes"]>[0];
function RoutingForm({ form }: inferSSRProps<typeof getServerSideProps>) {
const [customPageMessage, setCustomPageMessage] = useState<Route["action"]["value"]>("");
const formFillerIdRef = useRef(uuidv4());
// TODO: We might want to prevent spam from a single user by having same formFillerId across pageviews
// But technically, a user can fill form multiple times due to any number of reasons and we currently can't differentiate b/w that.
// - like a network error
// - or he abandoned booking flow in between
const formFillerId = formFillerIdRef.current;
const decidedActionRef = useRef<Route["action"]>();
const router = useRouter();
const onSubmit = (response: Response) => {
const decidedAction = processRoute({ form, response });
if (!decidedAction) {
// FIXME: Make sure that when a form is created, there is always a fallback route and then remove this.
alert("Define atleast 1 route");
return;
}
responseMutation.mutate({
formId: form.id,
formFillerId,
response: response,
});
decidedActionRef.current = decidedAction;
};
const responseMutation = trpc.useMutation("viewer.app_routing_forms.response", {
onSuccess: () => {
const decidedAction = decidedActionRef.current;
if (!decidedAction) {
return;
}
//TODO: Maybe take action after successful mutation
if (decidedAction.type === "customPageMessage") {
setCustomPageMessage(decidedAction.value);
} else if (decidedAction.type === "eventTypeRedirectUrl") {
router.push(`/${decidedAction.value}`);
} else if (decidedAction.type === "externalRedirectUrl") {
window.location.href = decidedAction.value;
}
showToast("Form submitted successfully! Redirecting now ...", "success");
},
onError: (e) => {
if (e?.message) {
return void showToast(e?.message, "error");
}
if (e?.data?.code === "CONFLICT") {
return void showToast("Form already submitted", "error");
}
showToast("Something went wrong", "error");
},
});
const [response, setResponse] = useState<Response>({});
const queryBuilderConfig = getQueryBuilderConfig(form);
const handleOnSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
onSubmit(response);
};
return !customPageMessage ? (
<div className="mx-auto my-0 max-w-3xl md:my-24">
<div className="w-full max-w-4xl ltr:mr-2 rtl:ml-2">
<div className="mx-0 rounded-sm border border-neutral-200 bg-white p-4 py-6 sm:-mx-4 sm:px-8">
<Toaster position="bottom-right" />
<form onSubmit={handleOnSubmit}>
<div className="mb-8">
<h1 className="font-cal mb-1 text-xl font-bold capitalize tracking-wide text-gray-900">
{form.name}
</h1>
{form.description ? (
<p className="min-h-10 text-sm text-neutral-500 ltr:mr-4 rtl:ml-4">{form.description}</p>
) : null}
</div>
{form.fields?.map((field) => {
const widget = queryBuilderConfig.widgets[field.type];
if (!("factory" in widget)) {
return null;
}
const Component = widget.factory;
const optionValues = field.selectText?.trim().split("\n");
const options = optionValues?.map((value) => {
const title = value;
return {
value,
title,
};
});
return (
<div key={field.id} className="mb-4 block flex-col sm:flex ">
<div className="min-w-48 mb-2 flex-grow">
<label
id="slug-label"
htmlFor="slug"
className="flex text-sm font-medium text-neutral-700">
{field.label}
</label>
</div>
<div className="flex rounded-sm shadow-sm">
<Component
value={response[field.id]?.value}
// required property isn't accepted by query-builder types
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
/* @ts-ignore */
required={!!field.required}
listValues={options}
setValue={(value) => {
setResponse((response) => {
response = response || {};
return {
...response,
[field.id]: {
label: field.label,
value,
},
};
});
}}
/>
</div>
</div>
);
})}
<div className="mt-4 flex justify-end space-x-2 rtl:space-x-reverse">
<Button
loading={responseMutation.isLoading}
type="submit"
className="dark:text-darkmodebrandcontrast text-brandcontrast bg-brand dark:bg-darkmodebrand relative inline-flex items-center rounded-sm border border-transparent px-3 py-2 text-sm font-medium hover:bg-opacity-90 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-1">
Submit
</Button>
</div>
</form>
</div>
</div>
</div>
) : (
<div className="mx-auto my-0 max-w-3xl md:my-24">
<div className="w-full max-w-4xl ltr:mr-2 rtl:ml-2">
<div className="-mx-4 rounded-sm border border-neutral-200 bg-white p-4 py-6 sm:mx-0 sm:px-8">
<div>{customPageMessage}</div>
</div>
</div>
</div>
);
}
function processRoute({ form, response }: { form: Form; response: Response }) {
const queryBuilderConfig = getQueryBuilderConfig(form);
const routes = form.routes || [];
let decidedAction: Route["action"] | null = null;
const fallbackRoute = routes.find((route) => route.isFallback);
if (!fallbackRoute) {
throw new Error("Fallback route is missing");
}
const reorderedRoutes = routes.filter((route) => !route.isFallback).concat([fallbackRoute]);
reorderedRoutes.some((route) => {
if (!route) {
return false;
}
const state = {
tree: QbUtils.checkTree(QbUtils.loadTree(route.queryValue), queryBuilderConfig),
config: queryBuilderConfig,
};
const jsonLogicQuery = QbUtils.jsonLogicFormat(state.tree, state.config);
const logic = jsonLogicQuery.logic;
let result = false;
const responseValues: Record<string, string | string[]> = {};
for (const [uuid, { value }] of Object.entries(response)) {
responseValues[uuid] = value;
}
if (logic) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
result = jsonLogic.apply(logic as any, responseValues);
} else {
// If no logic is provided, then consider it a match
result = true;
}
if (result) {
decidedAction = route.action;
return true;
}
});
return decidedAction;
}
export default function RoutingLink({ form }: { form: Form }) {
return <RoutingForm form={form} />;
}
export const getServerSideProps = async function getServerSideProps(
context: AppGetServerSidePropsContext,
prisma: AppPrisma
) {
const { params } = context;
if (!params) {
return {
notFound: true,
};
}
const formId = params.appPages[0];
if (!formId || params.appPages.length > 1) {
return {
notFound: true,
};
}
const form = await prisma.app_RoutingForms_Form.findUnique({
where: {
id: formId,
},
});
if (!form || form.disabled) {
return {
notFound: true,
};
}
return {
props: {
form: getSerializableForm(form),
},
};
};

View File

@ -0,0 +1,45 @@
import { Page, chromium } from "@playwright/test";
// TODO: Import it in _playwright/config/globalSetup.ts and export it from there.
import { loginAsUser } from "@calcom/app-store/_apps-playwright/config/globalSetup";
import { hashPassword } from "@calcom/lib/auth";
import prisma from "@lib/prisma";
async function installApp(appName: string, redirectUrl: string, page: Page) {
await page.goto(`${process.env.PLAYWRIGHT_TEST_BASE_URL}/apps/${appName}`);
await page.click('[data-testid="install-app-button"]');
await page.waitForNavigation({
url: (url) => {
return url.pathname == redirectUrl;
},
});
}
async function createUser(userName: string) {
const email = `${userName}@example.com`;
await prisma.user.create({
data: {
username: userName,
email,
completedOnboarding: true,
password: await hashPassword(userName),
},
});
}
async function globalSetup(/* config: FullConfig */) {
const browser = await chromium.launch({
headless: true,
});
const page = await browser.newPage();
const appName = "routing_forms";
const userName = `${appName}-e2e-${Math.random()}`;
process.env.APP_USER_NAME = userName;
await createUser(userName);
await loginAsUser(userName, page);
await installApp(appName, `/apps/${appName}/forms`, page);
page.context().close();
}
export default globalSetup;

View File

@ -0,0 +1,18 @@
import prisma from "@lib/prisma";
async function deleteUser(userName: string) {
await prisma.user.deleteMany({
where: {
AND: {
username: {
contains: userName,
},
},
},
});
}
async function globalTeardown(/* config: FullConfig */) {
await deleteUser("routing_forms-e2e");
}
export default globalTeardown;

View File

@ -0,0 +1,12 @@
import { expect, Config } from "@playwright/test";
import { config as baseConfig } from "@calcom/app-store/_apps-playwright/config/playwright.config";
const config: Config = {
...baseConfig,
globalSetup: require.resolve("./globalSetup"),
globalTeardown: require.resolve("./globalTeardown"),
};
expect.extend({});
export default config;

View File

@ -0,0 +1,5 @@
import { test as base } from "@playwright/test";
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface Fixtures {}
export const test = base.extend<Fixtures>({});

View File

@ -0,0 +1,12 @@
import prisma from "@calcom/web/lib/prisma";
export * from "@calcom/app-store/_apps-playwright/lib/testUtils";
export async function cleanUpForms() {
await prisma.app_RoutingForms_Form.deleteMany({
where: {
user: {
username: process.env.APP_USER_NAME,
},
},
});
}

View File

@ -0,0 +1,111 @@
import { expect, Page } from "@playwright/test";
import { test } from "../fixtures/fixtures";
import { cleanUpForms, todo } from "../lib/testUtils";
async function addForm(page: Page) {
await page.click('[data-testid="new-routing-form"]');
await page.waitForSelector('[data-testid="add-attribute"]');
}
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 fillForm(
page: Page,
form: { description: string; field?: { typeIndex: number; label: string } }
) {
await page.click('[data-testid="add-attribute"]');
await page.fill('[data-testid="description"]', form.description);
// Verify all Options of SelectBox
const { optionsInUi: types } = await verifySelectOptions(
{ selector: ".data-testid-attribute-type", nth: 0 },
["Email", "Long Text", "MultiSelect", "Number", "Phone", "Select", "Short Text"],
page
);
if (form.field) {
await page.fill('[name="fields.0.label"]', form.field.label);
await page.click(".data-testid-attribute-type");
await page.locator('[id*="react-select-"][aria-disabled]').nth(form.field.typeIndex).click();
}
await page.click('[data-testid="update-form"]');
await page.waitForSelector(".data-testid-toast-success");
return {
types,
};
}
test.use({ storageState: `playwright/artifacts/${process.env.APP_USER_NAME}StorageState.json` });
test.describe("Forms", () => {
test("should be able to add a new form and see it in forms list", async ({ page }) => {
page.goto("/");
await page.click('[href="/apps/routing_forms/forms"]');
await page.waitForSelector('[data-testid="empty-screen"]');
await addForm(page);
await page.click('[href="/apps/routing_forms/forms"]');
await page.waitForSelector('[data-testid="routing-forms-list"]');
expect(await page.locator('[data-testid="routing-forms-list"] > li').count()).toBe(1);
});
test("should be able to edit the form", async ({ page }) => {
await page.goto("/apps/routing_forms/forms");
await addForm(page);
const description = "Test Description";
const field = {
label: "Test Label",
typeIndex: 1,
};
const { types } = await fillForm(page, {
description,
field: field,
});
await page.reload();
expect(await page.inputValue(`[data-testid="description"]`), description);
expect(await page.locator('[data-testid="attribute"]').count()).toBe(1);
expect(await page.inputValue('[name="fields.0.label"]')).toBe(field.label);
expect(await page.locator(".data-testid-attribute-type").first().innerText()).toBe(
types[field.typeIndex]
);
await page.click('[href*="/apps/routing_forms/route-builder/"]');
await page.click('[data-testid="add-rule"]');
await verifySelectOptions(
{
selector: ".rule-container .data-testid-field-select",
nth: 0,
},
[field.label],
page
);
});
todo("Test Routing Link");
test.afterAll(() => {
cleanUpForms();
});
});

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M7 3a1 1 0 000 2h6a1 1 0 100-2H7zM4 7a1 1 0 011-1h10a1 1 0 110 2H5a1 1 0 01-1-1zM2 11a2 2 0 012-2h12a2 2 0 012 2v4a2 2 0 01-2 2H4a2 2 0 01-2-2v-4z" />
</svg>

After

Width:  |  Height:  |  Size: 265 B

View File

@ -0,0 +1,182 @@
import { Prisma } from "@prisma/client";
import { z } from "zod";
import { createProtectedRouter } from "@server/createRouter";
import { TRPCError } from "@trpc/server";
import { zodFields, zodRoutes } from "./zod";
const app_RoutingForms = createProtectedRouter()
.query("forms", {
async resolve({ ctx: { user, prisma } }) {
return await prisma.app_RoutingForms_Form.findMany({
where: {
userId: user.id,
},
orderBy: {
createdAt: "asc",
},
});
},
})
.query("form", {
input: z.object({
id: z.string(),
}),
async resolve({ ctx: { prisma }, input }) {
const form = await prisma.app_RoutingForms_Form.findFirst({
where: {
id: input.id,
},
});
return form;
},
})
.mutation("form", {
input: z.object({
id: z.string(),
name: z.string(),
description: z.string().nullable().optional(),
disabled: z.boolean().optional(),
fields: zodFields,
routes: zodRoutes,
}),
async resolve({ ctx: { user, prisma }, input }) {
const { name, id, routes, description, disabled } = input;
let { fields } = input;
fields = fields || [];
return await prisma.app_RoutingForms_Form.upsert({
where: {
id: id,
},
create: {
user: {
connect: {
id: user.id,
},
},
fields: fields,
name: name,
description,
// Prisma doesn't allow setting null value directly for JSON. It recommends using JsonNull for that case.
routes: routes === null ? Prisma.JsonNull : routes,
id: id,
},
update: {
disabled: disabled,
fields: fields,
name: name,
description,
routes: routes === null ? Prisma.JsonNull : routes,
},
});
},
})
// TODO: Can't se use DELETE method on form?
.mutation("deleteForm", {
input: z.object({
id: z.string(),
}),
async resolve({ ctx, input }) {
return await ctx.prisma.app_RoutingForms_Form.delete({
where: {
id: input.id,
},
});
},
})
.mutation("response", {
input: z.object({
formId: z.string(),
formFillerId: z.string(),
response: z.record(
z.object({
label: z.string(),
value: z.union([z.string(), z.array(z.string())]),
})
),
}),
async resolve({ ctx: { prisma }, input }) {
try {
const { response, formId } = input;
const form = await prisma.app_RoutingForms_Form.findFirst({
where: {
id: formId,
},
});
if (!form) {
throw new TRPCError({
code: "NOT_FOUND",
});
}
const fieldsParsed = zodFields.safeParse(form.fields);
if (!fieldsParsed.success) {
// This should not be possible normally as before saving the form it is verified by zod
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
});
}
const fields = fieldsParsed.data;
if (!fields) {
// There is no point in submitting a form that doesn't have fields defined
throw new TRPCError({
code: "BAD_REQUEST",
});
}
const missingFields = fields
.filter((field) => !(field.required ? response[field.id]?.value : true))
.map((f) => f.label);
if (missingFields.length) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Missing required fields ${missingFields.join(", ")}`,
});
}
const invalidFields = fields
.filter((field) => {
const fieldValue = response[field.id]?.value;
// The field isn't required at this point. Validate only if it's set
if (!fieldValue) {
return false;
}
let schema;
if (field.type === "email") {
schema = z.string().email();
} else if (field.type === "phone") {
schema = z.any();
} else {
schema = z.any();
}
return !schema.safeParse(fieldValue).success;
})
.map((f) => ({ label: f.label, type: f.type }));
if (invalidFields.length) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invalid fields ${invalidFields.map((f) => `${f.label}: ${f.type}`)}`,
});
}
return await prisma.app_RoutingForms_FormResponse.create({
data: input,
});
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === "P2002") {
throw new TRPCError({
code: "CONFLICT",
});
}
}
throw e;
}
},
});
export default app_RoutingForms;

View File

@ -0,0 +1,30 @@
import { App_RoutingForms_Form } from "@prisma/client";
import { zodFields, zodRoutes } from "./zod";
export function getSerializableForm<TForm extends App_RoutingForms_Form>(form: TForm) {
const routesParsed = zodRoutes.safeParse(form.routes);
if (!routesParsed.success) {
throw new Error("Error parsing routes");
}
const fieldsParsed = zodFields.safeParse(form.fields);
if (!fieldsParsed.success) {
throw new Error("Error parsing fields");
}
// Ideally we shouldb't have needed to explicitly type it but due to some reason it's not working reliably with VSCode TypeCheck
const serializableForm: Omit<TForm, "fields" | "routes" | "createdAt" | "updatedAt"> & {
fields: typeof fieldsParsed["data"];
routes: typeof routesParsed["data"];
createdAt: string;
updatedAt: string;
} = {
...form,
fields: fieldsParsed.data,
routes: routesParsed.data,
createdAt: form.createdAt.toString(),
updatedAt: form.updatedAt.toString(),
};
return serializableForm;
}

View File

@ -0,0 +1,39 @@
import { z } from "zod";
export const zodFields = z
.array(
z.object({
id: z.string(),
label: z.string(),
type: z.string(),
selectText: z.string().optional(),
required: z.boolean().optional(),
})
)
.optional();
export const zodRoutes = z
.union([
z.array(
z.object({
id: z.string(),
queryValue: z.object({
id: z.string().optional(),
type: z.union([z.literal("group"), z.literal("switch_group")]),
children1: z.any(),
properties: z.any(),
}),
isFallback: z.boolean().optional(),
action: z.object({
// TODO: Make it a union type of "customPageMessage" and ..
type: z.union([
z.literal("customPageMessage"),
z.literal("externalRedirectUrl"),
z.literal("eventTypeRedirectUrl"),
]),
value: z.string(),
}),
})
),
z.null(),
])
.optional();

View File

@ -5,6 +5,8 @@ import "./next-auth";
export declare module "next" {
interface NextApiRequest extends IncomingMessage {
// args is defined by /integrations/[...args] endpoint
query: Partial<{ [key: string]: string | string[] }> & { args: string[] };
session?: Session | null;
}
}

View File

@ -0,0 +1,4 @@
// TODO: Generate this file automatically. This supposed to contain trpc-routes from all the apps automatically imported here and then exported.
// Can't use this file right now as I am not able to figure out how to keep getting tRPC typesafety with merge calls done on already created router
// Till that time import routers from each app directly to core.
// import { Router } from "@trpc/server/dist/declarations/src/router";

View File

@ -1,8 +1,24 @@
{
"extends": "@calcom/tsconfig/react-library.json",
"include": [".", "@calcom/types"],
"exclude": ["dist", "build", "node_modules"],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@components/*": ["../../apps/web/components/*"],
"@lib/*": ["../../apps/web/lib/*"],
"@server/*": ["../../apps/web/server/*"],
"@prisma/client/*": ["@calcom/prisma/client/*"]
},
"resolveJsonModule": true
}
},
"include": [
"next-env.d.ts",
"@calcom/types",
"../../packages/types/*.d.ts",
"../../packages/types/next-auth.d.ts",
"**/*.ts",
"**/*.tsx",
"../../apps/web/server/**/*.ts",
"../../apps/web/server/**/*.tsx"
]
}

View File

@ -5,6 +5,12 @@ export type IntegrationOAuthCallbackState = {
};
export interface InstallAppButtonProps {
render: (renderProps: ButtonBaseProps) => JSX.Element;
render: (
renderProps:
| ButtonBaseProps & {
/** Tells that the default render component should be used */
useDefaultComponent?: boolean;
}
) => JSX.Element;
onChanged?: () => unknown;
}

View File

@ -11,6 +11,7 @@ export default function showToast(message: string, variant: "success" | "warning
color: "#fff",
boxShadow: "none",
},
className: "data-testid-toast-success",
});
break;
case "error":

View File

@ -0,0 +1,33 @@
-- CreateTable
CREATE TABLE "App_RoutingForms_Form" (
"id" TEXT NOT NULL,
"description" TEXT,
"routes" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"name" TEXT NOT NULL,
"fields" JSONB,
"userId" INTEGER NOT NULL,
"disabled" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "App_RoutingForms_Form_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "App_RoutingForms_FormResponse" (
"id" SERIAL NOT NULL,
"formFillerId" TEXT NOT NULL,
"formId" TEXT NOT NULL,
"response" JSONB NOT NULL,
CONSTRAINT "App_RoutingForms_FormResponse_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "App_RoutingForms_FormResponse_formFillerId_formId_key" ON "App_RoutingForms_FormResponse"("formFillerId", "formId");
-- AddForeignKey
ALTER TABLE "App_RoutingForms_Form" ADD CONSTRAINT "App_RoutingForms_Form_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "App_RoutingForms_FormResponse" ADD CONSTRAINT "App_RoutingForms_FormResponse_formId_fkey" FOREIGN KEY ("formId") REFERENCES "App_RoutingForms_Form"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -125,62 +125,64 @@ enum UserPermissionRole {
}
model User {
id Int @id @default(autoincrement())
username String? @unique
id Int @id @default(autoincrement())
username String? @unique
name String?
/// @zod.email()
email String @unique
email String @unique
emailVerified DateTime?
password String?
bio String?
avatar String?
timeZone String @default("Europe/London")
weekStart String @default("Sunday")
timeZone String @default("Europe/London")
weekStart String @default("Sunday")
// DEPRECATED - TO BE REMOVED
startTime Int @default(0)
endTime Int @default(1440)
startTime Int @default(0)
endTime Int @default(1440)
// </DEPRECATED>
bufferTime Int @default(0)
hideBranding Boolean @default(false)
bufferTime Int @default(0)
hideBranding Boolean @default(false)
theme String?
createdDate DateTime @default(now()) @map(name: "created")
createdDate DateTime @default(now()) @map(name: "created")
trialEndsAt DateTime?
eventTypes EventType[] @relation("user_eventtype")
eventTypes EventType[] @relation("user_eventtype")
credentials Credential[]
teams Membership[]
bookings Booking[]
schedules Schedule[]
defaultScheduleId Int?
selectedCalendars SelectedCalendar[]
completedOnboarding Boolean @default(false)
completedOnboarding Boolean @default(false)
locale String?
timeFormat Int? @default(12)
timeFormat Int? @default(12)
twoFactorSecret String?
twoFactorEnabled Boolean @default(false)
identityProvider IdentityProvider @default(CAL)
twoFactorEnabled Boolean @default(false)
identityProvider IdentityProvider @default(CAL)
identityProviderId String?
availability Availability[]
invitedTo Int?
plan UserPlan @default(TRIAL)
plan UserPlan @default(TRIAL)
webhooks Webhook[]
brandColor String @default("#292929")
darkBrandColor String @default("#fafafa")
brandColor String @default("#292929")
darkBrandColor String @default("#fafafa")
// the location where the events will end up
destinationCalendar DestinationCalendar?
away Boolean @default(false)
away Boolean @default(false)
// participate in dynamic group booking or not
allowDynamicBooking Boolean? @default(true)
allowDynamicBooking Boolean? @default(true)
/// @zod.custom(imports.userMetadata)
metadata Json?
verified Boolean? @default(false)
role UserPermissionRole @default(USER)
disableImpersonation Boolean @default(false)
impersonatedUsers Impersonations[] @relation("impersonated_user")
impersonatedBy Impersonations[] @relation("impersonated_by_user")
verified Boolean? @default(false)
role UserPermissionRole @default(USER)
disableImpersonation Boolean @default(false)
impersonatedUsers Impersonations[] @relation("impersonated_user")
impersonatedBy Impersonations[] @relation("impersonated_by_user")
apiKeys ApiKey[]
accounts Account[]
sessions Session[]
workflows Workflow[]
routingForms App_RoutingForms_Form[] @relation("routing-form")
Feedback Feedback[]
@@map(name: "users")
@ -488,6 +490,30 @@ model App {
ApiKey ApiKey[]
}
model App_RoutingForms_Form {
id String @id @default(cuid())
description String?
routes Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String
fields Json?
user User @relation("routing-form", fields: [userId], references: [id], onDelete: Cascade)
userId Int
responses App_RoutingForms_FormResponse[]
disabled Boolean @default(false)
}
model App_RoutingForms_FormResponse {
id Int @id @default(autoincrement())
formFillerId String @default(cuid())
form App_RoutingForms_Form @relation(fields: [formId], references: [id], onDelete: Cascade)
formId String
response Json
@@unique([formFillerId, formId])
}
model Feedback {
id Int @id @default(autoincrement())
date DateTime

View File

@ -1,6 +1,12 @@
[
{
"/*": "This file is auto-generated and managed by `yarn app-store`. Don't edit manually but it is to be committed",
"dirName": "routing_forms",
"categories": ["other"],
"slug": "routing_forms",
"type": "routing_forms_other"
},
{
"dirName": "whereby",
"categories": ["video"],
"slug": "whereby",

View File

@ -108,6 +108,7 @@ async function main() {
invite_link: process.env.ZAPIER_INVITE_LINK,
});
}
// Web3 apps
await createApp("huddle01", "huddle01video", ["web3", "video"], "huddle01_video");
await createApp("metamask", "metamask", ["web3"], "metamask_web3");

View File

@ -73,4 +73,6 @@ export interface App {
price?: number;
/** only required for "usage-based" billing. % of commission for paid bookings */
commission?: number;
licenseRequired?: boolean;
isProOnly?: boolean;
}

View File

@ -0,0 +1,16 @@
import { GetServerSidePropsContext, GetServerSidePropsResult } from "next";
import { CalendsoSessionUser } from "next-auth";
import prisma from "@calcom/web/lib/prisma";
export type AppUser = CalendsoSessionUser | undefined;
export type AppPrisma = typeof prisma;
export type AppGetServerSidePropsContext = GetServerSidePropsContext<{
appPages: string[];
}>;
export type AppGetServerSideProps = (
context: AppGetServerSidePropsContext,
prisma: AppPrisma,
user: AppUser
) => GetServerSidePropsResult;

15
packages/types/AppHandler.d.ts vendored Normal file
View File

@ -0,0 +1,15 @@
import { NextApiHandler } from "next";
import { Session } from "next-auth";
import { Credential } from "@calcom/prisma/client";
export type AppDeclarativeHandler = {
appType: string;
slug: string;
supportsMultipleInstalls: false;
handlerType: "add";
createCredential: (arg: { user: Session["user"]; appType: string; slug: string }) => Promise<Credential>;
supportsMultipleInstalls: boolean;
redirectUrl: string;
};
export type AppHandler = AppDeclarativeHandler | NextApiHandler;

View File

@ -0,0 +1,56 @@
import { Root as ToggleGroupPrimitive, Item as ToggleGroupItemPrimitive } from "@radix-ui/react-toggle-group";
import { useState } from "react";
import classNames from "@calcom/lib/classNames";
const boolean = (yesNo: "yes" | "no") => (yesNo === "yes" ? true : yesNo === "no" ? false : undefined);
const yesNo = (boolean?: boolean) => (boolean === true ? "yes" : boolean === false ? "no" : undefined);
export default function BooleanToggleGroup({
defaultValue = true,
value,
// eslint-disable-next-line @typescript-eslint/no-empty-function
onValueChange = () => {},
}: {
defaultValue?: boolean;
value?: boolean;
onValueChange?: (value?: boolean) => void;
}) {
// Maintain a state because it is not necessary that onValueChange the parent component would re-render. Think react-hook-form
// Also maintain a string as boolean isn't accepted as ToggleGroupPrimitive value
const [yesNoValue, setYesNoValue] = useState<"yes" | "no" | undefined>(yesNo(value));
if (!yesNoValue) {
setYesNoValue(yesNo(defaultValue));
onValueChange(defaultValue);
return null;
}
return (
<ToggleGroupPrimitive
value={yesNoValue}
type="single"
className="rounded-sm"
onValueChange={(yesNoValue: "yes" | "no") => {
setYesNoValue(yesNoValue);
onValueChange(boolean(yesNoValue));
}}>
<ToggleGroupItemPrimitive
className={classNames(
boolean(yesNoValue) ? "bg-gray-200" : "",
"border border-gray-300 py-2 px-3 text-sm"
)}
value="yes">
Yes
</ToggleGroupItemPrimitive>
<ToggleGroupItemPrimitive
className={classNames(
!boolean(yesNoValue) ? "bg-gray-200" : "",
"border border-l-0 border-gray-300 py-2 px-3 text-sm"
)}
value="no">
No
</ToggleGroupItemPrimitive>
</ToggleGroupPrimitive>
);
}

View File

@ -13,7 +13,9 @@ export default function EmptyScreen({
}) {
return (
<>
<div className="min-h-80 my-6 flex flex-col items-center justify-center rounded-sm border border-dashed">
<div
data-testid="empty-screen"
className="min-h-80 my-6 flex flex-col items-center justify-center rounded-sm border border-dashed">
<div className="flex h-[72px] w-[72px] items-center justify-center rounded-full bg-gray-600 dark:bg-white">
<Icon className="inline-block h-10 w-10 text-white dark:bg-white dark:text-gray-600" />
</div>

View File

@ -5,3 +5,4 @@ export { default as Loader } from "./Loader";
export * from "./skeleton";
export { default as Switch } from "./Switch";
export { default as Tooltip } from "./Tooltip";
export { default as BooleanToggleGroup } from "./BooleanToggleGroup";

View File

@ -2,7 +2,7 @@ import { loadEnvConfig } from "@next/env";
import { Browser, chromium } from "@playwright/test";
import fs from "fs";
async function loginAsUser(username: string, browser: Browser) {
export async function loginAsUser(username: string, browser: Browser) {
// Skip is file exists
if (fs.existsSync(`playwright/artifacts/${username}StorageState.json`)) return;
const page = await browser.newPage();

View File

@ -143,6 +143,7 @@
"outputs": ["../../../apps/web/public/embed/**"]
},
"embed-tests-update-snapshots:ci": {
"cache": false,
"dependsOn": [
"@calcom/prisma#db-seed",
"@calcom/web#build",
@ -151,6 +152,10 @@
"^embed-tests-update-snapshots:ci"
]
},
"app-e2e-quick": {
"cache": false,
"dependsOn": ["@calcom/prisma#db-seed", "@calcom/web#build", "^app-e2e-quick"]
},
"//#env-check:common": {
"inputs": ["./.env.example", "./.env"],
"outputs": ["./.env"]

375
yarn.lock
View File

@ -852,6 +852,18 @@
fast-equals "^1.6.3"
lodash "^4.17.15"
"@date-io/core@^1.3.13":
version "1.3.13"
resolved "https://registry.yarnpkg.com/@date-io/core/-/core-1.3.13.tgz#90c71da493f20204b7a972929cc5c482d078b3fa"
integrity sha512-AlEKV7TxjeK+jxWVKcCFrfYAk8spX9aCyiToFIiLPtfQbsjmRGLIhb5VZgptQcJdHtLXo7+m0DuurwFgUToQuA==
"@date-io/moment@^1.3.13":
version "1.3.13"
resolved "https://registry.yarnpkg.com/@date-io/moment/-/moment-1.3.13.tgz#56c2772bc4f6675fc6970257e6033e7a7c2960f0"
integrity sha512-3kJYusJtQuOIxq6byZlzAHoW/18iExJer9qfRF5DyyzdAk074seTuJfdofjz4RFfTd/Idk8WylOQpWtERqvFuQ==
dependencies:
"@date-io/core" "^1.3.13"
"@emotion/babel-plugin@^11.7.1":
version "11.7.2"
resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.7.2.tgz#fec75f38a6ab5b304b0601c74e2a5e77c95e5fa0"
@ -2157,11 +2169,6 @@
dependencies:
webpack-bundle-analyzer "4.3.0"
"@next/env@12.1.6":
version "12.1.6"
resolved "https://registry.yarnpkg.com/@next/env/-/env-12.1.6.tgz#5f44823a78335355f00f1687cfc4f1dafa3eca08"
integrity sha512-Te/OBDXFSodPU6jlXYPAXpmZr/AkG6DCATAxttQxqOWaq6eDFX25Db3dK0120GZrSZmv4QCe9KsZmJKDbWs4OA==
"@next/env@12.2.0":
version "12.2.0"
resolved "https://registry.yarnpkg.com/@next/env/-/env-12.2.0.tgz#17ce2d9f5532b677829840037e06f208b7eed66b"
@ -2172,11 +2179,6 @@
resolved "https://registry.yarnpkg.com/@next/env/-/env-12.2.1.tgz#083cc88469931fc3dc32bb633623321c29971a09"
integrity sha512-lz3TJKIvbdGRUsUr/+h3vy7XvBNGTGzHwhurk5AtqrABj4Zyo70xbshcI7YQTNUK4x9OA/E+SOcXvVx0DHmFRw==
"@next/env@12.2.2":
version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/env/-/env-12.2.2.tgz#cc1a0a445bd254499e30f632968c03192455f4cc"
integrity sha512-BqDwE4gDl1F608TpnNxZqrCn6g48MBjvmWFEmeX5wEXDXh3IkAOw6ASKUgjT8H4OUePYFqghDFUss5ZhnbOUjw==
"@next/eslint-plugin-next@12.1.6":
version "12.1.6"
resolved "https://registry.yarnpkg.com/@next/eslint-plugin-next/-/eslint-plugin-next-12.1.6.tgz#dde3f98831f15923b25244588d924c716956292e"
@ -2184,11 +2186,6 @@
dependencies:
glob "7.1.7"
"@next/swc-android-arm-eabi@12.1.6":
version "12.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-12.1.6.tgz#79a35349b98f2f8c038ab6261aa9cd0d121c03f9"
integrity sha512-BxBr3QAAAXWgk/K7EedvzxJr2dE014mghBSA9iOEAv0bMgF+MRq4PoASjuHi15M2zfowpcRG8XQhMFtxftCleQ==
"@next/swc-android-arm-eabi@12.2.0":
version "12.2.0"
resolved "https://registry.yarnpkg.com/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-12.2.0.tgz#f116756e668b267de84b76f068d267a12f18eb22"
@ -2199,16 +2196,6 @@
resolved "https://registry.yarnpkg.com/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-12.2.1.tgz#26a4363bd3857b934e7ad63aa1647d83b380ce1f"
integrity sha512-Gk7fvo1McA9gues9hixoeoxKnvvUusW0P+fya4ZAU3us+bQm1EqSoDrnOrUsdsgwIPQ3HobOJPY5C3xvKOl/tA==
"@next/swc-android-arm-eabi@12.2.2":
version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-12.2.2.tgz#f6c4111e6371f73af6bf80c9accb3d96850a92cd"
integrity sha512-VHjuCHeq9qCprUZbsRxxM/VqSW8MmsUtqB5nEpGEgUNnQi/BTm/2aK8tl7R4D0twGKRh6g1AAeFuWtXzk9Z/vQ==
"@next/swc-android-arm64@12.1.6":
version "12.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-android-arm64/-/swc-android-arm64-12.1.6.tgz#ec08ea61794f8752c8ebcacbed0aafc5b9407456"
integrity sha512-EboEk3ROYY7U6WA2RrMt/cXXMokUTXXfnxe2+CU+DOahvbrO8QSWhlBl9I9ZbFzJx28AGB9Yo3oQHCvph/4Lew==
"@next/swc-android-arm64@12.2.0":
version "12.2.0"
resolved "https://registry.yarnpkg.com/@next/swc-android-arm64/-/swc-android-arm64-12.2.0.tgz#cbd9e329cef386271d4e746c08416b5d69342c24"
@ -2219,16 +2206,6 @@
resolved "https://registry.yarnpkg.com/@next/swc-android-arm64/-/swc-android-arm64-12.2.1.tgz#28c7e964208e80d4b3ff791f323fbe425eae26fe"
integrity sha512-J+QwWRm2+bOtacZFahoplX3dCYGDpou86VjfcE+M5/E0UCtBmZ6JvItyV4scK8wSKHQQUWq8DmOEm/C0lhsSRQ==
"@next/swc-android-arm64@12.2.2":
version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-android-arm64/-/swc-android-arm64-12.2.2.tgz#b69de59c51e631a7600439e7a8993d6e82f3369e"
integrity sha512-v5EYzXUOSv0r9mO/2PX6mOcF53k8ndlu9yeFHVAWW1Dhw2jaJcvTRcCAwYYN8Q3tDg0nH3NbEltJDLKmcJOuVA==
"@next/swc-darwin-arm64@12.1.6":
version "12.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-12.1.6.tgz#d1053805615fd0706e9b1667893a72271cd87119"
integrity sha512-P0EXU12BMSdNj1F7vdkP/VrYDuCNwBExtRPDYawgSUakzi6qP0iKJpya2BuLvNzXx+XPU49GFuDC5X+SvY0mOw==
"@next/swc-darwin-arm64@12.2.0":
version "12.2.0"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-12.2.0.tgz#3473889157ba70b30ccdd4f59c46232d841744e2"
@ -2239,16 +2216,6 @@
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-12.2.1.tgz#ae68b105956c985214219d4f676b2e57c882d5ae"
integrity sha512-teSfpKHdHQER4FVVCdvS0fHff35Gh4LB2DZ2eNAateIluP2Gnl+tT881MeM4Knvl2Mvm3Z3vtSJNthVoveJnMA==
"@next/swc-darwin-arm64@12.2.2":
version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-12.2.2.tgz#80157c91668eff95b72d052428c353eab0fc4c50"
integrity sha512-JCoGySHKGt+YBk7xRTFGx1QjrnCcwYxIo3yGepcOq64MoiocTM3yllQWeOAJU2/k9MH0+B5E9WUSme4rOCBbpA==
"@next/swc-darwin-x64@12.1.6":
version "12.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-12.1.6.tgz#2d1b926a22f4c5230d5b311f9c56cfdcc406afec"
integrity sha512-9FptMnbgHJK3dRDzfTpexs9S2hGpzOQxSQbe8omz6Pcl7rnEp9x4uSEKY51ho85JCjL4d0tDLBcXEJZKKLzxNg==
"@next/swc-darwin-x64@12.2.0":
version "12.2.0"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-12.2.0.tgz#b25198c3ef4c906000af49e4787a757965f760bb"
@ -2259,11 +2226,6 @@
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-12.2.1.tgz#27da7988d01847b642b8d5c274f14bd82439fbb0"
integrity sha512-flA1H+9krrINtdWoXBzeESkdIV34OKX0+Lnqd90J1nsERTXntYy6CNOMxMtv1otAcnFy7EHYJQIL8URuu/2XXg==
"@next/swc-darwin-x64@12.2.2":
version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-12.2.2.tgz#12be2f58e676fccff3d48a62921b9927ed295133"
integrity sha512-dztDtvfkhUqiqpXvrWVccfGhLe44yQ5tQ7B4tBfnsOR6vxzI9DNPHTlEOgRN9qDqTAcFyPxvg86mn4l8bB9Jcw==
"@next/swc-freebsd-x64@12.2.0":
version "12.2.0"
resolved "https://registry.yarnpkg.com/@next/swc-freebsd-x64/-/swc-freebsd-x64-12.2.0.tgz#78e2213f8b703be0fef23a49507779b4a9842929"
@ -2274,16 +2236,6 @@
resolved "https://registry.yarnpkg.com/@next/swc-freebsd-x64/-/swc-freebsd-x64-12.2.1.tgz#0b4cd5c1707218cac86a7a58e116c74998da6286"
integrity sha512-SkAjp7B7aBxAsRVMZGiAp/qMkh65PLzYuLBTsBSu+4fxFuKF7MAEgaIUhvC8zzD58A+Y9yrY/3813bhtrwkcuA==
"@next/swc-freebsd-x64@12.2.2":
version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-freebsd-x64/-/swc-freebsd-x64-12.2.2.tgz#de1363431a49059f1efb8c0f86ce6a79c53b3a95"
integrity sha512-JUnXB+2xfxqsAvhFLPJpU1NeyDsvJrKoOjpV7g3Dxbno2Riu4tDKn3kKF886yleAuD/1qNTUCpqubTvbbT2VoA==
"@next/swc-linux-arm-gnueabihf@12.1.6":
version "12.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-12.1.6.tgz#c021918d2a94a17f823106a5e069335b8a19724f"
integrity sha512-PvfEa1RR55dsik/IDkCKSFkk6ODNGJqPY3ysVUZqmnWMDSuqFtf7BPWHFa/53znpvVB5XaJ5Z1/6aR5CTIqxPw==
"@next/swc-linux-arm-gnueabihf@12.2.0":
version "12.2.0"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-12.2.0.tgz#80a4baf0ba699357e7420e2dea998908dcef5055"
@ -2294,16 +2246,6 @@
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-12.2.1.tgz#3b93a18f1264a88985bc3a01e0067aa1afe0ab72"
integrity sha512-V7ov2LXrLWuYVH/syzrzpmwWumg5rCh0siwOPNCRzVkrpgP8WoIRNdeZ/NQIj0ng+kq7gDF1jib583Lk0wbDeQ==
"@next/swc-linux-arm-gnueabihf@12.2.2":
version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-12.2.2.tgz#d5b8e0d1bb55bbd9db4d2fec018217471dc8b9e6"
integrity sha512-XeYC/qqPLz58R4pjkb+x8sUUxuGLnx9QruC7/IGkK68yW4G17PHwKI/1njFYVfXTXUukpWjcfBuauWwxp9ke7Q==
"@next/swc-linux-arm64-gnu@12.1.6":
version "12.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-12.1.6.tgz#ac55c07bfabde378dfa0ce2b8fc1c3b2897e81ae"
integrity sha512-53QOvX1jBbC2ctnmWHyRhMajGq7QZfl974WYlwclXarVV418X7ed7o/EzGY+YVAEKzIVaAB9JFFWGXn8WWo0gQ==
"@next/swc-linux-arm64-gnu@12.2.0":
version "12.2.0"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-12.2.0.tgz#134a42ddea804d6bf04761607f774432c3126de6"
@ -2314,16 +2256,6 @@
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-12.2.1.tgz#9887a772f96680afa440ac3e6f716fd20d7f4178"
integrity sha512-HlnDQD3r4YqCj2gu6uo86oEM0ixBsyKLaPcZcGwWAD5mFG5R4zzTZG7BO2wJkGWmkzijHluE14dlTmfzc8jdEQ==
"@next/swc-linux-arm64-gnu@12.2.2":
version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-12.2.2.tgz#3bc75984e1d5ec8f59eb53702cc382d8e1be2061"
integrity sha512-d6jT8xgfKYFkzR7J0OHo2D+kFvY/6W8qEo6/hmdrTt6AKAqxs//rbbcdoyn3YQq1x6FVUUd39zzpezZntg9Naw==
"@next/swc-linux-arm64-musl@12.1.6":
version "12.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-12.1.6.tgz#e429f826279894be9096be6bec13e75e3d6bd671"
integrity sha512-CMWAkYqfGdQCS+uuMA1A2UhOfcUYeoqnTW7msLr2RyYAys15pD960hlDfq7QAi8BCAKk0sQ2rjsl0iqMyziohQ==
"@next/swc-linux-arm64-musl@12.2.0":
version "12.2.0"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-12.2.0.tgz#c781ac642ad35e0578d8a8d19c638b0f31c1a334"
@ -2334,16 +2266,6 @@
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-12.2.1.tgz#7ed5981b7afd3d9c4678ff36e1dd7f06a5f0c3d6"
integrity sha512-P8AkWd4RHbuF24ol3jk2akXpntcDI0gv5uD7eMpAOXb8W2A6y/sv0tKNSGUV3efSutOyu23jNn2EiTNxHgU4NQ==
"@next/swc-linux-arm64-musl@12.2.2":
version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-12.2.2.tgz#270db73e07a18d999f61e79a917943fa5bc1ef56"
integrity sha512-rIZRFxI9N/502auJT1i7coas0HTHUM+HaXMyJiCpnY8Rimbo0495ir24tzzHo3nQqJwcflcPTwEh/DV17sdv9A==
"@next/swc-linux-x64-gnu@12.1.6":
version "12.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-12.1.6.tgz#1f276c0784a5ca599bfa34b2fcc0b38f3a738e08"
integrity sha512-AC7jE4Fxpn0s3ujngClIDTiEM/CQiB2N2vkcyWWn6734AmGT03Duq6RYtPMymFobDdAtZGFZd5nR95WjPzbZAQ==
"@next/swc-linux-x64-gnu@12.2.0":
version "12.2.0"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-12.2.0.tgz#0e2235a59429eadd40ac8880aec18acdbc172a31"
@ -2354,16 +2276,6 @@
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-12.2.1.tgz#0bb3e5162b189cb4d88761ff1781896781c7bd65"
integrity sha512-ZbsM+rIMqK6xi3lovspzPJoIPre3LglKrCXKLkln7rD0uiymzfLhS2VCj8u4qRynz22iAzuI4mJNpZa3AsJFrA==
"@next/swc-linux-x64-gnu@12.2.2":
version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-12.2.2.tgz#e6c72fa20478552e898c434f4d4c0c5e89d2ea78"
integrity sha512-ir1vNadlUDj7eQk15AvfhG5BjVizuCHks9uZwBfUgT5jyeDCeRvaDCo1+Q6+0CLOAnYDR/nqSCvBgzG2UdFh9A==
"@next/swc-linux-x64-musl@12.1.6":
version "12.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-12.1.6.tgz#1d9933dd6ba303dcfd8a2acd6ac7c27ed41e2eea"
integrity sha512-c9Vjmi0EVk0Kou2qbrynskVarnFwfYIi+wKufR9Ad7/IKKuP6aEhOdZiIIdKsYWRtK2IWRF3h3YmdnEa2WLUag==
"@next/swc-linux-x64-musl@12.2.0":
version "12.2.0"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-12.2.0.tgz#b0a10db0d9e16f079429588a58f71fa3c3d46178"
@ -2374,16 +2286,6 @@
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-12.2.1.tgz#64e983e38a5e86bc613bfc46e0b92a1787ba5392"
integrity sha512-JeATguMe37bviPwkIUjO7T3kcefMBQwJFLhkFTaJYGmPm12EsW1FtKcg87AI87xdGvfrHQKlM3phNaG/dkneTQ==
"@next/swc-linux-x64-musl@12.2.2":
version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-12.2.2.tgz#b9ef9efe2c401839cdefa5e70402386aafdce15a"
integrity sha512-bte5n2GzLN3O8JdSFYWZzMgEgDHZmRz5wiispiiDssj4ik3l8E7wq/czNi8RmIF+ioj2sYVokUNa/ekLzrESWw==
"@next/swc-win32-arm64-msvc@12.1.6":
version "12.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-12.1.6.tgz#2ef9837f12ca652b1783d72ecb86208906042f02"
integrity sha512-3UTOL/5XZSKFelM7qN0it35o3Cegm6LsyuERR3/OoqEExyj3aCk7F025b54/707HTMAnjlvQK3DzLhPu/xxO4g==
"@next/swc-win32-arm64-msvc@12.2.0":
version "12.2.0"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-12.2.0.tgz#3063f850c9db7b774c69e9be74ad59986cf6fc34"
@ -2394,16 +2296,6 @@
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-12.2.1.tgz#2394b05230f0011a01010524e25d8f4ec71e27e1"
integrity sha512-8dal/MdrVshDKYBtloJw/RhJx140KUoRRYoRfpJ9oAdP8UXBdR0haKfg5EdOy98t8Q76apArxPsK7DfwoR1f3w==
"@next/swc-win32-arm64-msvc@12.2.2":
version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-12.2.2.tgz#18fa7ec7248da3a7926a0601d9ececc53ac83157"
integrity sha512-ZUGCmcDmdPVSAlwJ/aD+1F9lYW8vttseiv4n2+VCDv5JloxiX9aY32kYZaJJO7hmTLNrprvXkb4OvNuHdN22Jg==
"@next/swc-win32-ia32-msvc@12.1.6":
version "12.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-12.1.6.tgz#74003d0aa1c59dfa56cb15481a5c607cbc0027b9"
integrity sha512-8ZWoj6nCq6fI1yCzKq6oK0jE6Mxlz4MrEsRyu0TwDztWQWe7rh4XXGLAa2YVPatYcHhMcUL+fQQbqd1MsgaSDA==
"@next/swc-win32-ia32-msvc@12.2.0":
version "12.2.0"
resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-12.2.0.tgz#001bbadf3d2cf006c4991f728d1d23e4d5c0e7cc"
@ -2414,16 +2306,6 @@
resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-12.2.1.tgz#90acd18e63e7620992ee3f7d3dec80ccc7120f9e"
integrity sha512-uSAoOBpCp4oxVD9gTY1f27hr9xNLEOCglxZPH1+FonHpM5n9Sp4H01uQHWE/Y26iHmJeUJAWxtRxEYylnO4U9A==
"@next/swc-win32-ia32-msvc@12.2.2":
version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-12.2.2.tgz#54936e84f4a219441d051940354da7cd3eafbb4f"
integrity sha512-v7ykeEDbr9eXiblGSZiEYYkWoig6sRhAbLKHUHQtk8vEWWVEqeXFcxmw6LRrKu5rCN1DY357UlYWToCGPQPCRA==
"@next/swc-win32-x64-msvc@12.1.6":
version "12.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-12.1.6.tgz#a350caf42975e7197b24b495b8d764eec7e6a36e"
integrity sha512-4ZEwiRuZEicXhXqmhw3+de8Z4EpOLQj/gp+D9fFWo6ii6W1kBkNNvvEx4A90ugppu+74pT1lIJnOuz3A9oQeJA==
"@next/swc-win32-x64-msvc@12.2.0":
version "12.2.0"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-12.2.0.tgz#9f66664f9122ca555b96a5f2fc6e2af677bf801b"
@ -2434,11 +2316,6 @@
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-12.2.1.tgz#f3b186c8f7278656c7690a64f362d0d5b1d738af"
integrity sha512-gx4aLMAZAVjtShiCrUSszoxnzBWJWf09Lkey6mcc0jFZjbz4xkyDbp53V229DtOYTUL4t0IZJ0I7+ftQ5CYIjg==
"@next/swc-win32-x64-msvc@12.2.2":
version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-12.2.2.tgz#7460be700a60d75816f01109400b51fe929d7e89"
integrity sha512-2D2iinWUL6xx8D9LYVZ5qi7FP6uLAoWymt8m8aaG2Ld/Ka8/k723fJfiklfuAcwOxfufPJI+nRbT5VcgHGzHAQ==
"@node-redis/client@^1.0.1":
version "1.0.6"
resolved "https://registry.yarnpkg.com/@node-redis/client/-/client-1.0.6.tgz#de8bfe6cfdc5781f0021ce9d18a11c821c948d9d"
@ -3041,6 +2918,29 @@
"@radix-ui/react-use-previous" "0.1.1"
"@radix-ui/react-use-size" "0.1.1"
"@radix-ui/react-toggle-group@^0.1.5":
version "0.1.5"
resolved "https://registry.yarnpkg.com/@radix-ui/react-toggle-group/-/react-toggle-group-0.1.5.tgz#9e4d65e22c4fc0ba3a42fbc8d5496c430e5e9852"
integrity sha512-Yp14wFiqe00azF+sG5CCJz4JGOP/f5Jj+CxLlZCmMpG5qhVTWeaeG4YH6pvX4KL41fS8x9FAaLb8wW9y01o67g==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "0.1.0"
"@radix-ui/react-context" "0.1.1"
"@radix-ui/react-primitive" "0.1.4"
"@radix-ui/react-roving-focus" "0.1.5"
"@radix-ui/react-toggle" "0.1.4"
"@radix-ui/react-use-controllable-state" "0.1.0"
"@radix-ui/react-toggle@0.1.4":
version "0.1.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-toggle/-/react-toggle-0.1.4.tgz#c5c63f7cc5a03556bb58e0a763735b41bb0331f9"
integrity sha512-gxUq6NgMc4ChV8VJnwdYqueeoblspwXHAexYo+jM9N2hFLbI1C587jLjdTHzIcUa9q68Xaw4jtiImWDOokEhRw==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "0.1.0"
"@radix-ui/react-primitive" "0.1.4"
"@radix-ui/react-use-controllable-state" "0.1.0"
"@radix-ui/react-tooltip@^0.1.0":
version "0.1.7"
resolved "https://registry.yarnpkg.com/@radix-ui/react-tooltip/-/react-tooltip-0.1.7.tgz#6f8c00d6e489565d14abf209ce0fb8853c8c8ee3"
@ -3144,6 +3044,20 @@
dependencies:
"@babel/runtime" "^7.13.10"
"@reach/observe-rect@^1.1.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@reach/observe-rect/-/observe-rect-1.2.0.tgz#d7a6013b8aafcc64c778a0ccb83355a11204d3b2"
integrity sha512-Ba7HmkFgfQxZqqaeIWWkNK0rEhpxVQHIoVyW1YDSkGsGIXzcaW4deC8B0pZrNSSyLTdIk7y+5olKt5+g0GmFIQ==
"@reach/portal@^0.16.0":
version "0.16.2"
resolved "https://registry.yarnpkg.com/@reach/portal/-/portal-0.16.2.tgz#ca83696215ee03acc2bb25a5ae5d8793eaaf2f64"
integrity sha512-9ur/yxNkuVYTIjAcfi46LdKUvH0uYZPfEp4usWcpt6PIp+WDF57F/5deMe/uGi/B/nfDweQu8VVwuMVrCb97JQ==
dependencies:
"@reach/utils" "0.16.0"
tiny-warning "^1.0.3"
tslib "^2.3.0"
"@reach/skip-nav@^0.11.2":
version "0.11.2"
resolved "https://registry.yarnpkg.com/@reach/skip-nav/-/skip-nav-0.11.2.tgz#015498b2125ad8ef1e48cb8ab33dca93925fcbc8"
@ -3161,6 +3075,14 @@
tslib "^2.0.0"
warning "^4.0.3"
"@reach/utils@0.16.0":
version "0.16.0"
resolved "https://registry.yarnpkg.com/@reach/utils/-/utils-0.16.0.tgz#5b0777cf16a7cab1ddd4728d5d02762df0ba84ce"
integrity sha512-PCggBet3qaQmwFNcmQ/GqHSefadAFyNCUekq9RrWoaU9hh/S4iaFgf2MBMdM47eQj5i/Bk0Mm07cP/XPFlkN+Q==
dependencies:
tiny-warning "^1.0.3"
tslib "^2.3.0"
"@rollup/pluginutils@^4.2.1":
version "4.2.1"
resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-4.2.1.tgz#e6c6c3aba0744edce3fb2074922d3776c0af2a6d"
@ -3858,6 +3780,11 @@
resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-4.0.5.tgz#738dd390a6ecc5442f35e7f03fa1431353f7e138"
integrity sha512-FhpRzf927MNQdRZP0J5DLIdTXhjLYzeUTmLAu69mnVksLH9CJY3IuSeEgbKUki7GQZm0WqDkGzyxju2EZGD2wA==
"@types/json-logic-js@^1.2.1":
version "1.2.1"
resolved "https://registry.yarnpkg.com/@types/json-logic-js/-/json-logic-js-1.2.1.tgz#064e777b77b0fcb77f00c2c50fec1387cb33eb47"
integrity sha512-g/g+wj/7sgazpiCHiyAtndoNiy/LodLkNG4I9MILAl0UinKKwv3GiPKbtvcE1hIoezQqgDamXfx8Lht62/hHqw==
"@types/json-schema@*", "@types/json-schema@^7.0.6", "@types/json-schema@^7.0.7", "@types/json-schema@^7.0.9":
version "7.0.11"
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3"
@ -4015,6 +3942,11 @@
dependencies:
"@types/react" "*"
"@types/react-gtm-module@^2.0.1":
version "2.0.1"
resolved "https://registry.yarnpkg.com/@types/react-gtm-module/-/react-gtm-module-2.0.1.tgz#b2c6cd14ec251d6ae7fa576edf1d43825908a378"
integrity sha512-T/DN9gAbCYk5wJ1nxf4pSwmXz4d1iVjM++OoG+mwMfz9STMAotGjSb65gJHOS5bPvl6vLSsJnuC+y/43OQrltg==
"@types/react-phone-number-input@^3.0.13":
version "3.0.13"
resolved "https://registry.yarnpkg.com/@types/react-phone-number-input/-/react-phone-number-input-3.0.13.tgz#4eb7dcd278dcf9eb2a8d2ce2cb304657cbf1b4e5"
@ -5942,6 +5874,11 @@ clone@^1.0.2:
resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e"
integrity sha1-2jCcwmPfFZlMaIypAheco8fNfH4=
clone@^2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f"
integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==
clsx@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.1.1.tgz#98b3134f9abbdf23b2663491ace13c5c03a73188"
@ -6042,6 +5979,11 @@ comma-separated-tokens@^2.0.0:
resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-2.0.2.tgz#d4c25abb679b7751c880be623c1179780fe1dd98"
integrity sha512-G5yTt3KQN4Yn7Yk4ed73hlZ1evrFKXeUW3086p3PRFNp7m2vIjI6Pg+Kgb+oyzhd9F2qdcoj67+y3SdxL5XWsg==
command-score@^0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/command-score/-/command-score-0.1.2.tgz#b986ad7e8c0beba17552a56636c44ae38363d381"
integrity sha512-VtDvQpIJBvBatnONUsPzXYFVKQQAhuf3XTNOAsdBxCNO/QCtUUd8LSgjn0GVarBkCad6aJCZfXgrjYbl/KRr7w==
commander@6.2.0:
version "6.2.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.0.tgz#b990bfb8ac030aedc6d11bc04d1488ffef56db75"
@ -6807,6 +6749,11 @@ dotenv@^10.0.0:
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-10.0.0.tgz#3d4227b8fb95f81096cdd2b66653fb2c7085ba81"
integrity sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==
dotenv@^16.0.1:
version "16.0.1"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.0.1.tgz#8f8f9d94876c35dac989876a5d3a82a267fdce1d"
integrity sha512-1K6hR6wtk2FviQ4kEiSjFiH5rpzEVi8WW0x96aztHVMhEspNpc4DVOUTEHtEva5VThQ8IaBX1Pe4gSzpVVUsKQ==
dotenv@^8.2.0:
version "8.6.0"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.6.0.tgz#061af664d19f7f4d8fc6e4ff9b584ce237adcb8b"
@ -8001,6 +7948,11 @@ fast-equals@^1.6.3:
resolved "https://registry.yarnpkg.com/fast-equals/-/fast-equals-1.6.3.tgz#84839a1ce20627c463e1892f2ae316380c81b459"
integrity sha512-4WKW0AL5+WEqO0zWavAfYGY1qwLsBgE//DN4TTcVEN2UlINgkv9b3vm2iHicoenWKSX9mKWmGOsU/iI5IST7pQ==
fast-equals@^2.0.3:
version "2.0.4"
resolved "https://registry.yarnpkg.com/fast-equals/-/fast-equals-2.0.4.tgz#3add9410585e2d7364c2deeb6a707beadb24b927"
integrity sha512-caj/ZmjHljPrZtbzJ3kfH5ia/k4mTJe/qSiXAGzxZWRZgsgDV0cvNaQULqUX8t0/JVlzzEdYOwCN5DmzTxoD4w==
fast-glob@^3.2.11, fast-glob@^3.2.7, fast-glob@^3.2.9:
version "3.2.11"
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.11.tgz#a1172ad95ceb8a16e20caa5c5e56480e5129c1d9"
@ -9299,7 +9251,7 @@ immer@^9.0.12:
resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.15.tgz#0b9169e5b1d22137aba7d43f8a81a495dd1b62dc"
integrity sha512-2eB/sswms9AEUSkOm4SbV5Y7Vmt/bKRwByd52jfLkW4OLYeaTP3EEiJ9agqU0O/tq6Dk62Zfj+TJSqfm1rLVGQ==
immutable@^3.x.x:
immutable@^3.8.2, immutable@^3.x.x:
version "3.8.2"
resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.8.2.tgz#c2439951455bb39913daf281376f1530e104adf3"
integrity sha1-wkOZUUVbs5kT2vKBN28VMOEErfM=
@ -10961,6 +10913,11 @@ json-buffer@3.0.0:
resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.0.tgz#5b1f397afc75d677bde8bcfc0e47e1f9a3d9a898"
integrity sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=
json-logic-js@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/json-logic-js/-/json-logic-js-2.0.2.tgz#b613e095f5e598cb78f7b9a2bbf638e74cf98158"
integrity sha512-ZBtBdMJieqQcH7IX/LaBsr5pX+Y5JIW+EhejtM3Ffg2jdN9Iwf+Ht6TbHnvAZ/YtwyuhPaCBlnvzrwVeWdvGDQ==
json-parse-better-errors@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9"
@ -11126,6 +11083,17 @@ jws@^4.0.0:
jwa "^2.0.0"
safe-buffer "^5.0.1"
kbar@^0.1.0-beta.36:
version "0.1.0-beta.36"
resolved "https://registry.yarnpkg.com/kbar/-/kbar-0.1.0-beta.36.tgz#29f21337979b84ffdceb8a319d80fe89b5461936"
integrity sha512-i5tU7VYkMmxHCoyG5qzkNeU3qViKBz2F0fjqvWWSKsgVABCF3BjxzAH570Mhn3Zy92x3NGZae8emkBpEk7MKgw==
dependencies:
"@reach/portal" "^0.16.0"
command-score "^0.1.2"
fast-equals "^2.0.3"
react-virtual "^2.8.2"
tiny-invariant "^1.2.0"
keccak@^3.0.0:
version "3.0.2"
resolved "https://registry.yarnpkg.com/keccak/-/keccak-3.0.2.tgz#4c2c6e8c54e04f2670ee49fa734eb9da152206e0"
@ -12659,21 +12627,6 @@ next-api-middleware@^1.0.1:
dependencies:
debug "^4.3.2"
next-auth@^4.3.3:
version "4.10.0"
resolved "https://registry.yarnpkg.com/next-auth/-/next-auth-4.10.0.tgz#cffa850cb0f633e6340d34c567634df1d10feabe"
integrity sha512-4CKZbv9VeCaqfDAXyqFThZy05ApbLd0bhXEB+DCq9aD43h6Rkvz0QgM7QOCJXESy0QKJUXHzopkBq+iaGxdc0g==
dependencies:
"@babel/runtime" "^7.16.3"
"@panva/hkdf" "^1.0.1"
cookie "^0.4.1"
jose "^4.3.7"
oauth "^0.9.15"
openid-client "^5.1.0"
preact "^10.6.3"
preact-render-to-string "^5.1.19"
uuid "^8.3.2"
next-auth@^4.9.0:
version "4.9.0"
resolved "https://registry.yarnpkg.com/next-auth/-/next-auth-4.9.0.tgz#0d8cabcb22a976744131a2e68d5f08756f322593"
@ -12689,6 +12642,13 @@ next-auth@^4.9.0:
preact-render-to-string "^5.1.19"
uuid "^8.3.2"
next-axiom@^0.10.0:
version "0.10.0"
resolved "https://registry.yarnpkg.com/next-axiom/-/next-axiom-0.10.0.tgz#7cd2f52d9691cf9f7984ed325d58a6f93912eed3"
integrity sha512-QrOUqNmJ20StiR0b+/HMiW0o0w442DjfaOg4yH3hNJmAX0c9Afy6hiZ/j9D67XmqlpXeg83ESx89rt83u4/giA==
dependencies:
whatwg-fetch "^3.6.2"
next-collect@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/next-collect/-/next-collect-0.2.0.tgz#62ec8f5c263cd8bd6e1da26b5d456e072c6f6e4d"
@ -12769,29 +12729,6 @@ next-validations@^0.2.0:
resolved "https://registry.yarnpkg.com/next-validations/-/next-validations-0.2.0.tgz#ce3c4bc332b115beda633521fd81e587987864eb"
integrity sha512-QMF2hRNSSbjeBaCYqpt3mEM9CkXXzaMCWCvPyi5/vKTBjbgkiYtaQnUfjj5eH8dX+ZmRrBYGgN1EKqL7ZnI0wQ==
next@12.1.6:
version "12.1.6"
resolved "https://registry.yarnpkg.com/next/-/next-12.1.6.tgz#eb205e64af1998651f96f9df44556d47d8bbc533"
integrity sha512-cebwKxL3/DhNKfg9tPZDQmbRKjueqykHHbgaoG4VBRH3AHQJ2HO0dbKFiS1hPhe1/qgc2d/hFeadsbPicmLD+A==
dependencies:
"@next/env" "12.1.6"
caniuse-lite "^1.0.30001332"
postcss "8.4.5"
styled-jsx "5.0.2"
optionalDependencies:
"@next/swc-android-arm-eabi" "12.1.6"
"@next/swc-android-arm64" "12.1.6"
"@next/swc-darwin-arm64" "12.1.6"
"@next/swc-darwin-x64" "12.1.6"
"@next/swc-linux-arm-gnueabihf" "12.1.6"
"@next/swc-linux-arm64-gnu" "12.1.6"
"@next/swc-linux-arm64-musl" "12.1.6"
"@next/swc-linux-x64-gnu" "12.1.6"
"@next/swc-linux-x64-musl" "12.1.6"
"@next/swc-win32-arm64-msvc" "12.1.6"
"@next/swc-win32-ia32-msvc" "12.1.6"
"@next/swc-win32-x64-msvc" "12.1.6"
next@12.2.0:
version "12.2.0"
resolved "https://registry.yarnpkg.com/next/-/next-12.2.0.tgz#aef47cd96b602bc1307d1dcf9a1ee3e753845544"
@ -12818,32 +12755,6 @@ next@12.2.0:
"@next/swc-win32-ia32-msvc" "12.2.0"
"@next/swc-win32-x64-msvc" "12.2.0"
next@^12.1.6:
version "12.2.2"
resolved "https://registry.yarnpkg.com/next/-/next-12.2.2.tgz#029bf5e4a18a891ca5d05b189b7cd983fd22c072"
integrity sha512-zAYFY45aBry/PlKONqtlloRFqU/We3zWYdn2NoGvDZkoYUYQSJC8WMcalS5C19MxbCZLUVCX7D7a6gTGgl2yLg==
dependencies:
"@next/env" "12.2.2"
"@swc/helpers" "0.4.2"
caniuse-lite "^1.0.30001332"
postcss "8.4.5"
styled-jsx "5.0.2"
use-sync-external-store "1.1.0"
optionalDependencies:
"@next/swc-android-arm-eabi" "12.2.2"
"@next/swc-android-arm64" "12.2.2"
"@next/swc-darwin-arm64" "12.2.2"
"@next/swc-darwin-x64" "12.2.2"
"@next/swc-freebsd-x64" "12.2.2"
"@next/swc-linux-arm-gnueabihf" "12.2.2"
"@next/swc-linux-arm64-gnu" "12.2.2"
"@next/swc-linux-arm64-musl" "12.2.2"
"@next/swc-linux-x64-gnu" "12.2.2"
"@next/swc-linux-x64-musl" "12.2.2"
"@next/swc-win32-arm64-msvc" "12.2.2"
"@next/swc-win32-ia32-msvc" "12.2.2"
"@next/swc-win32-x64-msvc" "12.2.2"
next@^12.2.0:
version "12.2.1"
resolved "https://registry.yarnpkg.com/next/-/next-12.2.1.tgz#b487dc598ef1373a1b1275d68531a7088fe5653d"
@ -13857,6 +13768,18 @@ playwright-core@1.22.1:
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.22.1.tgz#59ddf903546171fdfd9c3dc189630c883619667c"
integrity sha512-H+ZUVYnceWNXrRf3oxTEKAr81QzFsCKu5Fp//fEjQvqgKkfA1iX3E9DBrPJpPNOrgVzcE+IqeI0fDmYJe6Ynnw==
playwright-core@1.22.2:
version "1.22.2"
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.22.2.tgz#ed2963d79d71c2a18d5a6fd25b60b9f0a344661a"
integrity sha512-w/hc/Ld0RM4pmsNeE6aL/fPNWw8BWit2tg+TfqJ3+p59c6s3B6C8mXvXrIPmfQEobkcFDc+4KirNzOQ+uBSP1Q==
playwright@^1.22.2:
version "1.22.2"
resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.22.2.tgz#353a7c29f89ca9600edc7a9a30aed790823c797d"
integrity sha512-hUTpg7LytIl3/O4t0AQJS1V6hWsaSY5uZ7w1oCC8r3a1AQN5d6otIdCkiB3cbzgQkcMaRxisinjMFMVqZkybdQ==
dependencies:
playwright-core "1.22.2"
pngjs@^3.0.0, pngjs@^3.3.3:
version "3.4.0"
resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-3.4.0.tgz#99ca7d725965fb655814eaf65f38f12bbdbf555f"
@ -14356,6 +14279,23 @@ raw-body@2.5.1:
iconv-lite "0.4.24"
unpipe "1.0.0"
react-awesome-query-builder@^5.1.2:
version "5.1.2"
resolved "https://registry.yarnpkg.com/react-awesome-query-builder/-/react-awesome-query-builder-5.1.2.tgz#2a8e34e4558275471069ca5b39d73113e748cf84"
integrity sha512-qh+vcu0Cgo1OaGS6uNiXNSNd2ORUGAtXUhwqqoGuI1LqXwXBQgKVBKg3/uaVL7T7BK8LYvJQiKu94S9+C9Fh3Q==
dependencies:
"@date-io/moment" "^1.3.13"
classnames "^2.3.1"
clone "^2.1.2"
immutable "^3.8.2"
lodash "^4.17.21"
moment "^2.29.1"
prop-types "^15.7.2"
react-redux "^7.2.2"
redux "^4.1.0"
spel2js "^0.2.8"
sqlstring "^2.3.2"
react-calendar@^3.3.1:
version "3.7.0"
resolved "https://registry.yarnpkg.com/react-calendar/-/react-calendar-3.7.0.tgz#951d56e91afb33b1c1e019cb790349fbffcc6894"
@ -14453,6 +14393,11 @@ react-fit@^1.4.0:
prop-types "^15.6.0"
tiny-warning "^1.0.0"
react-gtm-module@^2.0.11:
version "2.0.11"
resolved "https://registry.yarnpkg.com/react-gtm-module/-/react-gtm-module-2.0.11.tgz#14484dac8257acd93614e347c32da9c5ac524206"
integrity sha512-8gyj4TTxeP7eEyc2QKawEuQoAZdjKvMY4pgWfycGmqGByhs17fR+zEBs0JUDq4US/l+vbTl+6zvUIx27iDo/Vw==
react-hook-form@^7.16.2:
version "7.33.1"
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.33.1.tgz#8c4410e3420788d3b804d62cc4c142915c2e46d0"
@ -14582,7 +14527,7 @@ react-reconciler@^0.26.2:
object-assign "^4.1.1"
scheduler "^0.20.2"
react-redux@^7.2.4:
react-redux@^7.2.2, react-redux@^7.2.4:
version "7.2.8"
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.8.tgz#a894068315e65de5b1b68899f9c6ee0923dd28de"
integrity sha512-6+uDjhs3PSIclqoCk0kd6iX74gzrGc3W5zcAjbrFgEdIjRSQObdIwfx80unTkVUYvbQ95Y8Av3OvFHq1w5EOUw==
@ -14702,6 +14647,13 @@ react-use-intercom@1.5.1:
resolved "https://registry.yarnpkg.com/react-use-intercom/-/react-use-intercom-1.5.1.tgz#94567a80ce3b56692962d712a54489c55fb4c54e"
integrity sha512-rsSiW3j6yv0bBWCaX+VOCK/ndh/VzntlYjxHLHhV++iQtFurFhcRD249rF07ZaLH8ZP5SR6FzP3Alqdi3usBQg==
react-virtual@^2.8.2:
version "2.10.4"
resolved "https://registry.yarnpkg.com/react-virtual/-/react-virtual-2.10.4.tgz#08712f0acd79d7d6f7c4726f05651a13b24d8704"
integrity sha512-Ir6+oPQZTVHfa6+JL9M7cvMILstFZH/H3jqeYeKI4MSUX+rIruVwFC6nGVXw9wqAw8L0Kg2KvfXxI85OvYQdpQ==
dependencies:
"@reach/observe-rect" "^1.1.0"
react-virtualized-auto-sizer@^1.0.6:
version "1.0.6"
resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.6.tgz#66c5b1c9278064c5ef1699ed40a29c11518f97ca"
@ -14836,6 +14788,13 @@ redux@^4.0.0, redux@^4.1.2:
dependencies:
"@babel/runtime" "^7.9.2"
redux@^4.1.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/redux/-/redux-4.2.0.tgz#46f10d6e29b6666df758780437651eeb2b969f13"
integrity sha512-oSBmcKKIuIR4ME29/AeNUnl5L+hvBq7OaJWzaptTQJAntaPvxIJqfnjbaEiCzzaIz+XmVILfqAM3Ob0aXLPfjA==
dependencies:
"@babel/runtime" "^7.9.2"
reflect-metadata@0.1.13, reflect-metadata@^0.1.13:
version "0.1.13"
resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.1.13.tgz#67ae3ca57c972a2aa1642b10fe363fe32d49dc08"
@ -15754,6 +15713,11 @@ spdx-license-ids@^3.0.0:
resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.11.tgz#50c0d8c40a14ec1bf449bae69a0ea4685a9d9f95"
integrity sha512-Ctl2BrFiM0X3MANYgj3CkygxhRmr9mi6xhejbdO960nF6EDJApTYpn0BQnDKlnNBULKiCN1n3w9EBkHK8ZWg+g==
spel2js@^0.2.8:
version "0.2.8"
resolved "https://registry.yarnpkg.com/spel2js/-/spel2js-0.2.8.tgz#3ba3b291e5c6bae5c9f703e839294969b61fc691"
integrity sha512-dzYq+v4YV7SPIdNrmvFAUjc0HcgI7b0yoMw7kzOBmlj/GjdOb/+8dVn1I7nLuOS5X2SW+LK3tf2SVkXRjCkWBA==
split-string@^3.0.1, split-string@^3.0.2:
version "3.1.0"
resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2"
@ -16440,7 +16404,12 @@ timm@^1.6.1:
resolved "https://registry.yarnpkg.com/timm/-/timm-1.7.1.tgz#96bab60c7d45b5a10a8a4d0f0117c6b7e5aff76f"
integrity sha512-IjZc9KIotudix8bMaBW6QvMuq64BrJWFs1+4V0lXwWGQZwH+LnX87doAYhem4caOEusRP9/g6jVDQmZ8XOk1nw==
tiny-warning@^1.0.0:
tiny-invariant@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.2.0.tgz#a1141f86b672a9148c72e978a19a73b9b94a15a9"
integrity sha512-1Uhn/aqw5C6RI4KejVeTg6mIS7IqxnLJ8Mv2tV5rTc0qWobay7pDUz6Wi392Cnc8ak1H0F2cjoRzb2/AW4+Fvg==
tiny-warning@^1.0.0, tiny-warning@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754"
integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==
@ -17832,7 +17801,7 @@ whatwg-encoding@^1.0.5:
dependencies:
iconv-lite "0.4.24"
whatwg-fetch@^3.4.1:
whatwg-fetch@^3.4.1, whatwg-fetch@^3.6.2:
version "3.6.2"
resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.6.2.tgz#dced24f37f2624ed0281725d51d0e2e3fe677f8c"
integrity sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA==