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>pull/3367/head^2
parent
7ec5f01647
commit
58d1c28e9d
|
@ -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
|
|
@ -22,10 +22,6 @@
|
|||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
},
|
||||
// Try start the task on folder open
|
||||
"runOptions": {
|
||||
"runOn": "folderOpen"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 && (
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -19,6 +19,7 @@ export default function AllApps({ apps }: { apps: App[] }) {
|
|||
logo={app.logo}
|
||||
rating={app.rating}
|
||||
reviews={app.reviews}
|
||||
isProOnly={app.isProOnly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -30,6 +30,7 @@ const TrendingAppsSlider = <T extends App>({ items }: { items: T[] }) => {
|
|||
logo={app.logo}
|
||||
rating={app.rating}
|
||||
reviews={app.reviews}
|
||||
isProOnly={app.isProOnly}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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?,
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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` });
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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} />}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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 = `/**
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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` });
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export * from "../../../../apps/web/playwright/lib/testUtils";
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -0,0 +1,2 @@
|
|||
export { default as add } from "./add";
|
||||
export { default as responses } from "./responses/[formId]";
|
|
@ -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();
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
|
@ -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've shared the link with will no
|
||||
longer be able to book using it.
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
export { default as InstallAppButton } from "./InstallAppButton";
|
||||
export { default as Icon } from "./icon";
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
|
@ -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
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
declare module "react-awesome-query-builder/lib/config/basic";
|
|
@ -0,0 +1,3 @@
|
|||
export * as api from "./api";
|
||||
export * as components from "./components";
|
||||
export { metadata } from "./_metadata";
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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),
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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),
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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),
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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>({});
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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 |
|
@ -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;
|
|
@ -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;
|
||||
}
|
|
@ -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();
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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";
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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":
|
||||
|
|
|
@ -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;
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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
375
yarn.lock
|
@ -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==
|
||||
|
|
Loading…
Reference in New Issue