Compare commits

...
38 Commits
Author SHA1 Message Date
Peer Richelsen de446db434 v2.4.3
release.yml / release (push) Canceled after 0s
2023-01-04 21:00:14 +01:00
81546412bf add missing variables for variable translations (#6271)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-01-04 19:57:47 +00:00
Peer Richelsen 459530fba3 Revert "sidebar improvements" (#6273) 2023-01-04 15:46:42 +00:00
Marlo Kesser 42766b42c0 fix localization issues with default locations (#5569) 2023-01-04 15:31:50 +00:00
Hariom Balhara 38b3563350 Feature/Routing Forms: Add the concepts of reusable Router and Router Linked Fields. (#5566)
* WIP

* Fix all TS errors

* Move to using isRouter everywhere

* wip

* Add Router Picker and fix TS issues

* Fix tests and add option to connect during duplication

* Misc changes

* Fixes

* Fixes

* Fix TS errors

* Disable certain options in Router

* Fix connected Form fields not updating

* Some fixes

* reert shell.tsx

* Disable already used router instead of removing it

* Import prisma from correct place

* Simplify loop using Object.entries

* SimpliAdd playwright test and fix issues found
2023-01-04 13:30:46 +00:00
Jeroen Reumkens 37159914cf 🧹 Removed old root UI components. (#6249)
* Removed old root UI components.

* Fixed import path

* Changed icon to not import from root, since it somehow creates a recursive import

* Removed irrelevant todo

* Fixed merge issues.

* Fixed shell export
2023-01-04 14:05:00 +01:00
Peer Richelsen 268bf68532 sidebar improvements (#6259)
* added bottom-0 to sidebar nav

* open sidebar on right for RTL
2023-01-04 11:15:30 +00:00
sean-brydon b02c7e01d5 Move Apps to ui/components (#6245)
* Move Apps to components

* Update packages/features/ee/sso/page/teams-sso-view.tsx
2023-01-04 11:00:01 +00:00
45d10a3bc4 a11y: right-to-left improvements (#6235)
* fixed a ton of rtl issues

* removed duplicant

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2023-01-04 07:38:45 +00:00
Jeff LoiselleandPeer Richelsen 9250b20c1d fixes available times overflow bug on safari (#6208)
* fixes available times overflow bug on safari

* WIP

* remove extranous grid classes

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-01-03 22:05:40 +00:00
sean-brydon d9da311141 Move Skeleton to ui/components (#6247)
* Inital Move

* Skeleton story
2023-01-03 15:54:00 +00:00
Nafees Nazik 89a52dad21 fix: cutoff text and align text (#6248) 2023-01-03 14:12:36 +00:00
Hariom Balhara 2a1acc1294 Fix lang and dir attributes not being set (#6243) 2023-01-03 11:09:05 +01:00
8302990f84 fix: use hasMounted to render calcom versionm (#6239)
* fix: use hasMounted to render calcom versionm

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* added Credits component but struggling with import

* only made it work with v2 folder. sorry jerone

* added link to releases

* added link to cal.com

* removing script calcomversion for now until we need it

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Peer Richelsen <peer@cal.com>
2023-01-02 19:44:51 +00:00
LoneRifleandPeer Richelsen 5a03b61d63 refactor(web): remove fonts.css, rely on shared-globals.css (#6220)
`styles/fonts.css` is identical to `shared-globals.css`, as found in
`packages/ui/styles`, and as referenced by `styles/globals.css`. Remove
`fonts.css` and its references, leaning instead on the fonts described
in `shared-globals.css`

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-01-02 19:11:06 +00:00
github-actions[bot]andCrowdin Bot 6b6f239936 New Crowdin translations by Github Action (#6242)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2023-01-02 19:50:31 +01:00
Jeff LoiselleandPeer Richelsen 9c2748e8e9 adds team prefix to url when creating team (#6211)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-01-02 18:34:32 +00:00
Peer Richelsen bb5cc33737 Update README.md 2023-01-02 18:05:53 +01:00
Udit TakkarandPeer Richelsen 5616a64827 fix: use border-0 (#6233)
Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-01-02 15:10:00 +00:00
33530ad8c8 Make cal.com version available on all pages, even on login page. (#6221)
* Make cal.com version available on all pages, even on login page.

* Extract out calcom version as a utility

* added version to login screen

* added ee and ce to version

* fixed lint

* Fix import

* removed  version info component

Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-01-02 20:24:35 +05:30
Peer Richelsen fe14839f1b fixed sidebar position, added toHide to <LicenseRequired> (#6228)
* fixed sidebar position

* fixed sidebar position

* fixed sidebar position

* removed gradient overlay from user dropdown

* added toHide prop to license check to hide a component if a license is available

* show children if toHide is true
2023-01-02 10:13:27 +00:00
Hariom Balharaandkodiakhq[bot] 15123c1aa5 Add disable success page config[Platform Only] (#6225)
* Add disable success page config

* Change text

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2023-01-02 08:41:39 +00:00
Peer Richelsen 71ee9c72fe introduced size="fab" prop to Button, added to CTA buttons (#6176)
* introduced size="fab" prop to Button, added to CTA buttons

* increased size of FAB

* nit
2023-01-02 09:28:12 +01:00
github-actions[bot]andCrowdin Bot 5503539856 New Crowdin translations by Github Action (#6222)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2023-01-01 13:32:19 +01:00
sean-brydon 6548f01efa Show admin password top banner (#6226)
* Invalid-admin fake role

* Reset password link + shell
2023-01-01 11:19:58 +00:00
Udit TakkarandPeer Richelsen 518a4484d6 fix: add allowScroll prop (#6215)
Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2022-12-30 18:16:37 +00:00
Siddhant KhareandPeer Richelsen 3535a25a59 fix: consistency on menu hover border bug 🔧 (#6218)
Signed-off-by: Siddhant Khare <Siddhantkhare2694@gmail.com>

Signed-off-by: Siddhant Khare <Siddhantkhare2694@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2022-12-30 18:04:54 +00:00
Stefano Maffeis 06cc6741e6 Make radio field id unique by adding custom input id to their identifier (#6224) 2022-12-30 18:52:03 +01:00
Peer Richelsen ed1478e1ba Update README.md 2022-12-30 10:17:31 +01:00
Jeff Loiselle 854c05d673 fixes required custom radio element on firefox (#6209)
Fixes https://github.com/calcom/cal.com/issues/6160
2022-12-28 19:43:18 +00:00
bcf5fb18c5 Cal Video (Daily) Recording (#6039)
* feat: wip

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* feat: add download recording button

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* moved video recording into /ee/, wrapped in LicenseRequired

* fix: security issues in downloading recording
updates designs

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* feat: add upgradation banner

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* chore: remove default room

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* chore: fix type error

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: add type in get recording

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: add suggestions and zod type for recording

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: add types in getRecordings

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: type error

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: type error

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: finally all type errors fixed

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* Server side validation for users in team plans for recordings

* fix: remove any type

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: type error

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: Alan <alannnc@gmail.com>
2022-12-27 21:03:39 +00:00
3f822e814b event type setting refactor (#5959)
Co-authored-by: alannnc <alannnc@gmail.com>
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2022-12-26 10:55:58 +00:00
Peer Richelsen 4fd25c7215 fixed booking layout (#6188) 2022-12-26 16:11:28 +05:30
github-actions[bot]andCrowdin Bot f42bed2a55 New Crowdin translations by Github Action (#6175)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2022-12-26 11:25:47 +01:00
469c205033 fix: setting sidebar improvements (#6177)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Peer Richelsen <peer@cal.com>
2022-12-26 11:23:03 +01:00
Nafees Nazik 31551f3b9e fix: button focus (#6178) 2022-12-26 09:44:43 +00:00
Carina WollendorferandCarinaWolli 69925af17c Fix skeleton loader for /workflows (#6172)
* return latest bookings for example bookings for zapier

* remove mt on skeleton loader

* Revert "return latest bookings for example bookings for zapier"

This reverts commit b69f393a73a59a05fe99f01d8caa4ac0fd5cf992.

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2022-12-23 12:27:40 +00:00
Carina WollendorferandCarinaWolli 2502868ebc Return latest bookings for zapier example bookings (#6170)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2022-12-23 12:00:03 +01:00
231 changed files with 2987 additions and 9495 deletions
+3 -4
View File
@@ -1,8 +1,7 @@
<!-- PROJECT LOGO -->
<p align="center">
<a href="https://github.com/calcom/cal.com">
<img src="https://user-images.githubusercontent.com/8019099/133430653-24422d2a-3c8d-4052-9ad6-0580597151ee.png" alt="Logo">
<img src="https://user-images.githubusercontent.com/8019099/210054112-5955e812-a76e-4160-9ddd-58f2c72f1cce.png" alt="Logo">
</a>
<h3 align="center">Cal.com (formerly Calendso)</h3>
@@ -37,7 +36,7 @@
<a href="https://hub.docker.com/r/calendso/calendso"><img src="https://img.shields.io/docker/pulls/calendso/calendso"></a>
<a href="https://twitter.com/calcom"><img src="https://img.shields.io/twitter/follow/calcom?style=flat"></a>
<a href="https://twitch.tv/calcomtv"><img src="https://img.shields.io/twitch/status/calcomtv?style=flat"></a>
<a href="https://github.com/orgs/calcom/projects/9"><img src="https://img.shields.io/badge/Help%20Wanted-Contribute-blue"></a>
<a href="https://github.com/calcom/cal.com/issues?q=is:issue+is:open+label:%22%F0%9F%99%8B%F0%9F%8F%BB%E2%80%8D%E2%99%82%EF%B8%8Fhelp+wanted%22"><img src="https://img.shields.io/badge/Help%20Wanted-Contribute-blue"></a>
<a href="https://cal.com/figma"><img src="https://img.shields.io/badge/Figma-Design%20System-blueviolet"></a>
<a href="https://calendso.slack.com/archives/C02BY67GMMW"><img src="https://img.shields.io/badge/translations-contribute-brightgreen" /></a>
<a href="https://www.contributor-covenant.org/version/1/4/code-of-conduct/ "><img src="https://img.shields.io/badge/Contributor%20Covenant-1.4-purple" /></a>
@@ -310,7 +309,7 @@ Please see our [contributing guide](/CONTRIBUTING.md).
### Good First Issues
We have a list of [help wanted](https://github.com/orgs/calcom/projects/1/views/25) that contain small features and bugs which have a relatively limited scope. This is a great place to get started, gain experience, and get familiar with our contribution process.
We have a list of [help wanted](https://github.com/calcom/cal.com/issues?q=is:issue+is:open+label:%22%F0%9F%99%8B%F0%9F%8F%BB%E2%80%8D%E2%99%82%EF%B8%8Fhelp+wanted%22) that contain small features and bugs which have a relatively limited scope. This is a great place to get started, gain experience, and get familiar with our contribution process.
## Integrations
+1 -2
View File
@@ -14,14 +14,13 @@ Cal.com uses [TailwindCSS](https://tailwindcss.com) as a replacement for traditi
CSS files should be stored in the `styles` directory within the codebase.
Within the `styles` directory, you will find two CSS files, `fonts.css` and `global.css`. We suggest not to add to these files, and instead create new CSS files and import them into the application. This helps reduce conflicts when pulling in changes that we've made to either of the existing CSS files.
Within the `styles` directory, you will find a single CSS file, `global.css`. We suggest not to add to this file, and instead create new CSS files and import them into the application. This helps reduce conflicts when pulling in changes that we've made to either of the existing CSS files.
## Adding new stylesheets
Firstly, create the CSS file inside the `styles` directory.
Then, open the `pages/_app.tsx` file, and you will see the following two lines:
```javascript
import "../styles/fonts.css";
import "../styles/globals.css";
```
+3 -3
View File
@@ -639,7 +639,7 @@ const ChooseEmbedTypesDialogContent = () => {
<div className="flex items-start">
{embeds.map((embed, index) => (
<button
className="mr-2 w-1/3 border border-transparent p-3 text-left hover:rounded-md hover:border-gray-200 hover:bg-neutral-100"
className="w-1/3 border border-transparent p-3 text-left hover:rounded-md hover:border-gray-200 hover:bg-neutral-100 ltr:mr-2 rtl:ml-2"
key={index}
data-testid={embed.type}
onClick={() => {
@@ -914,7 +914,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
<div
className={classNames(
"mt-4 flex items-center justify-start",
embedType === "floating-popup" ? "space-x-2" : "hidden"
embedType === "floating-popup" ? "space-x-2 rtl:space-x-reverse" : "hidden"
)}>
<Switch
defaultChecked={true}
@@ -1010,7 +1010,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-6 text-sm">
<div className="mb-4 flex items-center justify-start space-x-2">
<div className="mb-4 flex items-center justify-start space-x-2 rtl:space-x-reverse">
<Switch
checked={previewState.hideEventTypeDetails}
onCheckedChange={(checked) => {
+6 -1
View File
@@ -1,6 +1,7 @@
import { useTranslation } from "next-i18next";
import { useEffect } from "react";
import { getDirFromLang } from "@calcom/lib/i18n";
import { trpc } from "@calcom/trpc/react";
export function useViewerI18n() {
@@ -21,12 +22,16 @@ export function useViewerI18n() {
*/
const I18nLanguageHandler = (): null => {
const { i18n } = useTranslation("common");
const locale = useViewerI18n().data?.locale;
const locale = useViewerI18n().data?.locale || "en";
useEffect(() => {
if (locale && i18n.language && i18n.language !== locale) {
if (typeof i18n.changeLanguage === "function") i18n.changeLanguage(locale);
}
const dir = getDirFromLang(locale);
document.documentElement.setAttribute("lang", locale);
document.documentElement.setAttribute("dir", dir);
}, [locale, i18n]);
return null;
@@ -15,7 +15,7 @@ import {
List,
ShellSubHeading,
showToast,
SkeletonLoader,
AppSkeletonLoader as SkeletonLoader,
Switch,
} from "@calcom/ui";
@@ -56,7 +56,7 @@ function IntegrationListItem(props: {
<ListItemText component="p">{props.description}</ListItemText>
{/* Alert error that key stopped working. */}
{props.invalidCredential && (
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2 rtl:space-x-reverse">
<Icon.FiAlertCircle className="w-8 text-red-500 sm:w-4" />
<ListItemText component="p" className="whitespace-pre-wrap text-red-500">
{t("invalid_credential")}
@@ -1,10 +1,13 @@
import { getEventLocationType, locationKeyToString } from "@calcom/app-store/locations";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Icon, Tooltip } from "@calcom/ui";
import { Props } from "./pages/AvailabilityPage";
export function AvailableEventLocations({ locations }: { locations: Props["eventType"]["locations"] }) {
const { t } = useLocale();
return locations.length ? (
<div className="dark:text-darkgray-600 mr-6 flex w-full flex-col space-y-2 break-words text-sm text-gray-600">
{locations.map((location) => {
@@ -16,19 +19,19 @@ export function AvailableEventLocations({ locations }: { locations: Props["event
return (
<div key={location.type} className="flex flex-row items-center text-sm font-medium">
{eventLocationType.iconUrl === "/link.svg" ? (
<Icon.FiLink className="dark:text-darkgray-600 mr-[10px] ml-[2px] h-4 w-4 opacity-70 dark:opacity-100 " />
<Icon.FiLink className="dark:text-darkgray-600 ml-[2px] h-4 w-4 opacity-70 ltr:mr-[10px] rtl:ml-[10px] dark:opacity-100 " />
) : (
<img
src={eventLocationType.iconUrl}
className={classNames(
"mr-[10px] ml-[2px] h-4 w-4 opacity-70 dark:opacity-100 ",
"ml-[2px] h-4 w-4 opacity-70 ltr:mr-[10px] rtl:ml-[10px] dark:opacity-100 ",
!eventLocationType.iconUrl?.includes("api") ? "dark:invert" : ""
)}
alt={`${eventLocationType.label} icon`}
/>
)}
<Tooltip content={locationKeyToString(location)}>
<p className="truncate">{locationKeyToString(location)}</p>
<Tooltip content={t(locationKeyToString(location) ?? "")}>
<p className="truncate">{t(locationKeyToString(location) ?? "")}</p>
</Tooltip>
</div>
);
@@ -71,7 +71,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
/>
</div>
</div>
<div className="-mb-5 grid flex-grow grid-cols-1 gap-x-2 overflow-y-auto sm:block md:h-[364px]">
<div className="flex-grow overflow-y-auto sm:block md:h-[364px]">
{slots.length > 0 &&
slots.map((slot) => {
type BookingURL = {
@@ -95,7 +95,7 @@ const BookingDescription: FC<Props> = (props) => {
<div>
<Icon.FiInfo
className={classNames(
"mr-[10px] ml-[2px] inline-block h-4 w-4",
"ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]",
isBookingPage && "dark:text-darkgray-600 -mt-1 text-gray-500"
)}
/>
@@ -112,7 +112,7 @@ const BookingDescription: FC<Props> = (props) => {
isBookingPage && "dark:text-darkgray-600 text-sm font-medium text-gray-600"
)}>
<div>
<Icon.FiCheckSquare className="mr-[10px] ml-[2px] inline-block h-4 w-4 " />
<Icon.FiCheckSquare className="ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px] " />
</div>
{requiresConfirmationText}
</div>
@@ -127,7 +127,7 @@ const BookingDescription: FC<Props> = (props) => {
)}>
<Icon.FiClock
className={classNames(
"min-h-4 min-w-4 mr-[10px] ml-[2px] inline-block",
"min-h-4 min-w-4 ml-[2px] inline-block ltr:mr-[10px] rtl:ml-[10px]",
isBookingPage && "mt-[2px]"
)}
/>
@@ -4,6 +4,7 @@ import { useState } from "react";
import { EventLocationType, getEventLocationType } from "@calcom/app-store/locations";
import dayjs from "@calcom/dayjs";
import ViewRecordingsDialog from "@calcom/features/ee/video/ViewRecordingsDialog";
import classNames from "@calcom/lib/classNames";
import { formatTime } from "@calcom/lib/date-fns";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -22,8 +23,9 @@ import {
showToast,
TextArea,
Tooltip,
ActionType,
TableActions,
} from "@calcom/ui";
import { ActionType, TableActions } from "@calcom/ui";
import useMeQuery from "@lib/hooks/useMeQuery";
@@ -48,6 +50,7 @@ function BookingListItem(booking: BookingItemProps) {
const router = useRouter();
const [rejectionReason, setRejectionReason] = useState<string>("");
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
const [viewRecordingsDialogIsOpen, setViewRecordingsDialogIsOpen] = useState<boolean>(false);
const mutation = trpc.viewer.bookings.confirm.useMutation({
onSuccess: (data) => {
if (data.status === BookingStatus.REJECTED) {
@@ -112,6 +115,17 @@ function BookingListItem(booking: BookingItemProps) {
},
];
const showRecordingActions: ActionType[] = [
{
id: "view_recordings",
label: t("view_recordings"),
onClick: () => {
setViewRecordingsDialogIsOpen(true);
},
disabled: mutation.isLoading,
},
];
let bookedActions: ActionType[] = [
{
id: "cancel",
@@ -206,7 +220,7 @@ function BookingListItem(booking: BookingItemProps) {
},
});
};
const showRecordingsButtons = booking.location === "integrations:daily" && isPast && isConfirmed;
return (
<>
<RescheduleDialog
@@ -220,7 +234,14 @@ function BookingListItem(booking: BookingItemProps) {
isOpenDialog={isOpenSetLocationDialog}
setShowLocationModal={setIsOpenLocationDialog}
/>
{showRecordingsButtons && (
<ViewRecordingsDialog
booking={booking}
isOpenDialog={viewRecordingsDialogIsOpen}
setIsOpenDialog={setViewRecordingsDialogIsOpen}
timeFormat={user?.timeFormat ?? null}
/>
)}
{/* NOTE: Should refactor this dialog component as is being rendered multiple times */}
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
<DialogContent>
@@ -342,7 +363,7 @@ function BookingListItem(booking: BookingItemProps) {
<span> </span>
{!!booking?.eventType?.price && !booking.paid && (
<Badge className="hidden ltr:ml-2 rtl:mr-2 sm:inline-flex" variant="orange">
<Badge className="hidden ltr:ml-2 ltr:mr-2 rtl:ml-2 sm:inline-flex" variant="orange">
{t("pending_payment")}
</Badge>
)}
@@ -377,6 +398,7 @@ function BookingListItem(booking: BookingItemProps) {
</>
) : null}
{isPast && isPending && !isConfirmed ? <TableActions actions={bookedActions} /> : null}
{showRecordingsButtons && <TableActions actions={showRecordingActions} />}
{isCancelled && booking.rescheduled && (
<div className="hidden h-full items-center md:flex">
<RequestSentMessage />
@@ -221,7 +221,7 @@ function TimezoneDropdown({
<Popover.Root open={isTimeOptionsOpen} onOpenChange={setIsTimeOptionsOpen}>
<Popover.Trigger className="min-w-32 dark:text-darkgray-600 radix-state-open:bg-gray-200 dark:radix-state-open:bg-darkgray-200 group relative mb-2 -ml-2 !mt-2 inline-block self-start rounded-md px-2 py-2 text-left text-gray-600">
<p className="flex items-center text-sm font-medium">
<Icon.FiGlobe className="min-h-4 min-w-4 mr-[10px] ml-[2px] -mt-[2px] inline-block" />
<Icon.FiGlobe className="min-h-4 min-w-4 ml-[2px] -mt-[2px] inline-block ltr:mr-[10px] rtl:ml-[10px]" />
{timeZone}
{isTimeOptionsOpen ? (
<Icon.FiChevronUp className="min-h-4 min-w-4 ml-1 inline-block" />
@@ -368,7 +368,7 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
<BookingDescription profile={profile} eventType={eventType} rescheduleUid={rescheduleUid}>
{!rescheduleUid && eventType.recurringEvent && (
<div className="flex items-start text-sm font-medium">
<Icon.FiRefreshCcw className="float-left mr-[10px] mt-[7px] ml-[2px] inline-block h-4 w-4 " />
<Icon.FiRefreshCcw className="float-left mt-[7px] ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px] " />
<div>
<p className="mb-1 -ml-2 inline px-2 py-1">
{getRecurringFreq({ t, recurringEvent: eventType.recurringEvent })}
@@ -393,7 +393,7 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
)}
{stripeAppData.price > 0 && (
<p className="-ml-2 px-2 text-sm font-medium">
<Icon.FiCreditCard className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
<Icon.FiCreditCard className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
<IntlProvider locale="en">
<FormattedNumber
value={stripeAppData.price / 100.0}
@@ -414,7 +414,7 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
{t("former_time")}
</p>
<p className="text-gray-500 line-through dark:text-darkgray-600">
<CalendarIcon className="mr-[10px] -mt-1 inline-block h-4 w-4 text-gray-500" />
<CalendarIcon className="ltr:mr-[10px] rtl:ml-[10px] -mt-1 inline-block h-4 w-4 text-gray-500" />
{typeof booking.startTime === "string" && parseDate(dayjs(booking.startTime), i18n)}
</p>
</div>
@@ -501,7 +501,7 @@ const BookingPage = ({
<BookingDescription isBookingPage profile={profile} eventType={eventType}>
{stripeAppData.price > 0 && (
<p className="text-bookinglight -ml-2 px-2 text-sm ">
<Icon.FiCreditCard className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
<Icon.FiCreditCard className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
<IntlProvider locale="en">
<FormattedNumber
value={stripeAppData.price / 100.0}
@@ -513,7 +513,7 @@ const BookingPage = ({
)}
{!rescheduleUid && eventType.recurringEvent?.freq && recurringEventCount && (
<div className="items-start text-sm font-medium text-gray-600 dark:text-white">
<Icon.FiRefreshCw className="mr-[10px] ml-[2px] inline-block h-4 w-4" />
<Icon.FiRefreshCw className="ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
<p className="-ml-2 inline-block items-center px-2">
{getEveryFreqFor({
t,
@@ -524,7 +524,7 @@ const BookingPage = ({
</div>
)}
<div className="text-bookinghighlight flex items-start text-sm">
<Icon.FiCalendar className="mr-[10px] ml-[2px] mt-[2px] inline-block h-4 w-4" />
<Icon.FiCalendar className="ml-[2px] mt-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
<div className="text-sm font-medium">
{(rescheduleUid || !eventType.recurringEvent?.freq) && `${parseDate(date, i18n)}`}
{!rescheduleUid &&
@@ -552,7 +552,7 @@ const BookingPage = ({
{t("former_time")}
</p>
<p className="line-through ">
<Icon.FiCalendar className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
<Icon.FiCalendar className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
{typeof booking.startTime === "string" && parseDate(dayjs(booking.startTime), i18n)}
</p>
</div>
@@ -560,7 +560,7 @@ const BookingPage = ({
{!!eventType.seatsPerTimeSlot && (
<div className="text-bookinghighlight flex items-start text-sm">
<Icon.FiUser
className={`mr-[10px] ml-[2px] mt-[2px] inline-block h-4 w-4 ${
className={`ml-[2px] mt-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px] ${
booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
? "text-rose-600"
: booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
@@ -623,7 +623,7 @@ const BookingPage = ({
/>
{bookingForm.formState.errors.email && (
<div className="mt-2 flex items-center text-sm text-red-700 ">
<Icon.FiInfo className="mr-2 h-3 w-3" />
<Icon.FiInfo className="h-3 w-3 ltr:mr-2 rtl:ml-2" />
<p>{t("email_validation_error")}</p>
</div>
)}
@@ -664,8 +664,8 @@ const BookingPage = ({
value={location.type}
defaultChecked={i === 0}
/>
<span className="text-sm ltr:ml-2 rtl:mr-2 dark:text-white">
{locationKeyToString(location)}
<span className="text-sm ltr:ml-2 ltr:mr-2 rtl:ml-2 dark:text-white">
{t(locationKeyToString(location) ?? "")}
</span>
</label>
);
@@ -689,7 +689,7 @@ const BookingPage = ({
{selectedLocationType === LocationType.Phone
? t("phone_number")
: selectedLocationType === LocationType.AttendeeInPerson
? t("Address")
? t("address")
: ""}
</label>
<div className="mt-1">
@@ -716,7 +716,7 @@ const BookingPage = ({
</div>
{bookingForm.formState.errors.phone && (
<div className="mt-2 flex items-center text-sm text-red-700 ">
<Icon.FiInfo className="mr-2 h-3 w-3" />
<Icon.FiInfo className="h-3 w-3 ltr:mr-2 rtl:ml-2" />
<p>{t("invalid_number")}</p>
</div>
)}
@@ -800,6 +800,7 @@ const BookingPage = ({
{input.options && input.type === EventTypeCustomInputType.RADIO && (
<div className="flex">
<Group
name={`customInputs.${input.id}`}
required={input.required}
onValueChange={(e) => {
bookingForm.setValue(`customInputs.${input.id}`, e);
@@ -808,9 +809,9 @@ const BookingPage = ({
{input.options.map((option, i) => (
<RadioField
label={option.label}
key={`option.${i}.radio`}
key={`option.${input.id}.${i}.radio`}
value={option.label}
id={`option.${i}.radio`}
id={`option.${input.id}.${i}.radio`}
/>
))}
</>
@@ -833,7 +834,7 @@ const BookingPage = ({
/>
{bookingForm.formState.errors?.customInputs?.[input.id] && (
<div className="mt-2 flex items-center text-sm text-red-700 ">
<Icon.FiInfo className="mr-2 h-3 w-3" />
<Icon.FiInfo className="h-3 w-3 ltr:mr-2 rtl:ml-2" />
<p>{t("invalid_number")}</p>
</div>
)}
@@ -913,7 +914,7 @@ const BookingPage = ({
</div>
{bookingForm.formState.errors.smsReminderNumber && (
<div className="mt-2 flex items-center text-sm text-red-700 ">
<Icon.FiInfo className="mr-2 h-3 w-3" />
<Icon.FiInfo className="h-3 w-3 ltr:mr-2 rtl:ml-2" />
<p>{t("invalid_number")}</p>
</div>
)}
@@ -354,7 +354,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
/>
{selectedLocation && LocationOptions}
<DialogFooter>
<div className="mt-4 flex justify-end space-x-2">
<div className="mt-4 flex justify-end space-x-2 rtl:space-x-reverse">
<Button
onClick={() => {
setShowLocationModal(false);
@@ -149,7 +149,7 @@ export const AvailabilityTab = ({ isTeamEvent }: { isTeamEvent: boolean }) => {
<span className="w-16 sm:w-28 sm:text-left">
{format(dayRange.startTime, timeFormat === 12)}
</span>
<span className="ml-4">-</span>
<span className="ltr:ml-4 rtl:mr-4">-</span>
<div className="ml-6">{format(dayRange.endTime, timeFormat === 12)}</div>
</div>
))}
@@ -164,7 +164,7 @@ export const AvailabilityTab = ({ isTeamEvent }: { isTeamEvent: boolean }) => {
<hr />
<div className="flex flex-col justify-center gap-2 sm:flex-row sm:justify-between">
<span className="flex items-center justify-center text-sm text-gray-600 sm:justify-start">
<Icon.FiGlobe className="mr-2" />
<Icon.FiGlobe className="ltr:mr-2 rtl:ml-2" />
{schedule?.timeZone || <SkeletonText className="block h-5 w-32" />}
</span>
<Button
@@ -134,8 +134,8 @@ const CustomInputTypeForm: FC<Props> = (props) => {
value={selectedCustomInput?.id || -1}
{...register("id", { valueAsNumber: true })}
/>
<div className="mt-5 flex justify-end space-x-2 sm:mt-4">
<Button onClick={onCancel} type="button" color="secondary" className="ltr:mr-2">
<div className="mt-5 flex justify-end space-x-2 rtl:space-x-reverse sm:mt-4">
<Button onClick={onCancel} type="button" color="secondary" className="ltr:mr-2 rtl:ml-2">
{t("cancel")}
</Button>
<Button
@@ -1,5 +1,5 @@
import Link from "next/link";
import type { CustomInputParsed, EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
import type { CustomInputParsed, EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
import { useEffect, useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import short from "short-uuid";
@@ -7,10 +7,11 @@ import { v5 as uuidv5 } from "uuid";
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
import CustomInputItem from "@calcom/features/eventtypes/components/CustomInputItem";
import { APP_NAME, CAL_URL } from "@calcom/lib/constants";
import { APP_NAME, CAL_URL, IS_SELF_HOSTED } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import {
Badge,
Button,
Checkbox,
Dialog,
@@ -36,7 +37,19 @@ const generateHashedLink = (id: number) => {
return uid;
};
export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered, "eventType" | "team">) => {
const getRandomId = (length = 8) => {
return (
-1 *
parseInt(
Math.ceil(Math.random() * Date.now())
.toPrecision(length)
.toString()
.replace(".", "")
)
);
};
export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps, "eventType" | "team">) => {
const connectedCalendarsQuery = trpc.viewer.connectedCalendars.useQuery();
const formMethods = useFormContext<FormValues>();
const { t } = useLocale();
@@ -51,7 +64,6 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
const [selectedCustomInputModalOpen, setSelectedCustomInputModalOpen] = useState(false);
const [requiresConfirmation, setRequiresConfirmation] = useState(eventType.requiresConfirmation);
const placeholderHashedLink = `${CAL_URL}/d/${hashedUrl}/${eventType.slug}`;
const seatsEnabled = formMethods.getValues("seatsPerTimeSlotEnabled");
const removeCustom = (index: number) => {
@@ -64,6 +76,12 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
!hashedUrl && setHashedUrl(generateHashedLink(eventType.users[0]?.id ?? team?.id));
}, [eventType.users, hashedUrl, team?.id]);
useEffect(() => {
if (eventType.customInputs) {
setCustomInputs(eventType.customInputs.sort((a, b) => a.id - b.id));
}
}, [eventType.customInputs]);
return (
<div className="flex flex-col space-y-8">
{/**
@@ -239,13 +257,30 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
defaultValue={eventType.successRedirectUrl || ""}
{...formMethods.register("successRedirectUrl")}
/>
<div className="mt-2 flex">
<Checkbox
description={t("disable_success_page")}
// Disable if it's not Self Hosted or if the redirect url is not set
disabled={!IS_SELF_HOSTED || !formMethods.watch("successRedirectUrl")}
{...formMethods.register("metadata.disableSuccessPage")}
/>
{/*TODO: Extract it out into a component when used more than once*/}
{!IS_SELF_HOSTED && (
<Link href="https://cal.com/pricing">
<a target="_blank">
<Badge variant="orange" className="ml-2">
Platform Only
</Badge>
</a>
</Link>
)}
</div>
</div>
</SettingsToggle>
</>
)}
/>
<hr />
<SettingsToggle
data-testid="hashedLinkCheck"
title={t("private_link")}
@@ -374,7 +409,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
<Controller
name="customInputs"
control={formMethods.control}
defaultValue={eventType.customInputs.sort((a, b) => a.id - b.id) || []}
defaultValue={customInputs}
render={() => (
<Dialog open={selectedCustomInputModalOpen} onOpenChange={setSelectedCustomInputModalOpen}>
<DialogContent
@@ -386,13 +421,14 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
selectedCustomInput={selectedCustomInput}
onSubmit={(values) => {
const customInput: CustomInputParsed = {
id: -1,
id: getRandomId(),
eventTypeId: -1,
label: values.label,
placeholder: values.placeholder,
required: values.required,
type: values.type,
options: values.options,
hasToBeCreated: true,
};
if (selectedCustomInput) {
selectedCustomInput.label = customInput.label;
@@ -400,6 +436,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
selectedCustomInput.required = customInput.required;
selectedCustomInput.type = customInput.type;
selectedCustomInput.options = customInput.options || undefined;
selectedCustomInput.hasToBeCreated = false;
// Update by id
const inputIndex = customInputs.findIndex((input) => input.id === values.id);
customInputs[inputIndex] = selectedCustomInput;
@@ -1,4 +1,4 @@
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
import { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
import { useFormContext } from "react-hook-form";
import EventTypeAppContext, { GetAppData, SetAppData } from "@calcom/app-store/EventTypeAppContext";
@@ -9,7 +9,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { RouterOutputs, trpc } from "@calcom/trpc/react";
import { Button, EmptyScreen, ErrorBoundary, Icon } from "@calcom/ui";
type EventType = Pick<EventTypeSetupInfered, "eventType">["eventType"] &
type EventType = Pick<EventTypeSetupProps, "eventType">["eventType"] &
EventTypeAppCardComponentProps["eventType"];
function AppCardWrapper({
@@ -1,6 +1,6 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import * as RadioGroup from "@radix-ui/react-radio-group";
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
import { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
import { useMemo, useRef, useState } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
@@ -12,7 +12,7 @@ import { PeriodType } from "@calcom/prisma/client";
import type { BookingLimit } from "@calcom/types/Calendar";
import { Button, DateRangePicker, Icon, Input, InputField, Label, Select, SettingsToggle } from "@calcom/ui";
export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupInfered, "eventType">) => {
export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventType">) => {
const { t } = useLocale();
const formMethods = useFormContext<FormValues>();
const minimumBookingNoticeType = useRef<DurationType>(findDurationType(eventType.minimumBookingNotice));
@@ -197,7 +197,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupInfered, "event
label={t("minimum_booking_notice")}
type="number"
placeholder="120"
className="mr-2 mb-0 h-[38px] rounded-[4px]"
className="mb-0 h-[38px] rounded-[4px] ltr:mr-2 rtl:ml-2"
{...formMethods.register("minimumBookingNoticeInDurationType", {
onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
onMinimumNoticeChange(event.target.value),
@@ -327,7 +327,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupInfered, "event
</div>
)}
{period.type === "RANGE" && (
<div className="inline-flex space-x-2 ltr:ml-2 rtl:mr-2 rtl:space-x-reverse">
<div className="inline-flex space-x-2 ltr:ml-2 ltr:mr-2 rtl:ml-2 rtl:space-x-reverse">
<Controller
name="periodDates"
control={formMethods.control}
@@ -347,7 +347,9 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupInfered, "event
/>
</div>
)}
{period.suffix ? <span className="ltr:ml-2 rtl:mr-2">&nbsp;{period.suffix}</span> : null}
{period.suffix ? (
<span className="ltr:ml-2 ltr:mr-2 rtl:ml-2">&nbsp;{period.suffix}</span>
) : null}
</div>
);
})}
@@ -410,7 +412,9 @@ const BookingLimits = () => {
.map(([key, bookingAmount]) => {
const bookingLimitKey = key as BookingLimitsKey;
return (
<div className="mb-2 flex items-center space-x-2 text-sm" key={bookingLimitKey}>
<div
className="mb-2 flex items-center space-x-2 text-sm rtl:space-x-reverse"
key={bookingLimitKey}>
<Input
id={`${bookingLimitKey}-limit`}
type="number"
@@ -1,11 +1,10 @@
import { EventTypeSetupInfered } from "pages/event-types/[type]";
import { useState } from "react";
import { EventTypeSetupProps } from "pages/event-types/[type]";
import getStripeAppData from "@calcom/lib/getStripeAppData";
import RecurringEventController from "./RecurringEventController";
export const EventRecurringTab = ({ eventType }: Pick<EventTypeSetupInfered, "eventType">) => {
export const EventRecurringTab = ({ eventType }: Pick<EventTypeSetupProps, "eventType">) => {
const stripeAppData = getStripeAppData(eventType);
const requirePayment = stripeAppData.price > 0;
@@ -1,7 +1,7 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { isValidPhoneNumber } from "libphonenumber-js";
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
import { useState } from "react";
import { Controller, useForm, useFormContext } from "react-hook-form";
import { MultiValue } from "react-select";
@@ -23,7 +23,7 @@ type OptionTypeBase = {
};
export const EventSetupTab = (
props: Pick<EventTypeSetupInfered, "eventType" | "locationOptions" | "team" | "teamMembers">
props: Pick<EventTypeSetupProps, "eventType" | "locationOptions" | "team" | "teamMembers">
) => {
const { t } = useLocale();
const formMethods = useFormContext<FormValues>();
@@ -169,7 +169,7 @@ export const EventSetupTab = (
alt={`${eventLocationType.label} logo`}
/>
<span className="truncate text-sm ltr:ml-1 rtl:mr-1">
{location[eventLocationType.defaultValueVariable] || eventLocationType.label}
{t(location[eventLocationType.defaultValueVariable] || eventLocationType.label)}
</span>
</div>
<div className="flex">
@@ -342,7 +342,7 @@ export const EventSetupTab = (
saveLocation={saveLocation}
defaultValues={formMethods.getValues("locations")}
selection={
selectedLocation ? { value: selectedLocation.value, label: selectedLocation.label } : undefined
selectedLocation ? { value: selectedLocation.value, label: t(selectedLocation.label) } : undefined
}
setSelectedLocation={setSelectedLocation}
setEditingLocationType={setEditingLocationType}
+3 -49
View File
@@ -1,12 +1,12 @@
import { SchedulingType } from "@prisma/client/";
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
import { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
import { useMemo } from "react";
import { Controller, useFormContext } from "react-hook-form";
import CheckedTeamSelect from "@calcom/features/eventtypes/components/CheckedTeamSelect";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Avatar, Button, Icon, Label, Select } from "@calcom/ui";
import { Label, Select } from "@calcom/ui";
interface IMemberToValue {
id: number | null;
@@ -26,8 +26,7 @@ export const EventTeamTab = ({
eventType,
team,
teamMembers,
currentUserMembership,
}: Pick<EventTypeSetupInfered, "eventType" | "teamMembers" | "team" | "currentUserMembership">) => {
}: Pick<EventTypeSetupProps, "eventType" | "teamMembers" | "team">) => {
const formMethods = useFormContext<FormValues>();
const { t } = useLocale();
@@ -102,48 +101,3 @@ export const EventTeamTab = ({
</div>
);
};
const TeamProfileCard = ({
id,
name,
hasPermsToDelete,
email,
username,
}: {
id: number;
name: string | null;
email: string;
username: string | null;
hasPermsToDelete: boolean;
}) => {
const formMethods = useFormContext<FormValues>();
const avatar = `${WEBAPP_URL}/${username}/avatar.png`;
return (
<div className="flex items-center border border-gray-300 px-6 py-4 first:rounded-t-md last:rounded-b-md only:rounded-md">
<Avatar imageSrc={avatar} gravatarFallbackMd5="ajsdkwakhsd1231" size="md" alt="Team Image" />
<div className="flex flex-col pl-3">
<div className="">
<span className="pr-2 text-sm font-semibold leading-none text-black">{name}</span>
</div>
<p className="text-sm font-normal leading-normal text-gray-700">{email}</p>
</div>
{hasPermsToDelete && (
<div className="ml-auto">
<Button
type="button"
size="icon"
color="destructive"
StartIcon={Icon.FiTrash}
onClick={() => {
formMethods.setValue(
"users",
formMethods.getValues("users").filter((user) => user !== id.toString())
);
}}
/>
</div>
)}
</div>
);
};
@@ -1,4 +1,4 @@
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
import { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
import { useState } from "react";
import { TbWebhook } from "react-icons/tb";
@@ -14,7 +14,7 @@ import { Button, Dialog, DialogContent, EmptyScreen, Icon, showToast } from "@ca
export const EventTeamWebhooksTab = ({
eventType,
team,
}: Pick<EventTypeSetupInfered, "eventType" | "team">) => {
}: Pick<EventTypeSetupProps, "eventType" | "team">) => {
const { t } = useLocale();
const utils = trpc.useContext();
@@ -1,6 +1,6 @@
import { TFunction } from "next-i18next";
import { useRouter } from "next/router";
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
import { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
import { useMemo, useState } from "react";
import { UseFormReturn } from "react-hook-form";
import { TbWebhook } from "react-icons/tb";
@@ -37,9 +37,9 @@ import { EmbedButton, EmbedDialog } from "@components/Embed";
type Props = {
children: React.ReactNode;
eventType: EventTypeSetupInfered["eventType"];
currentUserMembership: EventTypeSetupInfered["currentUserMembership"];
team: EventTypeSetupInfered["team"];
eventType: EventTypeSetupProps["eventType"];
currentUserMembership: EventTypeSetupProps["currentUserMembership"];
team: EventTypeSetupProps["team"];
disableBorder?: boolean;
enabledAppsNumber: number;
installedAppsNumber: number;
@@ -57,7 +57,7 @@ export default function RecurringEventController({
{recurringEventState && (
<div data-testid="recurring-event-collapsible" className="text-sm">
<div className="flex items-center">
<p className="mr-2 text-neutral-900">{t("repeats_every")}</p>
<p className="text-neutral-900 ltr:mr-2 rtl:ml-2">{t("repeats_every")}</p>
<input
type="number"
min="1"
@@ -89,7 +89,7 @@ export default function RecurringEventController({
/>
</div>
<div className="mt-4 flex items-center">
<p className="mr-2 text-neutral-900">{t("for_a_maximum_of")}</p>
<p className="text-neutral-900 ltr:mr-2 rtl:ml-2">{t("for_a_maximum_of")}</p>
<input
type="number"
min="1"
@@ -105,7 +105,7 @@ export default function RecurringEventController({
setRecurringEventState(newVal);
}}
/>
<p className="mr-2 text-neutral-900">
<p className="text-neutral-900 ltr:mr-2 rtl:ml-2">
{t("events", {
count: recurringEventState.count,
})}
@@ -82,7 +82,7 @@ const DisableTwoFactorAuthModal = ({ onDisable, onCancel }: DisableTwoFactorAuth
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<Button type="submit" className="ltr:ml-2 rtl:mr-2" disabled={isDisabling}>
<Button type="submit" className="ltr:ml-2 ltr:mr-2 rtl:ml-2" disabled={isDisabling}>
{t("disable")}
</Button>
<Button color="secondary" onClick={onCancel}>
@@ -177,7 +177,7 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
<WithStep step={SetupStep.ConfirmPassword} current={step}>
<Button
type="submit"
className="ltr:ml-2 rtl:mr-2"
className="ltr:ml-2 ltr:mr-2 rtl:ml-2"
onClick={handleSetup}
disabled={password.length === 0 || isSubmitting}>
{t("continue")}
@@ -186,13 +186,13 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
<WithStep step={SetupStep.DisplayQrCode} current={step}>
<Button
type="submit"
className="ltr:ml-2 rtl:mr-2"
className="ltr:ml-2 ltr:mr-2 rtl:ml-2"
onClick={() => setStep(SetupStep.EnterTotpCode)}>
{t("continue")}
</Button>
</WithStep>
<WithStep step={SetupStep.EnterTotpCode} current={step}>
<Button type="submit" className="ltr:ml-2 rtl:mr-2" disabled={isSubmitting}>
<Button type="submit" className="ltr:ml-2 ltr:mr-2 rtl:ml-2" disabled={isSubmitting}>
{t("enable")}
</Button>
</WithStep>
@@ -1,10 +1,10 @@
import { ShieldCheckIcon } from "@calcom/ui";
import { Icon } from "@calcom/ui";
const TwoFactorModalHeader = ({ title, description }: { title: string; description: string }) => {
return (
<div className="mb-4 sm:flex sm:items-start">
<div className="bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
<ShieldCheckIcon className="h-6 w-6 text-white" />
<Icon.ShieldCheckIcon className="h-6 w-6 text-white" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="font-cal text-lg font-medium leading-6 text-gray-900" id="modal-title">
@@ -62,7 +62,7 @@ export function CalendarSwitch(props: {
}
);
return (
<div className="flex space-x-2 py-1">
<div className="flex space-x-2 py-1 rtl:space-x-reverse">
<Switch
key={props.externalId}
name="enabled"
@@ -90,7 +90,7 @@ const DisableTwoFactorAuthModal = ({
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<Button type="submit" className="ltr:ml-2 rtl:mr-2" disabled={isDisabling}>
<Button type="submit" className="ltr:ml-2 ltr:mr-2 rtl:ml-2" disabled={isDisabling}>
{t("disable")}
</Button>
<Button color="secondary" onClick={onCancel}>
@@ -170,7 +170,7 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
<WithStep step={SetupStep.ConfirmPassword} current={step}>
<Button
type="submit"
className="ltr:ml-2 rtl:mr-2"
className="ltr:ml-2 ltr:mr-2 rtl:ml-2"
onClick={handleSetup}
disabled={password.length === 0 || isSubmitting}>
{t("continue")}
@@ -179,13 +179,13 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
<WithStep step={SetupStep.DisplayQrCode} current={step}>
<Button
type="submit"
className="ltr:ml-2 rtl:mr-2"
className="ltr:ml-2 ltr:mr-2 rtl:ml-2"
onClick={() => setStep(SetupStep.EnterTotpCode)}>
{t("continue")}
</Button>
</WithStep>
<WithStep step={SetupStep.EnterTotpCode} current={step}>
<Button type="submit" className="ltr:ml-2 rtl:mr-2" disabled={isSubmitting}>
<Button type="submit" className="ltr:ml-2 ltr:mr-2 rtl:ml-2" disabled={isSubmitting}>
{t("enable")}
</Button>
</WithStep>
+7 -2
View File
@@ -1,11 +1,12 @@
import classNames from "classnames";
import React from "react";
import { APP_NAME, LOGO } from "@calcom/lib/constants";
import Loader from "@components/Loader";
import { HeadSeo } from "@components/seo/head-seo";
import Credits from "../../../../packages/ui/v2/core/Credits";
interface Props {
title: string;
description: string;
@@ -23,6 +24,7 @@ export default function AuthContainer(props: React.PropsWithChildren<Props>) {
// eslint-disable-next-line @next/next/no-img-element
<img className="mb-auto h-4" src={LOGO} alt={`${APP_NAME} Logo`} />
)}
<div className={classNames(props.showLogo ? "text-center" : "", "sm:mx-auto sm:w-full sm:max-w-md")}>
{props.heading && <h2 className="font-cal text-center text-3xl text-neutral-900">{props.heading}</h2>}
</div>
@@ -35,7 +37,10 @@ export default function AuthContainer(props: React.PropsWithChildren<Props>) {
<div className="border-1 mx-2 rounded-md border-gray-200 bg-white px-4 py-10 sm:px-10">
{props.children}
</div>
<div className="mt-8 text-center text-sm text-neutral-600">{props.footerText}</div>
<div className="mt-8 text-center text-sm text-neutral-600">
{props.footerText}
<Credits />
</div>
</div>
</div>
);
+1 -1
View File
@@ -34,7 +34,7 @@ export const AvatarGroup = function AvatarGroup(props: AvatarGroupProps) {
);
return (
<li key={idx} className="-mr-2 inline-block">
<li key={idx} className="-ltr:mr-2 inline-block rtl:ml-2">
{item.href ? (
<Link href={item.href}>
<a>{avatar}</a>
@@ -11,17 +11,7 @@ import { User } from "@calcom/prisma/client";
import { TRPCClientErrorLike } from "@calcom/trpc/client";
import { RouterOutputs, trpc } from "@calcom/trpc/react";
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogHeader,
Icon,
Input,
Label,
StarIconSolid,
} from "@calcom/ui";
import { Button, Dialog, DialogClose, DialogContent, DialogHeader, Icon, Input, Label } from "@calcom/ui";
export enum UsernameChangeStatusEnum {
UPGRADE = "UPGRADE",
@@ -240,7 +230,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
isInputUsernamePremium ? "text-orange-400" : "",
usernameIsAvailable ? "" : ""
)}>
{isInputUsernamePremium ? <StarIconSolid className="mt-[2px] w-6" /> : <></>}
{isInputUsernamePremium ? <Icon.StarIconSolid className="mt-[2px] w-6" /> : <></>}
{!isInputUsernamePremium && usernameIsAvailable ? <Icon.FiCheck className="mt-2 w-6" /> : <></>}
</span>
</div>
@@ -7,7 +7,17 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { TRPCClientErrorLike } from "@calcom/trpc/client";
import { trpc } from "@calcom/trpc/react";
import { AppRouter } from "@calcom/trpc/server/routers/_app";
import { Button, Dialog, DialogClose, DialogContent, DialogHeader, Icon, Input, Label } from "@calcom/ui";
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogHeader,
Icon,
Input,
Label,
TextField,
} from "@calcom/ui";
interface ICustomUsernameProps {
currentUsername: string | undefined;
@@ -77,10 +87,9 @@ const UsernameTextfield = (props: ICustomUsernameProps) => {
const ActionButtons = () => {
return usernameIsAvailable && currentUsername !== inputUsernameValue ? (
<div className="flex flex-row">
<div className="rlt:space-x-reverse mt-px flex flex-row space-x-2 ltr:ml-2 ltr:mr-2 rtl:ml-2 rtl:space-x-reverse">
<Button
type="button"
className="mx-2"
onClick={() => setOpenDialogSaveUsername(true)}
data-testid="update-username-btn">
{t("update")}
@@ -88,7 +97,6 @@ const UsernameTextfield = (props: ICustomUsernameProps) => {
<Button
type="button"
color="minimal"
className="mx-2"
onClick={() => {
if (currentUsername) {
setInputUsernameValue(currentUsername);
@@ -110,26 +118,20 @@ const UsernameTextfield = (props: ICustomUsernameProps) => {
return (
<div>
<div>
<Label htmlFor="username">{t("username")}</Label>
</div>
<div className="mt-2 flex rounded-md">
<span
className={classNames(
"inline-flex items-center rounded-l-md border border-r-0 border-gray-300 bg-gray-50 px-3 text-sm text-gray-500"
)}>
{process.env.NEXT_PUBLIC_WEBSITE_URL.replace("https://", "").replace("http://", "")}/
</span>
<div className="relative w-full">
<Input
<TextField
ref={usernameRef}
name="username"
value={inputUsernameValue}
addOnLeading={
<>{process.env.NEXT_PUBLIC_WEBSITE_URL.replace("https://", "").replace("http://", "")}/</>
}
autoComplete="none"
autoCapitalize="none"
autoCorrect="none"
className={classNames(
"mb-0 mt-0 h-6 rounded-md rounded-l-none",
"mb-0 mt-0 h-6 rounded-md ltr:rounded-l-none rtl:rounded-r-none",
markAsError
? "focus:shadow-0 focus:ring-shadow-0 border-red-500 focus:border-red-500 focus:outline-none focus:ring-0"
: ""
@@ -141,18 +143,18 @@ const UsernameTextfield = (props: ICustomUsernameProps) => {
data-testid="username-input"
/>
{currentUsername !== inputUsernameValue && (
<div className="absolute right-[2px] top-0 flex flex-row">
<div className="absolute right-[2px] top-6 flex flex-row">
<span className={classNames("mx-2 py-2")}>
{usernameIsAvailable ? <Icon.FiCheck className="mt-[2px] w-6" /> : <></>}
{usernameIsAvailable ? <Icon.FiCheck className="w-6" /> : <></>}
</span>
</div>
)}
</div>
<div className="hidden md:inline">
<div className="mt-5 hidden md:inline">
<ActionButtons />
</div>
</div>
{markAsError && <p className="mt-1 text-xs text-red-500">Username is already taken</p>}
{markAsError && <p className="mt-1 text-xs text-red-500">{t("username_already_taken")}</p>}
{usernameIsAvailable && currentUsername !== inputUsernameValue && (
<div className="mt-2 flex justify-end md:hidden">
+1 -1
View File
@@ -109,7 +109,7 @@ function Select<
IndicatorSeparator: () => null,
Input: InputComponent,
}}
className={classNames("text-sm", className)}
className={classNames("border-0 text-sm", className)}
{...props}
/>
);
+1 -1
View File
@@ -1,7 +1,6 @@
import parser from "accept-language-parser";
import { IncomingMessage } from "http";
import prisma from "@calcom/prisma";
import { Maybe } from "@calcom/trpc/server";
import { getSession } from "@lib/auth";
@@ -17,6 +16,7 @@ export function getLocaleFromHeaders(req: IncomingMessage): string {
}
export const getOrSetUserLocaleFromHeaders = async (req: IncomingMessage): Promise<string> => {
const prisma = (await import("@calcom/prisma")).default;
const session = await getSession({ req });
const preferredLocale = getLocaleFromHeaders(req);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "2.4.2",
"version": "2.4.3",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+2 -2
View File
@@ -27,7 +27,7 @@ import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calco
import prisma from "@calcom/prisma";
import { baseEventTypeSelect } from "@calcom/prisma/selects";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { BadgeCheckIcon, Icon } from "@calcom/ui";
import { Icon } from "@calcom/ui";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import { EmbedProps } from "@lib/withEmbedSsr";
@@ -139,7 +139,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
<h1 className="font-cal mb-1 text-3xl text-neutral-900 dark:text-white">
{nameOrUsername}
{user.verified && (
<BadgeCheckIcon className="mx-1 -mt-1 inline h-6 w-6 text-blue-500 dark:text-white" />
<Icon.BadgeCheckIcon className="mx-1 -mt-1 inline h-6 w-6 text-blue-500 dark:text-white" />
)}
</h1>
<p className="dark:text-darkgray-600 text-s text-neutral-500">{user.bio}</p>
-2
View File
@@ -19,7 +19,6 @@ import { seoConfig } from "@lib/config/next-seo.config";
import I18nLanguageHandler from "@components/I18nLanguageHandler";
import "../styles/fonts.css";
import "../styles/globals.css";
function MyApp(props: AppProps) {
@@ -32,7 +31,6 @@ function MyApp(props: AppProps) {
}
// Use the layout defined at the page level, if available
const getLayout = Component.getLayout ?? ((page) => page);
return (
<AppProviders {...props}>
<DefaultSeo {...seoConfig.defaultNextSeo} />
+3 -1
View File
@@ -1,6 +1,8 @@
import Document, { DocumentContext, Head, Html, Main, NextScript, DocumentProps } from "next/document";
import Script from "next/script";
import { getDirFromLang } from "@calcom/lib/i18n";
type Props = Record<string, unknown> & DocumentProps;
function toRunBeforeReactOnClient() {
@@ -47,7 +49,7 @@ class MyDocument extends Document<Props> {
render() {
const { locale } = this.props.__NEXT_DATA__;
const dir = locale === "ar" || locale === "he" ? "rtl" : "ltr";
const dir = getDirFromLang(locale);
return (
<Html lang={locale} dir={dir}>
<Head>
+1 -1
View File
@@ -138,7 +138,7 @@ const providers: Provider[] = [
username: user.username,
email: user.email,
name: user.name,
role: "USER",
role: "INACTIVE_ADMIN",
belongsToActiveTeam: hasActiveTeams,
};
}
@@ -0,0 +1,46 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import { fetcher } from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter";
import { getSession } from "@calcom/lib/auth";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
const getAccessLinkSchema = z.union([
z.object({
download_link: z.string(),
expires: z.number(),
}),
z.object({}),
]);
const requestQuery = z.object({
recordingId: z.string(),
});
async function handler(
req: NextApiRequest,
res: NextApiResponse<z.infer<typeof getAccessLinkSchema> | void>
) {
const { recordingId } = requestQuery.parse(req.query);
const session = await getSession({ req });
// Check if user belong to active team
if (!session?.user?.belongsToActiveTeam) {
return res.status(403);
}
try {
const response = await fetcher(`/recordings/${recordingId}/access-link`).then(getAccessLinkSchema.parse);
if ("download_link" in response && response.download_link) {
return res.status(200).json(response);
}
return res.status(400);
} catch (err) {
res.status(500);
}
}
export default defaultHandler({
GET: Promise.resolve({ default: defaultResponder(handler) }),
});
+1 -1
View File
@@ -17,7 +17,7 @@ import {
InstalledAppsLayout,
List,
ShellSubHeading,
SkeletonLoader,
AppSkeletonLoader as SkeletonLoader,
} from "@calcom/ui";
import { QueryCell } from "@lib/QueryCell";
+1 -1
View File
@@ -132,7 +132,7 @@ export default function Login({
{...register("email")}
/>
<div className="relative">
<div className="absolute right-0 -top-[6px] z-10">
<div className="absolute -top-[6px] z-10 ltr:right-0 rtl:left-0">
<Link href="/auth/forgot-password">
<a tabIndex={-1} className="text-sm font-medium text-gray-600">
{t("forgot")}
@@ -97,7 +97,7 @@ const SetupFormStep1 = (props: { setIsLoading: (val: boolean) => void }) => {
<TextField
addOnLeading={
!longWebsiteUrl && (
<span className="items-centerpx-3 inline-flex rounded-none text-sm text-gray-500">
<span className="inline-flex items-center rounded-none px-3 text-sm text-gray-500">
{process.env.NEXT_PUBLIC_WEBSITE_URL}/
</span>
)
+10 -2
View File
@@ -174,7 +174,6 @@ const querySchema = z.object({
export default function Success(props: SuccessProps) {
const { t } = useLocale();
const router = useRouter();
const {
allRemainingBookings,
isSuccessBookingPage,
@@ -636,7 +635,7 @@ export default function Success(props: SuccessProps) {
encodeURIComponent(new RRule(props.eventType.recurringEvent).toString())
: "")
}>
<a className="mr-2 h-10 w-10 rounded-sm border border-neutral-200 px-3 py-2 dark:border-neutral-700 dark:text-white">
<a className="h-10 w-10 rounded-sm border border-neutral-200 px-3 py-2 ltr:mr-2 rtl:ml-2 dark:border-neutral-700 dark:text-white">
<svg
className="-mt-1.5 inline-block h-4 w-4"
fill="currentColor"
@@ -1051,6 +1050,15 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
customInputs: customInputSchema.array().parse(eventTypeRaw.customInputs),
};
if (eventType.metadata?.disableSuccessPage && eventType.successRedirectUrl) {
return {
redirect: {
destination: eventType.successRedirectUrl,
permanent: false,
},
};
}
const profile = {
name: eventType.team?.name || eventType.users[0]?.name || null,
email: eventType.team ? null : eventType.users[0].email || null,
+1 -1
View File
@@ -137,7 +137,7 @@ export default function Bookings() {
</div>
)}
<div className="pt-2 xl:pt-0">
<p className="mb-2 text-xs font-medium uppercase leading-4 text-gray-500">{t("all")}</p>
{/* <p className="mb-2 text-xs font-medium uppercase leading-4 text-gray-500">{t("all")}</p> */}
<div className="overflow-hidden rounded-md border border-gray-200">
<table className="w-full max-w-full table-fixed">
<tbody className="divide-y divide-gray-200 bg-white" data-testid="bookings">
+46 -262
View File
@@ -1,27 +1,24 @@
/* eslint-disable @typescript-eslint/no-empty-function */
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { PeriodType, Prisma, SchedulingType } from "@prisma/client";
import { PeriodType, SchedulingType } from "@prisma/client";
import { GetServerSidePropsContext } from "next";
import { useRouter } from "next/router";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { StripeData } from "@calcom/app-store/stripepayment/lib/server";
import { getEventTypeAppData, getLocationOptions } from "@calcom/app-store/utils";
import { EventLocationType, LocationObject } from "@calcom/core/location";
import { parseBookingLimit, parseRecurringEvent, validateBookingLimitOrder } from "@calcom/lib";
import getEnabledApps from "@calcom/lib/apps/getEnabledApps";
import { EventLocationType } from "@calcom/core/location";
import { validateBookingLimitOrder } from "@calcom/lib";
import { CAL_URL } from "@calcom/lib/constants";
import convertToNewDurationType from "@calcom/lib/convertToNewDurationType";
import findDurationType from "@calcom/lib/findDurationType";
import getStripeAppData from "@calcom/lib/getStripeAppData";
import getEventTypeById from "@calcom/lib/getEventTypeById";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
import { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc/react";
import { trpc, RouterOutputs } from "@calcom/trpc/react";
import type { BookingLimit, RecurringEvent } from "@calcom/types/Calendar";
import { Form, showToast } from "@calcom/ui";
@@ -41,7 +38,6 @@ import { EventTeamWebhooksTab } from "@components/eventtype/EventTeamWebhooksTab
import { EventTypeSingleLayout } from "@components/eventtype/EventTypeSingleLayout";
import EventWorkflowsTab from "@components/eventtype/EventWorkfowsTab";
import { getTranslation } from "@server/lib/i18n";
import { ssrInit } from "@server/lib/ssr";
export type FormValues = {
@@ -110,27 +106,24 @@ const querySchema = z.object({
.default("setup"),
});
export type EventTypeSetupInfered = inferSSRProps<typeof getServerSideProps>;
export type EventTypeSetupProps = RouterOutputs["viewer"]["eventTypes"]["get"];
const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
const EventTypePage = (props: EventTypeSetupProps) => {
const { t } = useLocale();
const utils = trpc.useContext();
const router = useRouter();
const { tabName } = querySchema.parse(router.query);
const { data: eventTypeApps } = trpc.viewer.apps.useQuery({
extendsFeature: "EventType",
});
const { eventType: dbEventType, locationOptions, team, teamMembers } = props;
// TODO: It isn't a good idea to maintain state using setEventType. If we want to connect the SSR'd data to tRPC, we should useQuery(["viewer.eventTypes.get"]) with initialData
// Due to this change, when Form is saved, there is no way to propagate that info to eventType (e.g. disabling stripe app doesn't allow recurring tab to be enabled without refresh).
const [eventType, setEventType] = useState(dbEventType);
const router = useRouter();
const { tabName } = querySchema.parse(router.query);
const { eventType, locationOptions, team, teamMembers, currentUserMembership } = props;
const [animationParentRef] = useAutoAnimate<HTMLDivElement>();
const updateMutation = trpc.viewer.eventTypes.update.useMutation({
onSuccess: async ({ eventType: newEventType }) => {
setEventType({ ...eventType, slug: newEventType.slug });
onSuccess: async () => {
showToast(
t("event_type_updated_successfully", {
eventTypeTitle: eventType.title,
@@ -138,6 +131,9 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
"success"
);
},
async onSettled() {
await utils.viewer.eventTypes.get.invalidate();
},
onError: (err) => {
let message = "";
if (err instanceof HttpError) {
@@ -241,14 +237,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
/>
),
availability: <AvailabilityTab isTeamEvent={!!team} />,
team: (
<EventTeamTab
eventType={eventType}
teamMembers={teamMembers}
team={team}
currentUserMembership={props.currentUserMembership}
/>
),
team: <EventTeamTab eventType={eventType} teamMembers={teamMembers} team={team} />,
limits: <EventLimitsTab eventType={eventType} />,
advanced: <EventAdvancedTab eventType={eventType} team={team} />,
recurring: <EventRecurringTab eventType={eventType} />,
@@ -272,7 +261,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
isUpdateMutationLoading={updateMutation.isLoading}
formMethods={formMethods}
disableBorder={tabName === "apps" || tabName === "workflows" || tabName === "webhooks"}
currentUserMembership={props.currentUserMembership}>
currentUserMembership={currentUserMembership}>
<Form
form={formMethods}
id="event-type-form"
@@ -337,6 +326,18 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
);
};
const EventTypePageWrapper = (props: inferSSRProps<typeof getServerSideProps>) => {
const { data, isLoading } = trpc.viewer.eventTypes.get.useQuery(
{ id: props.type },
{
initialData: props.initialData,
}
);
if (isLoading || !data) return null;
return <EventTypePage {...data} />;
};
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const { req, query } = context;
const session = await getSession({ req });
@@ -358,244 +359,27 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
};
}
const userSelect = Prisma.validator<Prisma.UserSelect>()({
name: true,
username: true,
id: true,
avatar: true,
email: true,
locale: true,
defaultScheduleId: true,
});
const rawEventType = await prisma.eventType.findFirst({
where: {
AND: [
{
OR: [
{
users: {
some: {
id: session.user.id,
},
},
},
{
team: {
members: {
some: {
userId: session.user.id,
},
},
},
},
{
userId: session.user.id,
},
],
},
{
id: typeParam,
},
],
},
select: {
id: true,
title: true,
slug: true,
description: true,
length: true,
hidden: true,
locations: true,
eventName: true,
customInputs: true,
timeZone: true,
periodType: true,
metadata: true,
periodDays: true,
periodStartDate: true,
periodEndDate: true,
periodCountCalendarDays: true,
requiresConfirmation: true,
recurringEvent: true,
hideCalendarNotes: true,
disableGuests: true,
minimumBookingNotice: true,
beforeEventBuffer: true,
afterEventBuffer: true,
slotInterval: true,
hashedLink: true,
bookingLimits: true,
successRedirectUrl: true,
currency: true,
team: {
select: {
id: true,
slug: true,
members: {
where: {
accepted: true,
},
select: {
role: true,
user: {
select: userSelect,
},
},
},
try {
const res = await getEventTypeById({ eventTypeId: typeParam, userId: session.user.id, prisma });
return {
props: {
session,
type: typeParam,
trpcState: ssr.dehydrate(),
initialData: {
eventType: res.eventType,
locationOptions: res.locationOptions,
team: res.team,
teamMembers: res.teamMembers,
currentUserMembership: res.currentUserMembership,
},
},
users: {
select: userSelect,
},
schedulingType: true,
schedule: {
select: {
id: true,
},
},
userId: true,
price: true,
destinationCalendar: true,
seatsPerTimeSlot: true,
seatsShowAttendees: true,
webhooks: {
select: {
id: true,
subscriberUrl: true,
payloadTemplate: true,
active: true,
eventTriggers: true,
secret: true,
eventTypeId: true,
},
},
workflows: {
include: {
workflow: {
include: {
activeOn: {
select: {
eventType: {
select: {
id: true,
title: true,
},
},
},
},
steps: true,
},
},
},
},
},
});
if (!rawEventType) {
};
} catch (err) {
return {
notFound: true,
};
}
const credentials = await prisma.credential.findMany({
where: {
userId: session.user.id,
app: {
enabled: true,
},
},
select: {
id: true,
type: true,
key: true,
userId: true,
appId: true,
invalid: true,
},
});
const { locations, metadata, ...restEventType } = rawEventType;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const newMetadata = EventTypeMetaDataSchema.parse(metadata || {})!;
const apps = newMetadata.apps || {};
const eventTypeWithParsedMetadata = { ...rawEventType, metadata: newMetadata };
newMetadata.apps = {
...apps,
stripe: {
...getStripeAppData(eventTypeWithParsedMetadata, true),
currency:
(
credentials.find((integration) => integration.type === "stripe_payment")
?.key as unknown as StripeData
)?.default_currency || "usd",
},
giphy: getEventTypeAppData(eventTypeWithParsedMetadata, "giphy", true),
rainbow: getEventTypeAppData(eventTypeWithParsedMetadata, "rainbow", true),
};
// TODO: How to extract metadata schema from _EventTypeModel to be able to parse it?
// const parsedMetaData = _EventTypeModel.parse(newMetadata);
const parsedMetaData = newMetadata;
const parsedCustomInputs = (rawEventType.customInputs || []).map((input) => customInputSchema.parse(input));
const eventType = {
...restEventType,
schedule: rawEventType.schedule?.id || rawEventType.users[0]?.defaultScheduleId || null,
recurringEvent: parseRecurringEvent(restEventType.recurringEvent),
bookingLimits: parseBookingLimit(restEventType.bookingLimits),
locations: locations as unknown as LocationObject[],
metadata: parsedMetaData,
customInputs: parsedCustomInputs,
};
// backwards compat
if (eventType.users.length === 0 && !eventType.team) {
const fallbackUser = await prisma.user.findUnique({
where: {
id: session.user.id,
},
select: userSelect,
});
if (!fallbackUser) throw Error("The event type doesn't have user and no fallback user was found");
eventType.users.push(fallbackUser);
}
const currentUser = eventType.users.find((u) => u.id === session.user.id);
const t = await getTranslation(currentUser?.locale ?? "en", "common");
const integrations = await getEnabledApps(credentials);
const locationOptions = getLocationOptions(integrations, t);
const eventTypeObject = Object.assign({}, eventType, {
periodStartDate: eventType.periodStartDate?.toString() ?? null,
periodEndDate: eventType.periodEndDate?.toString() ?? null,
});
const teamMembers = eventTypeObject.team
? eventTypeObject.team.members.map((member) => {
const user = member.user;
user.avatar = `${CAL_URL}/${user.username}/avatar.png`;
return user;
})
: [];
// Find the current users memebership so we can check role to enable/disable deletion.
// Sets to null if no membership is found - this must mean we are in a none team event type
const currentUserMembership =
eventTypeObject.team?.members.find((el) => el.user.id === session.user.id) ?? null;
return {
props: {
session,
eventType: eventTypeObject,
locationOptions,
team: eventTypeObject.team || null,
teamMembers,
currentUserMembership,
trpcState: ssr.dehydrate(),
},
};
};
export default EventTypePage;
export default EventTypePageWrapper;
+3 -3
View File
@@ -78,7 +78,7 @@ const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGrou
className="hidden font-normal leading-4 text-gray-600 sm:inline"
data-testid={"event-type-slug-" + type.id}>{`/${group.profile.slug}/${type.slug}`}</small>
{readOnly && (
<span className="rtl:mr-2inline items-center rounded-sm bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-800 ltr:ml-2">
<span className="rtl:ml-2inline items-center rounded-sm bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-800 ltr:ml-2 ltr:mr-2">
{t("readonly") as string}
</span>
)}
@@ -394,7 +394,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
</div>
</div>
</div>
<div className="min-w-9 mr-5 flex sm:hidden">
<div className="min-w-9 mx-5 flex sm:hidden">
<Dropdown>
<DropdownMenuTrigger asChild data-testid={"event-type-options-" + type.id}>
<Button type="button" size="icon" color="secondary" StartIcon={Icon.FiMoreHorizontal} />
@@ -531,7 +531,7 @@ const EventTypeListHeading = ({
<a className="font-bold">{profile?.name || ""}</a>
</Link>
{membershipCount && teamId && (
<span className="relative -top-px text-xs text-neutral-500 ltr:ml-2 rtl:mr-2">
<span className="relative -top-px text-xs text-neutral-500 ltr:ml-2 ltr:mr-2 rtl:ml-2">
<Link href={`/settings/teams/${teamId}/members`}>
<a>
<Badge variant="gray">
@@ -21,7 +21,7 @@ function AdminView() {
const enteredUsername = usernameRef.current?.value.toLowerCase();
signIn("impersonation-auth", { username: enteredUsername });
}}>
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2 rtl:space-x-reverse">
<TextField
containerClassName="w-full"
name={t("user_impersonation_heading")}
@@ -16,7 +16,7 @@ import {
getSettingsLayout as getLayout,
Icon,
Meta,
SkeletonLoader,
AppSkeletonLoader as SkeletonLoader,
} from "@calcom/ui";
import { ssrInit } from "@server/lib/ssr";
@@ -177,7 +177,9 @@ const AppearanceView = () => {
<div className="flex w-full text-sm">
<div className="mr-1 flex-grow">
<div className="flex items-center">
<p className="mr-2 font-semibold">{t("disable_cal_branding", { appName: APP_NAME })}</p>
<p className="font-semibold ltr:mr-2 rtl:ml-2">
{t("disable_cal_branding", { appName: APP_NAME })}
</p>
<Badge variant="gray">{t("pro")}</Badge>
</div>
<p className="mt-0.5 text-gray-600">{t("removes_cal_branding", { appName: APP_NAME })}</p>
@@ -156,7 +156,7 @@ const CalendarsView = () => {
)
}
<div className="flex-grow truncate pl-2">
<ListItemTitle component="h3" className="mb-1 space-x-2">
<ListItemTitle component="h3" className="mb-1 space-x-2 rtl:space-x-reverse">
<Link href={"/apps/" + item.integration.slug}>
{item.integration.name || item.integration.title}
</Link>
@@ -82,7 +82,7 @@ const ConferencingLayout = () => {
app.logo && <img className="h-10 w-10" src={app.logo} alt={app.title} />
}
<div className="flex-grow truncate pl-2">
<ListItemTitle component="h3" className="mb-1 space-x-2">
<ListItemTitle component="h3" className="mb-1 space-x-2 rtl:space-x-reverse">
<h3 className="truncate text-sm font-medium text-neutral-900">{app.title}</h3>
</ListItemTitle>
<ListItemText component="p">{app.description}</ListItemText>
@@ -340,7 +340,7 @@ const ProfileForm = ({
render={({ field: { value } }) => (
<>
<Avatar alt="" imageSrc={value} gravatarFallbackMd5={emailMd5} size="lg" />
<div className="ml-4">
<div className="ltr:ml-4 rtl:mr-4">
<ImageUploader
target="avatar"
id="avatar-upload"
+1 -1
View File
@@ -49,7 +49,7 @@ function TeamPage({ team }: TeamPageProps) {
<Link href={`/team/${team.slug}/${type.slug}`}>
<a className="flex justify-between px-6 py-4" data-testid="event-type-link">
<div className="flex-shrink">
<div className="flex flex-wrap items-center space-x-2">
<div className="flex flex-wrap items-center space-x-2 rtl:space-x-reverse">
<h2 className="dark:text-darkgray-700 text-sm font-semibold text-gray-700">{type.title}</h2>
</div>
<EventTypeDescription className="text-sm" eventType={type} />
+5 -2
View File
@@ -14,8 +14,11 @@ function Teams() {
heading={t("teams")}
subtitle={t("create_manage_teams_collaborative")}
CTA={
<Button type="button" href={`${WEBAPP_URL}/settings/teams/new?returnTo=/teams`}>
<Icon.FiPlus className="inline-block h-3.5 w-3.5 text-white group-hover:text-black ltr:mr-2 rtl:ml-2" />
<Button
size="fab"
StartIcon={Icon.FiPlus}
type="button"
href={`${WEBAPP_URL}/settings/teams/new?returnTo=/teams`}>
{t("new")}
</Button>
}>
@@ -1390,7 +1390,7 @@
"attendee_email_info": "البريد الإلكتروني للشخص الحجز",
"kbar_search_placeholder": "اكتب أمرًا أو بحثًا...",
"invalid_credential": "لا! يبدو أن الصلاحية انتهت أو تم إلغاؤها. يرجى إعادة التثبيت مرة أخرى.",
"choose_common_schedule_team_event": "اختر جدولاً زمنياً مشتركاً",
"choose_common_schedule_team_event": "اختر جدولًا زمنيًا مشتركًا",
"choose_common_schedule_team_event_description": "قم بتمكين هذا الخيار إذا كنت ترغب في استخدام جدول زمني مشترك بين المضيفين. عند تعطيل هذا الخيار، سيتم حجز كل مضيف على أساس جدوله الافتراضي.",
"reason": "السبب",
"sender_id": "معرّف المرسل",
@@ -454,14 +454,17 @@
"additional_guests": "+ Weitere Gäste",
"your_name": "Ihr Name",
"email_address": "E-Mail Adresse",
"enter_valid_email": "Bitte geben Sie eine gültige Emaildresse ein",
"enter_valid_email": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"location": "Ort",
"address": "Adresse",
"enter_address": "Geben Sie eine Adresse ein",
"in_person_attendee_address": "In Person (Adresse von Ihnen)",
"yes": "Ja",
"no": "Nein",
"additional_notes": "Zusätzliche Notizen",
"booking_fail": "Termin konnte nicht gebucht werden.",
"reschedule_fail": "Termin konnte nicht neugeplant werden.",
"share_additional_notes": "Bitten teilen Sie Notizen zur Vorbereitung des Termins, falls nötig.",
"share_additional_notes": "Teilen Sie uns weitere Informationen mit, die zur Vorbereitung des Termins hilfreich sind.",
"booking_confirmation": "Bestätigen Sie {{eventTypeTitle}} mit {{profileName}}",
"booking_reschedule_confirmation": "Planen Sie Ihr {{eventTypeTitle}} mit {{profileName}} um",
"in_person_meeting": "Vor-Ort-Termin",
+17 -2
View File
@@ -388,7 +388,7 @@
"further_billing_help": "If you need any further help with billing, our support team are here to help.",
"contact": "Contact",
"our_support_team": "our support team",
"contact_our_support_team":"Contact our support team",
"contact_our_support_team": "Contact our support team",
"uh_oh": "Uh oh!",
"no_event_types_have_been_setup": "This user hasn't set up any event types yet.",
"edit_logo": "Edit logo",
@@ -460,6 +460,9 @@
"email_address": "Email address",
"enter_valid_email": "Please enter a valid email",
"location": "Location",
"address": "Address",
"enter_address": "Enter address",
"in_person_attendee_address": "In Person (Attendee Address)",
"yes": "yes",
"no": "no",
"additional_notes": "Additional notes",
@@ -1091,6 +1094,10 @@
"navigate": "Navigate",
"open": "Open",
"close": "Close",
"upgrade": "Upgrade",
"upgrade_to_access_recordings_title": "Upgrade to access recordings",
"upgrade_to_access_recordings_description":"Recordings are only available as part of our teams plan. Upgrade to start recording your calls",
"recordings_are_part_of_the_teams_plan":"Recordings are part of the teams plan",
"team_feature_teams": "This is a Team feature. Upgrade to Team to see your team's availability.",
"team_feature_workflows": "This is a Team feature. Upgrade to Team to automate your event notifications and reminders with Workflows.",
"show_eventtype_on_profile": "Show on Profile",
@@ -1148,8 +1155,11 @@
"confirm_delete_account_modal": "Are you sure you want to delete your {{appName}} account?",
"delete_my_account": "Delete my account",
"start_of_week": "Start of week",
"recordings_title": "Recordings",
"recording": "Recording",
"select_calendars": "Select which calendars you want to check for conflicts to prevent double bookings.",
"check_for_conflicts": "Check for conflicts",
"view_recordings": "View recordings",
"adding_events_to": "Adding events to",
"follow_system_preferences": "Follow system preferences",
"custom_brand_colors": "Custom brand colors",
@@ -1221,6 +1231,7 @@
"to": "To",
"workflow_turned_on_successfully": "{{workflowName}} workflow turned {{offOn}} successfully",
"download_responses": "Download Responses",
"download": "Download",
"create_your_first_form": "Create your first form",
"create_your_first_form_description": "With Routing Forms you can ask qualifying questions and route to the correct person or event type.",
"create_your_first_webhook": "Create your first Webhook",
@@ -1473,5 +1484,9 @@
"send_code" : "Send code",
"number_verified": "Number Verified",
"create_your_first_team_webhook_description": "Create your first webhook for this team event type",
"create_webhook_team_event_type": "Create a webhook for this team event type"
"create_webhook_team_event_type": "Create a webhook for this team event type",
"disable_success_page":"Disable Success Page (only works if you have a redirect URL)",
"invalid_admin_password": "You are set as an admin but you do not have a password length of at least 15 characters",
"change_password_admin": "Change Password to gain admin access",
"username_already_taken": "Username is already taken"
}
@@ -611,7 +611,7 @@
"monthly_other": "חודשים",
"yearly_one": "שנה",
"yearly_other": "שנים",
"plus_more": "ועוד {{count}}",
"plus_more": "{{count}} נוספים",
"max": "מקסימום",
"single_theme": "ערכת נושא בודדת",
"brand_color": "צבע המותג",
+42 -1
View File
@@ -303,6 +303,7 @@
"cancelling_all_recurring": "Dette er alle gjenværende forekomster i den gjentakende hendelsen.",
"error_with_status_code_occured": "Det oppstod en feil med statuskoden {{status}}.",
"booking_already_cancelled": "Denne bookingen var allerede kansellert",
"booking_already_accepted_rejected": "Denne bookingen var allerede akseptert eller avvist",
"go_back_home": "Gå til hjem",
"or_go_back_home": "Eller gå tilbake til hjem",
"no_availability": "Utilgjengelig",
@@ -385,6 +386,8 @@
"go_to_billing_portal": "Gå til betalingsportalen",
"need_anything_else": "Trenger du noe annet?",
"further_billing_help": "Hvis du trenger ytterligere hjelp med betaling, er supportteamet vårt her for å hjelpe.",
"contact": "Kontakt",
"our_support_team": "vårt support-team",
"contact_our_support_team": "Kontakt supportteamet vårt",
"uh_oh": "Uh oh!",
"no_event_types_have_been_setup": "Denne brukeren har ikke konfigurert noen hendelsestyper ennå.",
@@ -629,6 +632,7 @@
"teams": "Team",
"team": "Team",
"team_billing": "Team Fakturering",
"team_billing_description": "Administrer betaling for ditt team",
"upgrade_to_flexible_pro_title": "Vi har endret fakturering for team",
"upgrade_to_flexible_pro_message": "Det er medlemmer i teamet ditt uten sete. Oppgrader proffplanen din for å dekke manglende seter.",
"changed_team_billing_info": "Fra januar 2022 tar vi betalt per sete for teammedlemmer. Medlemmer av teamet ditt som hadde PRO gratis, har nå en 14 dagers prøveversjon. Når prøveperioden deres utløper, vil disse medlemmene bli skjult for teamet ditt med mindre du oppgraderer nå.",
@@ -704,6 +708,7 @@
"hide_event_type": "Skjul hendelsestype",
"edit_location": "Rediger sted",
"into_the_future": "i fremtiden",
"when_booked_with_less_than_notice": "Når booket med mindre enn <time></time> varsel",
"within_date_range": "Innenfor en datoperiode",
"indefinitely_into_future": "Uendelig inn i fremtiden",
"add_new_custom_input_field": "Legg til nytt egendefinert input felt",
@@ -723,6 +728,8 @@
"delete_account_confirmation_message": "Er du sikker på at du vil slette {{appName}}-kontoen din? Alle du har delt konto-lenken med, vil ikke lenger kunne booke ved å bruke den, og eventuelle innstillinger du har lagret vil gå tapt.",
"integrations": "Integrasjoner",
"apps": "Apper",
"apps_description": "Her finner du en liste over appene dine",
"apps_listing": "App liste",
"category_apps": "{{category}} apper",
"app_store": "App Butikken",
"app_store_description": "Kobler sammen folk, teknologi og arbeidsplassen.",
@@ -746,6 +753,7 @@
"toggle_calendars_conflict": "Huk av kalenderne du vil sjekke for konfliker for å hindre dobbelt-bookinger.",
"select_destination_calendar": "Opprett hendelser på",
"connect_additional_calendar": "Koble til enda en kalender",
"calendar_updated_successfully": "Kalenderen er oppdatert",
"conferencing": "Konferansering",
"calendar": "Kalender",
"payments": "Betalinger",
@@ -1028,6 +1036,9 @@
"error_removing_app": "Feil ved fjerning av app",
"web_conference": "Nett-konferanse",
"requires_confirmation": "Krever bekreftelse",
"always_requires_confirmation": "Alltid",
"requires_confirmation_threshold": "Krever bekreftelse hvis booket med < {{time}} $t({{unit}}_timeUnit) varsel",
"may_require_confirmation": "Kan kreve bekreftelse",
"nr_event_type_one": "{{count}} hendelsestype",
"nr_event_type_other": "{{count}} hendelsestyper",
"add_action": "Legg til handling",
@@ -1080,6 +1091,10 @@
"navigate": "Naviger",
"open": "Åpne",
"close": "Lukk",
"upgrade": "Oppgrader",
"upgrade_to_access_recordings_title": "Oppgrader for å få tilgang til opptak",
"upgrade_to_access_recordings_description": "Opptak er kun tilgjengelig som en del av team-planen vår. Oppgrader for å begynne å ta opp samtalene dine",
"recordings_are_part_of_the_teams_plan": "Opptak er en del av team-planen",
"team_feature_teams": "Dette er en Team-funksjon. Oppgrader til Team for å se teamets tilgjengelighet.",
"team_feature_workflows": "Dette er en Team-funksjon. Oppgrader til Team for å automatisere hendelsesvarslene og påminnelsene dine med arbeidsflyter.",
"show_eventtype_on_profile": "Vis på Profil",
@@ -1137,8 +1152,11 @@
"confirm_delete_account_modal": "Er du sikker på at du vil slette {{appName}}-kontoen din?",
"delete_my_account": "Slett kontoen min",
"start_of_week": "Begynnelsen av uken",
"recordings_title": "Opptak",
"recording": "Opptak",
"select_calendars": "Velg hvilke kalendere du vil sjekke for konflikter for å forhindre dobbeltbookinger.",
"check_for_conflicts": "Se etter konflikter",
"view_recordings": "Vis opptak",
"adding_events_to": "Legger til hendelser til",
"follow_system_preferences": "Følg systempreferansene",
"custom_brand_colors": "Egendefinerte merkevare-farger",
@@ -1171,8 +1189,11 @@
"invoices": "Fakturaer",
"embeds": "Innbygginger",
"impersonation": "Etterligning",
"impersonation_description": "Innstillinger for å administrere etterligning av brukere",
"users": "Brukere",
"profile_description": "Administrer innstillinger for {{appName}} profilen din",
"users_description": "Her finner du en liste over alle brukere",
"users_listing": "Bruker liste",
"general_description": "Administrer innstillinger for språk og tidssone",
"calendars_description": "Konfigurer hvordan hendelsestypene dine samhandler med kalenderne dine",
"appearance_description": "Administrer innstillinger for booking-utseendet",
@@ -1207,6 +1228,7 @@
"to": "Til",
"workflow_turned_on_successfully": "Arbeidsflyten for {{workflowName}} ble {{offOn}} slått av",
"download_responses": "Last ned Respons",
"download": "Nedlasting",
"create_your_first_form": "Lag ditt første skjema",
"create_your_first_form_description": "Med Viderekoblings-skjema kan du stille kvalifiserende spørsmål og videresende til riktig person eller hendelsestype.",
"create_your_first_webhook": "Lag din første Webhook",
@@ -1315,6 +1337,8 @@
"booking_confirmation_success": "Booking-bekreftelsen var vellykket",
"booking_rejection_success": "Booking-avvisningen var vellykket",
"booking_confirmation_fail": "Booking-bekreftelse feilet",
"booking_tentative": "Denne bookingen er foreløpig",
"booking_accept_intent": "Oops, jeg vil akseptere",
"we_wont_show_again": "Vi vil ikke vise dette igjen",
"couldnt_update_timezone": "Vi kunne ikke oppdatere tidssonen",
"updated_timezone_to": "Oppdaterte tidssone til {{formattedCurrentTz}}",
@@ -1377,7 +1401,9 @@
"number_sms_notifications": "Telefonnummer (SMS varsler)",
"attendee_email_workflow": "Deltakerens e-post",
"attendee_email_info": "Den som booker sin e-post",
"kbar_search_placeholder": "Skriv inn en kommando eller søk...",
"invalid_credential": "Å nei! Det ser ut til at tillatelsen er utløpt eller tilbakekalt. Vennligst installer på nytt.",
"reschedule_reason_success_page": "Årsak for endring av tidspunkt",
"choose_common_schedule_team_event": "Velg en felles timeplan",
"choose_common_schedule_team_event_description": "Aktiver dette hvis du vil bruke en felles tidsplan mellom verter. Når den er deaktivert, vil hver vert bookes basert på deres standard tidsplan.",
"reason": "Årsak",
@@ -1388,10 +1414,25 @@
"route_to": "Videresend til",
"test_preview_description": "Test viderekoblings-skjemaet ditt uten å sende noen data",
"test_routing": "Test Viderekobling",
"payment_app_disabled": "En administrator har deaktivert en betalingsapp",
"edit_event_type": "Rediger hendelsestype",
"collective_scheduling": "Kollektiv planlegging av tidspunkt",
"make_it_easy_to_book": "Gjør det enkelt å booke teamet ditt når alle er tilgjengelige.",
"find_the_best_person": "Finn den beste personen tilgjengelig og bla gjennom teamet ditt.",
"fixed_round_robin": "Fast round robin",
"add_one_fixed_attendee": "Legg til én fast deltaker og round robin gjennom et antall deltakere.",
"calcom_is_better_with_team": "Cal.com er bedre med team",
"add_your_team_members": "Legg til team-medlemmene dine i hendelsestypene dine. Bruk kollektiv planlegging av tidspunkt for å inkludere alle eller finn den mest passende personen med round robin-planlegging.",
"booking_limit_reached": "Booking-grensen for denne hendelsestypen er nådd",
"admin_has_disabled": "En administrator har deaktivert {{appName}}",
"fill_this_field": "Vennligst fyll ut dette feltet",
"options": "Alternativer",
"enter_option": "Angi alternativ {{index}}",
"add_an_option": "Legg til et alternativ",
"radio": "Radio"
"radio": "Radio",
"create_your_first_team_webhook_description": "Opprett din første webhook for denne team-hendelsestypen",
"create_webhook_team_event_type": "Opprett en webhook for denne team-hendelsestypen",
"disable_success_page": "Deaktiver Suksess-side (fungerer bare hvis du har en omdirigerings-URL)",
"invalid_admin_password": "Du er satt som administrator, men du har ikke en passordlengde på minst 15 tegn",
"change_password_admin": "Endre passord for å få administratortilgang"
}
@@ -1394,7 +1394,7 @@
"reason": "Powód",
"sender_id": "Identyfikator nadawcy",
"sender_id_error_message": "Dozwolone są tylko litery, cyfry i spacje (maksymalnie 11 znaków)",
"test_routing_form": "Przetestuj formularz przekierowania",
"test_routing_form": "Testowanie formularza przekierowania",
"test_preview": "Przetestuj podgląd",
"route_to": "Przekieruj do",
"test_preview_description": "Przetestuj swój formularz przekierowania bez przesyłania jakichkolwiek danych",
+59 -1
View File
@@ -52,6 +52,7 @@
"still_waiting_for_approval": "Un eveniment încă așteaptă aprobare",
"event_is_still_waiting": "Solicitarea evenimentului este încă în așteptare: {{attendeeName}} - {{date}} - {{eventType}}",
"no_more_results": "Nu mai există rezultate",
"no_results": "Niciun rezultat",
"load_more_results": "Încărcați mai multe rezultate",
"integration_meeting_id": "{{integrationName}} ședință ID: {{meetingId}}",
"confirmed_event_type_subject": "Confirmat: {{eventType}} cu {{name}} pe {{date}}",
@@ -248,6 +249,7 @@
"add_to_calendar": "Adaugă în calendar",
"add_another_calendar": "Adăugați alt calendar",
"other": "Altele",
"email_sign_in_subject": "Linkul tău de conectare pentru {{appName}}",
"emailed_you_and_attendees": "V-am trimis un e-mail dumneavoastră şi celuilalt participant la o invitaţie în calendar cu toate detaliile.",
"emailed_you_and_attendees_recurring": "V-am trimis un e-mail dvs. și celorlalți participanți cu o invitație în calendar pentru primul dintre aceste evenimente recurente.",
"emailed_you_and_any_other_attendees": "Dumneavoastră şi oricare alţi participanţi aţi primit prin e-mail aceste informaţii.",
@@ -464,11 +466,14 @@
"booking_confirmation": "Confirmă {{eventTypeTitle}} cu {{profileName}}",
"booking_reschedule_confirmation": "Reprogramează {{eventTypeTitle}} cu {{profileName}}",
"in_person_meeting": "Link sau întâlnire în persoană",
"attendeeInPerson": "În persoană (adresă participant)",
"inPerson": "În persoană (adresă organizator)",
"link_meeting": "Link întâlnire",
"phone_call": "Număr de telefon participant",
"your_number": "Numărul dvs. de telefon",
"phone_number": "Număr de telefon",
"attendee_phone_number": "Număr de telefon participant",
"organizer_phone_number": "Număr de telefon organizator",
"host_phone_number": "Numărul dvs. de telefon",
"enter_phone_number": "Introduceți numărul de telefon",
"reschedule": "Reprogrameaza",
@@ -555,6 +560,10 @@
"collective": "Colectiv",
"collective_description": "Planifică ședințe când toți membrii selecționați ai echipei sunt disponibili.",
"duration": "Durata",
"available_durations": "Durate disponibile",
"default_duration": "Durată implicită",
"default_duration_no_options": "Alege mai întâi duratele disponibile",
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
"minutes": "Minute",
"round_robin": "Round Robin",
"round_robin_description": "Întâlniri ciclice între mai mulţi membri ai echipei.",
@@ -620,6 +629,7 @@
"teams": "Echipe",
"team": "Echipă",
"team_billing": "Facturare echipă",
"team_billing_description": "Gestionează facturarea pentru echipa ta",
"upgrade_to_flexible_pro_title": "Am schimbat facturarea pentru echipe",
"upgrade_to_flexible_pro_message": "Există membri fără licență în echipa dvs. Faceți upgrade la planul Pro pentru a acoperi licențele lipsă.",
"changed_team_billing_info": "Începând din ianuarie 2022, se percepe un preț per licență pentru membrii echipei. Membrii echipei dvs. care au avut gratuit planul PRO sunt acum într-o perioadă de probă de 14 zile. Odată ce perioada de probă expiră, acești membri vor fi ascunși în echipa dvs. dacă nu faceți upgrade acum.",
@@ -695,6 +705,7 @@
"hide_event_type": "Ascunde tipul de eveniment",
"edit_location": "Editați locația",
"into_the_future": "în viitor",
"when_booked_with_less_than_notice": "Când se rezervă cu aviz mai scurt de <time></time>",
"within_date_range": "Intr-un interval de timp",
"indefinitely_into_future": "Pe termen nelimitat în viitor",
"add_new_custom_input_field": "Adaugă un nou câmp personalizat de introdus date",
@@ -738,6 +749,7 @@
"toggle_calendars_conflict": "Comutați calendarele în care doriți să verificați dacă există conflicte, pentru a preveni rezervările suprapuse.",
"select_destination_calendar": "Creați evenimente în",
"connect_additional_calendar": "Conectați calendarul suplimentar",
"calendar_updated_successfully": "Calendar actualizat cu succes",
"conferencing": "Conferință",
"calendar": "Calendar",
"payments": "Plăți",
@@ -770,6 +782,7 @@
"trending_apps": "Aplicații populare",
"explore_apps": "Aplicații {{category}}",
"installed_apps": "Aplicații instalate",
"free_to_use_apps": "Gratuit",
"no_category_apps": "Nicio aplicație {{category}}",
"no_category_apps_description_calendar": "Adăugați o aplicație calendar pentru a verifica dacă există conflicte și a evita rezervările suprapuse",
"no_category_apps_description_conferencing": "Încercați să adăugați o aplicație de conferință pentru a integra apelul video cu clienții dvs.",
@@ -808,6 +821,8 @@
"verify_wallet": "Verificați portofelul",
"connect_metamask": "Conectați Metamask",
"create_events_on": "Creați evenimente în",
"enterprise_license": "Aceasta este o caracteristică de întreprindere",
"enterprise_license_description": "Pentru a activa această caracteristică, obține o cheie de implementare la consola {{consoleUrl}} și adaug-o la .env ca CALCOM_LICENSE_KEY. Dacă echipa ta are deja o licență, te rugăm să contactezi {{supportMail}} pentru ajutor.",
"missing_license": "Licență lipsă",
"signup_requires": "Licență comercială necesară",
"signup_requires_description": "În prezent, {{companyName}} nu oferă o versiune open-source gratuită a paginii de înscriere. Pentru a primi acces deplin la componentele de înscriere, trebuie să obțineți o licență comercială. Pentru uz personal, recomandăm Prisma Data Platform sau orice altă interfață Postgres pentru a crea conturi.",
@@ -899,6 +914,7 @@
"user_impersonation_heading": "Reprezentare utilizator",
"user_impersonation_description": "Permite echipei noastre de asistență să se conecteze temporar în locul dvs. pentru a ne ajuta să rezolvăm orice probleme pe care ni le raportați.",
"team_impersonation_description": "Permite membrilor echipei să se conecteze temporar din contul dvs.",
"allow_booker_to_select_duration": "Permite persoanei care face rezervarea să selecteze durata",
"impersonate_user_tip": "Toate utilizările acestei caracteristici sunt verificate.",
"impersonating_user_warning": "Reprezentare nume de utilizator „{{user}}”.",
"impersonating_stop_instructions": "<0>Faceți clic aici pentru a înceta</0>.",
@@ -1016,6 +1032,9 @@
"error_removing_app": "Eroare la ștergerea aplicației",
"web_conference": "Conferință web",
"requires_confirmation": "Necesită confirmare",
"always_requires_confirmation": "Întotdeauna",
"requires_confirmation_threshold": "Necesită confirmare dacă este rezervat cu aviz de < {{time}} $t({{unit}}_timeUnit)",
"may_require_confirmation": "Poate necesita confirmare",
"nr_event_type_one": "{{count}} tip de eveniment",
"nr_event_type_other": "{{count}} (de) tipuri de evenimente",
"add_action": "Adăugare acțiune",
@@ -1103,6 +1122,8 @@
"event_limit_tab_description": "Cât de des puteți fi rezervat",
"event_advanced_tab_description": "Setări calendar și mai multe...",
"event_advanced_tab_title": "Avansat",
"event_setup_multiple_duration_error": "Configurare eveniment: duratele multiple necesită cel puțin o opțiune.",
"event_setup_multiple_duration_default_error": "Configurare eveniment: selectează o durată implicită validă.",
"event_setup_booking_limits_error": "Limitele de rezervare trebuie să fie în ordine crescătoare. [zi,săptămână,lună,an]",
"select_which_cal": "Alegeți la ce calendar se adaugă rezervările",
"custom_event_name": "Nume eveniment personalizat",
@@ -1157,8 +1178,11 @@
"invoices": "Facturi",
"embeds": "Încorporări",
"impersonation": "Reprezentare",
"impersonation_description": "Setări pentru gestionarea reprezentării utilizatorului",
"users": "Utilizatori",
"profile_description": "Gestionați setările profilului dvs. {{appName}}",
"users_description": "Aici poți găsi o listă a tuturor utilizatorilor",
"users_listing": "Listare utilizatori",
"general_description": "Gestionați setările pentru limbă și fus orar",
"calendars_description": "Configurați modul în care interacționează tipurile de evenimente cu calendarele dvs.",
"appearance_description": "Gestionați setările pentru aspectul rezervărilor dvs.",
@@ -1363,6 +1387,7 @@
"number_sms_notifications": "Număr de telefon (notificări SMS)",
"attendee_email_workflow": "E-mail participant",
"attendee_email_info": "E-mailul persoanei care efectuează rezervarea",
"kbar_search_placeholder": "Scrie o comandă sau caută...",
"invalid_credential": "Vai, nu! Se pare că permisiunea a expirat sau a fost revocată. Reinstalați din nou.",
"choose_common_schedule_team_event": "Alegeți un program comun",
"choose_common_schedule_team_event_description": "Activați această opțiune dacă doriți să folosiți un program comun între gazde. Atunci când este dezactivată, fiecare gazdă va fi rezervată în funcție de programul său implicit.",
@@ -1374,9 +1399,42 @@
"route_to": "Direcționați către",
"test_preview_description": "Testați-vă formularul de redirecționare fără a trimite date",
"test_routing": "Testați redirecționarea",
"payment_app_disabled": "Un administrator a dezactivat o aplicație de plată",
"edit_event_type": "Editează tipul de eveniment",
"collective_scheduling": "Programare colectivă",
"make_it_easy_to_book": "Facilitează rezervarea echipei tale când toată lumea este disponibilă.",
"find_the_best_person": "Găsește cea mai bună persoană disponibilă și treci succesiv prin membrii echipei tale.",
"fixed_round_robin": "Sistem fiecare-cu-fiecare fix",
"add_one_fixed_attendee": "Adaugă un participant fix şi unul din sistemul fiecare-cu-fiecare dintr-un număr de participanţi.",
"calcom_is_better_with_team": "Cal.com funcționează mai bine cu echipe",
"add_your_team_members": "Adaugă membrii echipei tale la tipurile tale de evenimente. Folosește programarea colectivă pentru a include pe toată lumea sau găsește persoana cea mai potrivită cu programarea fiecare-cu-fiecare.",
"booking_limit_reached": "A fost atinsă limita de rezervare pentru acest tip de eveniment",
"admin_has_disabled": "Un administrator a dezactivat {{appName}}",
"disabled_app_affects_event_type": "Un administrator a dezactivat {{appName}} care afectează tipul tău de eveniment {{eventType}}",
"disable_payment_app": "Administratorul a dezactivat {{appName}} care afectează tipul tău de eveniment {{title}}. Participanții încă pot să facă rezervări pentru acest tip de eveniment, dar nu li se va solicita să plătească. Poți ascunde tipul de eveniment pentru a preveni acest lucru până când administratorul tău reactivează metoda ta de plată.",
"payment_disabled_still_able_to_book": "Participanții încă pot să facă rezervări pentru acest tip de eveniment, dar nu li se va solicita să plătească. Poți ascunde tipul de eveniment pentru a preveni acest lucru până când administratorul tău reactivează metoda ta de plată.",
"app_disabled_with_event_type": "Administratorul a dezactivat {{appName}} care afectează tipul tău de eveniment {{title}}.",
"app_disabled_video": "Administratorul a dezactivat {{appName}} care poate afecta tipurile tale de evenimente. Dacă ai tipuri de evenimente cu {{appName}} ca locație, atunci va fi setat implicit la Cal Video.",
"app_disabled_subject": "{{appName}} a fost dezactivat",
"navigate_installed_apps": "Mergi la aplicațiile instalate",
"disabled_calendar": "Dacă ai un alt calendar instalat, rezervările noi vor fi adăugate la acesta. Dacă nu ai, conectează un calendar nou, astfel încât să nu ratezi nicio rezervare nouă.",
"enable_apps": "Activează aplicații",
"enable_apps_description": "Activează aplicațiile pe care utilizatorii le pot integra cu Cal.com",
"app_is_enabled": "{{appName}} este activat",
"app_is_disabled": "{{appName}} este dezactivat",
"keys_have_been_saved": "Cheile au fost salvate",
"disable_app": "Dezactivează aplicația",
"disable_app_description": "Dezactivarea acestei aplicații poate cauza probleme cu modul în care utilizatorii interacționează cu Cal",
"edit_keys": "Editează chei",
"no_available_apps": "Nu există nicio aplicație disponibilă",
"no_available_apps_description": "Asigură-te că există aplicații în implementare sub „pachete/magazin de aplicații”",
"no_apps": "Nu există nicio aplicație activată în această instanță de Cal",
"apps_settings": "Setări aplicații",
"fill_this_field": "Te rugăm să completezi acest câmp",
"options": "Opțiuni",
"enter_option": "Introdu opțiunea {{index}}",
"add_an_option": "Adaugă o opțiune",
"radio": "Radio",
"event_type_duplicate_copy_text": "{{slug}}-copie"
"event_type_duplicate_copy_text": "{{slug}}-copie",
"set_as_default": "Setează ca implicit"
}
+46 -1
View File
@@ -52,6 +52,7 @@
"still_waiting_for_approval": "Событие все еще ждет одобрения",
"event_is_still_waiting": "Запрос встречи все еще ожидает: {{attendeeName}} - {{date}} - {{eventType}}",
"no_more_results": "Ничего не найдено",
"no_results": "Нет результатов",
"load_more_results": "Загрузить больше результатов",
"integration_meeting_id": "{{integrationName}} ID встречи: {{meetingId}}",
"confirmed_event_type_subject": "Подтверждено: {{eventType}} с {{name}} на {{date}}",
@@ -248,6 +249,7 @@
"add_to_calendar": "Добавить в календарь",
"add_another_calendar": "Добавить другой календарь",
"other": "Прочее",
"email_sign_in_subject": "Ссылка для входа в {{appName}}",
"emailed_you_and_attendees": "Мы отправили вам и другим участникам приглашение в календарь со всеми деталями.",
"emailed_you_and_attendees_recurring": "Мы отправили вам и другим участникам в календарь приглашение на первое из этой серии повторяющихся событий.",
"emailed_you_and_any_other_attendees": "Вы и другие участники были уведомлены по электронной почте.",
@@ -464,11 +466,14 @@
"booking_confirmation": "Подтвердите вашу встречу «{{eventTypeTitle}}» с {{profileName}}",
"booking_reschedule_confirmation": "Перенесите вашу встречу «{{eventTypeTitle}}» с {{profileName}}",
"in_person_meeting": "Ссылка или личная встреча",
"attendeeInPerson": "Лично (адрес участника)",
"inPerson": "Лично (адрес организатора)",
"link_meeting": "Встреча со ссылкой",
"phone_call": "Номер телефона участника",
"your_number": "Ваш номер телефона",
"phone_number": "Номер телефона",
"attendee_phone_number": "Номер телефона участника",
"organizer_phone_number": "Телефон организатора",
"host_phone_number": "Ваш номер телефона",
"enter_phone_number": "Введите номер телефона",
"reschedule": "Перенести",
@@ -556,6 +561,9 @@
"collective_description": "Расписание встреч, когда доступны все выбранные члены команды.",
"duration": "Продолжительность",
"available_durations": "Доступные сроки",
"default_duration": "Продолжительность по умолчанию",
"default_duration_no_options": "Пожалуйста, выберите сначала доступную продолжительность",
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
"minutes": "мин.",
"round_robin": "По кругу",
"round_robin_description": "Цикл встреч между несколькими членами команды.",
@@ -621,6 +629,7 @@
"teams": "Команды",
"team": "Команда",
"team_billing": "Условия расчета для команд",
"team_billing_description": "Управление биллингом вашей команды",
"upgrade_to_flexible_pro_title": "Мы изменили условия расчета для команд",
"upgrade_to_flexible_pro_message": "В вашей команде есть пользователи без места. Перейдите на тарифный план Pro, чтобы получить недостающие места.",
"changed_team_billing_info": "С января 2022 года мы взимаем плату с членов команды за каждое место. Членам вашей команды, которые пользовались тарифным планом Pro бесплатно, теперь будет предоставлена 14-дневная пробная версия. Если вы не перейдете на тарифный план Pro сейчас, по истечении этого периода такие члены команды будут скрыты.",
@@ -696,6 +705,7 @@
"hide_event_type": "Скрыть шаблон события",
"edit_location": "Изменить местоположение",
"into_the_future": "в будущее",
"when_booked_with_less_than_notice": "Уведомлять при бронировании менее чем на <time></time>",
"within_date_range": "В диапазоне дат",
"indefinitely_into_future": "На неопределенный срок в будущем",
"add_new_custom_input_field": "Добавить настраиваемый вопрос",
@@ -739,6 +749,7 @@
"toggle_calendars_conflict": "Переключайте календари для проверки на наличие конфликтов, чтобы избежать двойного бронирования.",
"select_destination_calendar": "Создать события на",
"connect_additional_calendar": "Подключить дополнительный календарь",
"calendar_updated_successfully": "Календарь обновлен",
"conferencing": "Конференция",
"calendar": "Календарь",
"payments": "Платежи",
@@ -810,6 +821,7 @@
"verify_wallet": "Подтвердить кошелек",
"connect_metamask": "Подключить Metamask",
"create_events_on": "Создавать события в календаре:",
"enterprise_license": "Это функция корпоративного тарифного плана",
"enterprise_license_description": "Чтобы включить эту функцию, получите ключ развертывания на консоли {{consoleUrl}} и добавьте его в .env как CALCOM_LICENSE_KEY. Если у вашей команды уже есть лицензия, пожалуйста, обратитесь за помощью в {{supportMail}}.",
"missing_license": "Отсутствует лицензия",
"signup_requires": "Требуется коммерческая лицензия",
@@ -902,6 +914,7 @@
"user_impersonation_heading": "Вход от имени пользователя",
"user_impersonation_description": "Позволяет нашей службе поддержки временно входить в систему от вашего имени, чтобы быстро решать любые проблемы, о которых вы нам сообщаете.",
"team_impersonation_description": "Позволяет участникам вашей команды временно входить в систему под вашим именем.",
"allow_booker_to_select_duration": "Разрешить пользователю, оформляющему бронирование, выбирать длительность",
"impersonate_user_tip": "Все варианты использования этой функции проверяются.",
"impersonating_user_warning": "Олицетворение пользователя \"{{user}}\".",
"impersonating_stop_instructions": "<0>Нажмите здесь, чтобы остановить</0>.",
@@ -1019,6 +1032,9 @@
"error_removing_app": "Ошибка при удалении приложения",
"web_conference": "Веб-конференция",
"requires_confirmation": "Требуется подтверждение",
"always_requires_confirmation": "Всегда",
"requires_confirmation_threshold": "Требуется подтверждение, если бронирование оформляется с уведомлением < {{time}} $t({{unit}}_timeUnit)",
"may_require_confirmation": "Может потребоваться подтверждение",
"nr_event_type_one": "Типы событий: {{count}}",
"nr_event_type_other": "Типы событий: {{count}}",
"add_action": "Добавить действие",
@@ -1106,6 +1122,8 @@
"event_limit_tab_description": "Как часто можно забронировать для вас событие",
"event_advanced_tab_description": "Настройки календаря и ещё...",
"event_advanced_tab_title": "Расширенные",
"event_setup_multiple_duration_error": "Настройка события: чтобы выбрать несколько вариантов продолжительности, требуется как минимум 1 параметр.",
"event_setup_multiple_duration_default_error": "Настройка события: выберите допустимую продолжительность по умолчанию.",
"event_setup_booking_limits_error": "Лимиты бронирования должны указываться по возрастанию [день, неделя, месяц, год].",
"select_which_cal": "Выберите, в какой календарь добавить бронирование",
"custom_event_name": "Пользовательское имя события",
@@ -1160,8 +1178,10 @@
"invoices": "Счета на оплату",
"embeds": "Встраивание",
"impersonation": "Разрешить вход под вашим именем",
"impersonation_description": "Настройки для управления входа в аккаунт от имени пользователя",
"users": "Пользователи",
"profile_description": "Настройка вашего профиля в {{appName}}",
"users_description": "Здесь представлен список всех пользователей",
"users_listing": "Список пользователей",
"general_description": "Настройте язык и часовой пояс",
"calendars_description": "Настройка взаимодействия типов событий с календарями",
@@ -1367,6 +1387,7 @@
"number_sms_notifications": "Номер телефона (SMS-уведомления)",
"attendee_email_workflow": "Электронная почта участника",
"attendee_email_info": "Электронная почта участника, на которого оформляется бронирование",
"kbar_search_placeholder": "Введите команду или начните поиск...",
"invalid_credential": "Похоже, разрешение истекло или было отозвано. Необходима переустановка.",
"choose_common_schedule_team_event": "Выберите общее расписание",
"choose_common_schedule_team_event_description": "Позволяет организаторам использовать общее расписание. Если общее расписание отключено, то для каждого организатора бронирование выполняется в соответствии с его индивидуальным расписанием по умолчанию.",
@@ -1378,18 +1399,42 @@
"route_to": "Цель маршрута",
"test_preview_description": "Проверить форму маршрутизации без отправки данных",
"test_routing": "Проверить машрутизацию",
"payment_app_disabled": "Администратор отключил платёжное приложение",
"edit_event_type": "Редактировать тип мероприятия",
"collective_scheduling": "Совместное планирование",
"make_it_easy_to_book": "Оформляйте бронирование для команды, когда все пользователи доступны. Теперь это несложно.",
"find_the_best_person": "Выберите наиболее подходящего сотрудника из команды.",
"fixed_round_robin": "Фиксированный раунд",
"add_one_fixed_attendee": "Добавьте одного фиксированного участника и общайтесь с другими участниками по кругу.",
"calcom_is_better_with_team": "Cal.com отлично подходит для командной работы",
"add_your_team_members": "Добавьте участников команды к типам мероприятий. Благодаря совместному планированию можно включить в мероприятие всех пользователей или найти наиболее подходящего пользователя с циклическим планированием.",
"booking_limit_reached": "Достигнут лимит бронирования для этого типа события",
"admin_has_disabled": "Администратор отключил {{appName}}",
"disabled_app_affects_event_type": "Администратор отключил {{appName}}, которое влияет на тип мероприятия {{eventType}}",
"disable_payment_app": "Администратор отключил приложение {{appName}}, которое влияет на тип вашего мероприятия {{title}}. Участники все еще могут забронировать этот тип мероприятия, но они не получат собщение о том, что нужно произвести оплату. Чтобы этого избежать, можно скрыть тип мероприятия, пока администратор не активирует способ оплаты снова.",
"payment_disabled_still_able_to_book": "Участники все еще могут забронировать этот тип мероприятия, но они не получат сообщение о том, что нужно произвести оплату. Чтобы этого избежать, можно скрыть тип мероприятия, пока администратор не активирует способ оплаты снова.",
"app_disabled_with_event_type": "Администратор отключил {{appName}}, которое влияет на тип мероприятия {{title}}.",
"app_disabled_video": "Администратор отключил приложение {{appName}}, которое влияет на типы мероприятий. Если у вас есть типы мероприятий с {{appName}} в качестве места, то по умолчанию будет использоваться Cal Video.",
"app_disabled_subject": "{{appName}} отключено",
"navigate_installed_apps": "Перейти к установленным приложениям",
"disabled_calendar": "Если у вас установлен другой календарь, в него будут добавляться новые бронирования. Если у вас нет установленного календаря, подключите новый календарь, чтобы не пропускать новые бронирования.",
"enable_apps": "Включить приложения",
"enable_apps_description": "Включить приложения, которые пользователи могут интегрировать с Cal.com",
"app_is_enabled": "{{appName}} включено",
"app_is_disabled": "{{appName}} отключено",
"keys_have_been_saved": "Ключи сохранены",
"disable_app": "Отключить приложение",
"disable_app_description": "При отключении этого приложения у пользователей могут возникнуть проблемы с взаимодействием с Cal",
"edit_keys": "Изменить ключи",
"no_available_apps": "Нет доступных приложений",
"no_available_apps_description": "Убедитесь, что в вашей установке в 'packages/app-store' есть приложения",
"no_apps": "В этом экземпляре Cal нет включенных приложений",
"apps_settings": "Настройки приложений",
"fill_this_field": "Заполните это поле",
"options": "Настройки",
"enter_option": "Введите параметр {{index}}",
"add_an_option": "Добавить параметр",
"radio": "Переключатель",
"event_type_duplicate_copy_text": "{{slug}}- копия"
"event_type_duplicate_copy_text": "{{slug}}- копия",
"set_as_default": "Использовать по умолчанию"
}
@@ -611,7 +611,7 @@
"monthly_other": "月",
"yearly_one": "年",
"yearly_other": "年",
"plus_more": "多 {{count}} 個",
"plus_more": "另外 {{count}} 項",
"max": "最大",
"single_theme": "主題",
"brand_color": "品牌顏色",
-13
View File
@@ -1,13 +0,0 @@
@font-face {
font-family: "Cal Sans";
src: url("/fonts/cal.ttf");
}
@font-face {
font-family: "Inter var";
font-weight: 100 900;
font-display: swap;
font-style: normal;
font-named-instance: "Regular";
src: url("/fonts/Inter-roman.var.woff2") format("woff2");
}
-18
View File
@@ -215,17 +215,6 @@ button[role="switch"][data-state="checked"] {
background: black;
}
/* add padding bottom to bottom nav on standalone mode */
@media all and (display-mode: standalone) {
.bottom-nav {
padding-bottom: 24px;
}
.cta {
bottom: 100px;
}
}
/* hide chat bubble on mobile */
@media only screen and (max-width: 768px) {
/* Intercom FAB*/
@@ -247,13 +236,6 @@ button[role="switch"][data-state="checked"] {
}
}
/* add padding bottom to bottom nav on standalone mode */
@media all and (display-mode: standalone) {
.bottom-nav {
padding-bottom: 24px;
}
}
.react-multi-email > [type="text"] {
@apply dark:placeholder:text-darkgray-600 focus:border-brand dark:border-darkgray-300 dark:text-darkgray-900 block w-full rounded-md border-gray-300 text-sm focus:ring-black disabled:bg-gray-200 disabled:hover:cursor-not-allowed dark:bg-transparent dark:selection:bg-green-500 disabled:dark:text-gray-500 dark:text-white;
}
@@ -28,8 +28,8 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
switchChecked={enabled}>
<div className="mt-2 text-sm">
<div className="flex">
<span className="mr-2">{isSunrise ? <Icon.FiSunrise /> : <Icon.FiSunset />}</span>I am an AppCard
for Event with Title: {eventType.title}
<span className="ltr:mr-2 rtl:ml-2">{isSunrise ? <Icon.FiSunrise /> : <Icon.FiSunset />}</span>I am
an AppCard for Event with Title: {eventType.title}
</div>{" "}
<div className="mt-2">
Edit <span className="italic">packages/app-store/{app.slug}/extensions/EventTypeAppCard.tsx</span>{" "}
@@ -83,7 +83,7 @@ export default function AppleCalendarSetup() {
</fieldset>
{errorMessage && <Alert severity="error" title={errorMessage} className="my-4" />}
<div className="mt-5 justify-end space-x-2 sm:mt-4 sm:flex">
<div className="mt-5 justify-end space-x-2 rtl:space-x-reverse sm:mt-4 sm:flex">
<Button type="button" color="secondary" onClick={() => router.back()}>
{t("cancel")}
</Button>
@@ -100,7 +100,7 @@ export default function CalDavCalendarSetup() {
className="my-4"
/>
)}
<div className="mt-5 justify-end space-x-2 sm:mt-4 sm:flex">
<div className="mt-5 justify-end space-x-2 rtl:space-x-reverse sm:mt-4 sm:flex">
<Button type="button" color="secondary" onClick={() => router.back()}>
{t("cancel")}
</Button>
@@ -98,7 +98,7 @@ export default function CloseComSetup() {
)}
/>
</fieldset>
<div className="mt-5 justify-end space-x-2 sm:mt-4 sm:flex">
<div className="mt-5 justify-end space-x-2 rtl:space-x-reverse sm:mt-4 sm:flex">
<Button type="button" color="secondary" onClick={() => router.back()}>
{t("cancel")}
</Button>
@@ -1,6 +1,7 @@
import { z } from "zod";
import { handleErrorsJson } from "@calcom/lib/errors";
import { GetRecordingsResponseSchema, getRecordingsResponseSchema } from "@calcom/prisma/zod-utils";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { CredentialPayload } from "@calcom/types/Credential";
import type { PartialReference } from "@calcom/types/EventManager";
@@ -62,7 +63,7 @@ export const FAKE_DAILY_CREDENTIAL: CredentialPayload & { invalid: boolean } = {
invalid: false,
};
const fetcher = async (endpoint: string, init?: RequestInit | undefined) => {
export const fetcher = async (endpoint: string, init?: RequestInit | undefined) => {
const { api_key } = await getDailyAppKeys();
return fetch(`https://api.daily.co/v1${endpoint}`, {
method: "GET",
@@ -87,9 +88,8 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
if (!event.uid) {
throw new Error("We need need the booking uid to create the Daily reference in DB");
}
const dailyEvent = await postToDailyAPI(endpoint, translateEvent(event)).then(
dailyReturnTypeSchema.parse
);
const body = await translateEvent(event);
const dailyEvent = await postToDailyAPI(endpoint, body).then(dailyReturnTypeSchema.parse);
const meetingToken = await postToDailyAPI("/meeting-tokens", {
properties: { room_name: dailyEvent.name, is_owner: true },
}).then(meetingTokenSchema.parse);
@@ -102,11 +102,11 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
});
}
const translateEvent = (event: CalendarEvent) => {
const translateEvent = async (event: CalendarEvent) => {
// Documentation at: https://docs.daily.co/reference#list-rooms
// added a 1 hour buffer for room expiration
const exp = Math.round(new Date(event.endTime).getTime() / 1000) + 60 * 60;
const scalePlan = process.env.DAILY_SCALE_PLAN;
const { scale_plan: scalePlan } = await getDailyAppKeys();
if (scalePlan === "true") {
return {
@@ -118,7 +118,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
enable_screenshare: true,
enable_chat: true,
exp: exp,
enable_recording: "local",
enable_recording: "cloud",
},
};
}
@@ -148,6 +148,16 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
},
updateMeeting: (bookingRef: PartialReference, event: CalendarEvent): Promise<VideoCallData> =>
createOrUpdateMeeting(`/rooms/${bookingRef.uid}`, event),
getRecordings: async (roomName: string): Promise<GetRecordingsResponseSchema> => {
try {
const res = await fetcher(`/recordings?room_name=${roomName}`).then(
getRecordingsResponseSchema.parse
);
return Promise.resolve(res);
} catch (err) {
throw new Error("Something went wrong! Unable to get recording");
}
},
};
};
@@ -4,6 +4,7 @@ import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
const dailyAppKeysSchema = z.object({
api_key: z.string(),
scale_plan: z.string().default("false"),
});
export const getDailyAppKeys = async () => {
+1 -1
View File
@@ -2,7 +2,7 @@ import { z } from "zod";
export const appKeysSchema = z.object({
api_key: z.string().min(1),
scale_plan: z.string(),
scale_plan: z.string().default("false"),
});
export const appDataSchema = z.object({});
@@ -71,7 +71,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
if (!form) {
throw new Error("Form not found");
}
const serializableForm = getSerializableForm(form, true);
const serializableForm = await getSerializableForm(form, true);
res.setHeader("Content-Type", "text/csv; charset=UTF-8");
res.setHeader(
"Content-Disposition",
@@ -2,6 +2,7 @@ import type { NextRouter } from "next/router";
import { useRouter } from "next/router";
import { createContext, forwardRef, useContext, useState } from "react";
import { useForm } from "react-hook-form";
import { Controller } from "react-hook-form";
import { v4 as uuidv4 } from "uuid";
import { z } from "zod";
@@ -26,6 +27,7 @@ import {
Switch,
TextAreaField,
TextField,
SettingsToggle,
} from "@calcom/ui";
import { EmbedButton, EmbedDialog } from "@components/Embed";
@@ -77,6 +79,7 @@ function NewFormDialog({ appUrl }: { appUrl: string }) {
const hookForm = useForm<{
name: string;
description: string;
shouldConnect: boolean;
}>();
const { action, target } = router.query as z.infer<typeof newFormModalQuerySchema>;
@@ -97,6 +100,7 @@ function NewFormDialog({ appUrl }: { appUrl: string }) {
form={hookForm}
handleSubmit={(values) => {
const formId = uuidv4();
mutation.mutate({
id: formId,
...values,
@@ -115,6 +119,23 @@ function NewFormDialog({ appUrl }: { appUrl: string }) {
placeholder="Form Description"
/>
</div>
{action === "duplicate" && (
<Controller
name="shouldConnect"
render={({ field: { value, onChange } }) => {
return (
<SettingsToggle
title="Keep me connected with the form"
description="Any changes in Router and Fields of the form being duplicated, would reflect in the duplicate."
checked={value}
onCheckedChange={(checked) => {
onChange(checked);
}}
/>
);
}}
/>
)}
</div>
<div className="mt-8 flex flex-row-reverse gap-x-2">
<Button loading={mutation.isLoading} data-testid="add-form" type="submit">
@@ -186,7 +207,7 @@ function Dialogs({
if (context?.previousValue) {
utils.viewer.appRoutingForms.forms.setData(undefined, context.previousValue);
}
showToast("Something went wrong", "error");
showToast(err.message || "Something went wrong", "error");
},
});
return (
@@ -1,6 +1,7 @@
import { App_RoutingForms_Form } from "@prisma/client";
import { Dispatch, SetStateAction } from "react";
import isRouterLinkedField from "../lib/isRouterLinkedField";
import { getQueryBuilderConfig } from "../pages/route-builder/[...appPages]";
import { SerializableForm, Response } from "../types/types";
@@ -18,6 +19,9 @@ export default function FormInputFields(props: Props) {
return (
<>
{form.fields?.map((field) => {
if (isRouterLinkedField(field)) {
field = field.routerField;
}
const widget = queryBuilderConfig.widgets[field.type];
if (!("factory" in widget)) {
return null;
@@ -1,4 +1,5 @@
import { App_RoutingForms_Form } from "@prisma/client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { Controller, useForm, UseFormReturn } from "react-hook-form";
@@ -12,8 +13,9 @@ import {
AppUser,
} from "@calcom/types/AppGetServerSideProps";
import {
Banner,
Button,
Banner,
Badge,
ButtonGroup,
Dialog,
DialogClose,
@@ -42,7 +44,8 @@ import FormInputFields from "./FormInputFields";
import RoutingNavBar from "./RoutingNavBar";
type RoutingForm = SerializableForm<App_RoutingForms_Form>;
type RoutingFormWithResponseCount = RoutingForm & {
export type RoutingFormWithResponseCount = RoutingForm & {
_count: {
responses: number;
};
@@ -236,13 +239,19 @@ function SingleForm({ form, appUrl, Page }: SingleFormComponentProps) {
onSuccess() {
showToast("Form updated successfully.", "success");
},
onError() {
onError(e) {
if (e.message) {
showToast(e.message, "error");
return;
}
showToast(`Something went wrong`, "error");
},
onSettled() {
utils.viewer.appRoutingForms.formQuery.invalidate({ id: form.id });
},
});
const connectedForms = form.connectedForms;
return (
<>
<Form
@@ -295,6 +304,55 @@ function SingleForm({ form, appUrl, Page }: SingleFormComponentProps) {
}}
/>
</div>
{form.routers.length ? (
<div className="mt-6">
<div className="mb-2 block text-sm font-semibold leading-none text-black ">
Routers
</div>
<p className="-mt-1 text-xs leading-normal text-gray-600">
Modifications in fields and routes of following forms will be reflected in this form.
</p>
<div className="flex">
{form.routers.map((router) => {
return (
<div key={router.id} className="mr-2">
<Link href={`/${appUrl}/route-builder/${router.id}`}>
<a>
<Badge variant="gray">{router.name}</Badge>
</a>
</Link>
</div>
);
})}
</div>
</div>
) : null}
{connectedForms?.length ? (
<div className="mt-6">
<div className="mb-2 block text-sm font-semibold leading-none text-black ">
Connected Forms
</div>
<p className="-mt-1 text-xs leading-normal text-gray-600">
Following forms would be affected when you modify fields or routes here
</p>
<div className="flex">
{connectedForms.map((router) => {
return (
<div key={router.id} className="mr-2">
<Link href={`/${appUrl}/route-builder/${router.id}`}>
<a>
<Badge variant="default">{router.name}</Badge>
</a>
</Link>
</div>
);
})}
</div>
</div>
) : null}
<div className="mt-6">
<Button
color="secondary"
@@ -452,8 +510,8 @@ export const getServerSidePropsForSingleFormView = async function getServerSideP
};
}
const isAllowed = (await import("../lib/isAllowed")).isAllowed;
if (!(await isAllowed({ userId: user.id, formId }))) {
const isFormEditAllowed = (await import("../lib/isFormEditAllowed")).isFormEditAllowed;
if (!(await isFormEditAllowed({ userId: user.id, formId }))) {
return {
notFound: true,
};
@@ -480,7 +538,7 @@ export const getServerSidePropsForSingleFormView = async function getServerSideP
return {
props: {
trpcState: ssr.dehydrate(),
form: getSerializableForm(form),
form: await getSerializableForm(form),
},
};
};
@@ -1,2 +0,0 @@
export { default as InstallAppButton } from "./InstallAppButton";
export { default as Icon } from "./icon";
@@ -0,0 +1,16 @@
import { Utils as QbUtils } from "react-awesome-query-builder";
import { GlobalRoute, SerializableRoute } from "../types/types";
export const createFallbackRoute = (): Exclude<SerializableRoute, GlobalRoute> => {
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" },
};
};
@@ -0,0 +1,20 @@
import { App_RoutingForms_Form } from "@calcom/prisma/client";
export default async function getConnectedForms(
prisma: typeof import("@calcom/prisma").default,
form: Pick<App_RoutingForms_Form, "id" | "userId">
) {
return await prisma.app_RoutingForms_Form.findMany({
where: {
userId: form.userId,
routes: {
array_contains: [
{
id: form.id,
isRouter: true,
},
],
},
},
});
}
@@ -1,26 +1,33 @@
import { App_RoutingForms_Form } from "@prisma/client";
import { z } from "zod";
import { RoutingFormSettings } from "@calcom/prisma/zod-utils";
import { SerializableForm } from "../types/types";
import { zodFields, zodRoutes } from "../zod";
import { zodFields, zodRoutes, zodRoutesView, zodFieldsView } from "../zod";
import getConnectedForms from "./getConnectedForms";
import isRouter from "./isRouter";
import isRouterLinkedField from "./isRouterLinkedField";
/**
* Doesn't have deleted fields by default
*/
export function getSerializableForm<TForm extends App_RoutingForms_Form>(
export async function getSerializableForm<TForm extends App_RoutingForms_Form>(
form: TForm,
withDeletedFields = false
) {
const prisma = (await import("@calcom/prisma")).default;
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");
throw new Error("Error parsing fields" + fieldsParsed.error);
}
const settings = RoutingFormSettings.parse(
form.settings || {
// Would have really loved to do it using zod. But adding .default(true) throws type error in prisma/zod/app_routingforms_form.ts
@@ -28,18 +35,102 @@ export function getSerializableForm<TForm extends App_RoutingForms_Form>(
}
);
// Ideally we shouldb't have needed to explicitly type it but due to some reason it's not working reliably with VSCode TypeCheck
const parsedFields =
(withDeletedFields ? fieldsParsed.data : fieldsParsed.data?.filter((f) => !f.deleted)) || [];
const parsedRoutes = routesParsed.data;
const fields = parsedFields as NonNullable<z.infer<typeof zodFieldsView>>;
const fieldsExistInForm: Record<string, true> = {};
parsedFields?.forEach((f) => {
fieldsExistInForm[f.id] = true;
});
const { routes, routers } = await getEnrichedRoutesAndRouters(parsedRoutes);
const connectedForms = (await getConnectedForms(prisma, form)).map((f) => ({
id: f.id,
name: f.name,
description: f.description,
}));
const finalFields = fields;
// Ideally we should't have needed to explicitly type it but due to some reason it's not working reliably with VSCode TypeCheck
const serializableForm: SerializableForm<TForm> = {
...form,
settings: settings,
fields: fieldsParsed.data
? withDeletedFields
? fieldsParsed.data
: fieldsParsed.data.filter((f) => !f.deleted)
: [],
routes: routesParsed.data,
settings,
fields: finalFields,
routes,
routers,
connectedForms,
createdAt: form.createdAt.toString(),
updatedAt: form.updatedAt.toString(),
};
return serializableForm;
/**
* Enriches routes that are actually routers and returns a list of routers separately
*/
async function getEnrichedRoutesAndRouters(parsedRoutes: z.infer<typeof zodRoutes>) {
const routers: { name: string; description: string | null; id: string }[] = [];
const routes: z.infer<typeof zodRoutesView> = [];
if (!parsedRoutes) {
return { routes, routers };
}
for (const [, route] of Object.entries(parsedRoutes)) {
if (isRouter(route)) {
const router = await prisma.app_RoutingForms_Form.findFirst({
where: {
id: route.id,
userId: form.userId,
},
});
if (!router) {
throw new Error("Form -" + route.id + ", being used as router, not found");
}
const parsedRouter = await getSerializableForm(router, false);
routers.push({
name: parsedRouter.name,
description: parsedRouter.description,
id: parsedRouter.id,
});
// Enrichment
routes.push({
...route,
isRouter: true,
name: parsedRouter.name,
description: parsedRouter.description,
routes: parsedRouter.routes || [],
});
parsedRouter.fields?.forEach((field) => {
if (!fieldsExistInForm[field.id]) {
// Instead of throwing error, Log it instead of breaking entire routing forms feature
console.error(
"This is an impossible state. A router field must always be present in the connected form."
);
} else {
const currentFormField = fields.find((f) => f.id === field.id);
if (!currentFormField || !("routerId" in currentFormField)) {
return;
}
if (!isRouterLinkedField(field)) {
currentFormField.routerField = field;
}
currentFormField.router = {
id: parsedRouter.id,
name: router.name,
description: router.description || "",
};
}
});
} else {
routes.push(route);
}
}
return { routes, routers };
}
}
@@ -0,0 +1,11 @@
import { z } from "zod";
import { zodRoute } from "../zod";
import isRouter from "./isRouter";
export const isFallbackRoute = (route: z.infer<typeof zodRoute>) => {
if (isRouter(route)) {
return false;
}
return route.isFallback;
};
@@ -2,7 +2,7 @@ import prisma from "@calcom/prisma";
import { App_RoutingForms_Form, User } from ".prisma/client";
export async function isAllowed({
export async function isFormEditAllowed({
userId,
formId,
}: {
@@ -0,0 +1,12 @@
import { z } from "zod";
import { zodRouterRouteView, zodRoute, zodRouterRoute, zodRouteView } from "../zod";
export default function isRouter(
route: z.infer<typeof zodRouteView> | z.infer<typeof zodRoute>
): route is z.infer<typeof zodRouterRouteView> | z.infer<typeof zodRouterRoute> {
if ("isRouter" in route) {
return route.isRouter;
}
return false;
}
@@ -0,0 +1,12 @@
import { z } from "zod";
import { zodFieldView, zodField, zodRouterField, zodRouterFieldView } from "../zod";
export default function isRouterLinkedField(
field: z.infer<typeof zodFieldView> | z.infer<typeof zodField>
): field is z.infer<typeof zodRouterField> | z.infer<typeof zodRouterFieldView> {
if ("routerId" in field) {
return true;
}
return false;
}
@@ -1,8 +1,12 @@
import jsonLogic from "json-logic-js";
import { Utils as QbUtils } from "react-awesome-query-builder";
import { z } from "zod";
import { getQueryBuilderConfig } from "../pages/route-builder/[...appPages]";
import { Response, Route, SerializableForm } from "../types/types";
import { zodNonRouterRoute } from "../zod";
import { isFallbackRoute } from "./isFallbackRoute";
import isRouter from "./isRouter";
import { App_RoutingForms_Form } from ".prisma/client";
export function processRoute({
@@ -18,15 +22,24 @@ export function processRoute({
let decidedAction: Route["action"] | null = null;
const fallbackRoute = routes.find((route) => route.isFallback);
const fallbackRoute = routes.find(isFallbackRoute);
if (!fallbackRoute) {
throw new Error("Fallback route is missing");
}
const reorderedRoutes = routes.filter((route) => !route.isFallback).concat([fallbackRoute]);
const routesWithFallbackInEnd = routes
.flatMap((r) => {
// For a router, use it's routes instead.
if (isRouter(r)) return r.routes;
return r;
})
// Use only non fallback routes
.filter((route) => route && !isFallbackRoute(route))
// After above flat map, all routes are non router routes.
.concat([fallbackRoute]) as z.infer<typeof zodNonRouterRoute>[];
reorderedRoutes.some((route) => {
routesWithFallbackInEnd.some((route) => {
if (!route) {
return false;
}
@@ -1,5 +1,4 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { App_RoutingForms_Form } from "@prisma/client";
import { useEffect, useState } from "react";
import { Controller, useFieldArray, UseFormReturn } from "react-hook-form";
import { v4 as uuidv4 } from "uuid";
@@ -21,17 +20,12 @@ import { inferSSRProps } from "@lib/types/inferSSRProps";
import SingleForm, {
getServerSidePropsForSingleFormView as getServerSideProps,
RoutingFormWithResponseCount,
} from "../../components/SingleForm";
import { SerializableForm } from "../../types/types";
export { getServerSideProps };
type RoutingForm = SerializableForm<App_RoutingForms_Form>;
type RoutingFormWithResponseCount = RoutingForm & {
_count: {
responses: number;
};
};
type HookForm = UseFormReturn<RoutingFormWithResponseCount>;
export const FieldTypes = [
{
label: "Short Text",
@@ -70,6 +64,7 @@ function Field({
fieldIndex,
moveUp,
moveDown,
appUrl,
}: {
fieldIndex: number;
hookForm: HookForm;
@@ -86,6 +81,7 @@ function Field({
check: () => boolean;
fn: () => void;
};
appUrl: string;
}) {
const [identifier, _setIdentifier] = useState(hookForm.getValues(`${hookFieldNamespace}.identifier`));
@@ -102,7 +98,8 @@ function Field({
_setIdentifier(label);
}
}, [label, hookFieldNamespace, hookForm]);
const router = hookForm.getValues(`${hookFieldNamespace}.router`);
const routerField = hookForm.getValues(`${hookFieldNamespace}.routerField`);
return (
<div
data-testid="field"
@@ -111,13 +108,22 @@ function Field({
label={label || `Field ${fieldIndex + 1}`}
moveUp={moveUp}
moveDown={moveDown}
deleteField={deleteField}>
badge={
router ? { text: router.name, variant: "gray", href: `${appUrl}/form-edit/${router.id}` } : null
}
deleteField={router ? null : deleteField}>
<div className="w-full">
<div className="mb-6 w-full">
<TextField
disabled={!!router}
label="Label"
type="text"
placeholder="This is what your users would see"
/**
* This is a bit of a hack to make sure that for routerField, label is shown from there.
* For other fields, value property is used because it exists and would take precedence
*/
defaultValue={routerField?.label}
required
{...hookForm.register(`${hookFieldNamespace}.label`)}
className="block w-full rounded-sm border-gray-300 text-sm"
@@ -125,11 +131,13 @@ function Field({
</div>
<div className="mb-6 w-full">
<TextField
disabled={!!router}
label="Identifier"
name="identifier"
required
placeholder="Identifies field by this name."
value={identifier}
defaultValue={routerField?.identifier || routerField?.label}
onChange={(e) => setUserChangedIdentifier(e.target.value)}
className="block w-full rounded-sm border-gray-300 text-sm"
/>
@@ -138,11 +146,13 @@ function Field({
<Controller
name={`${hookFieldNamespace}.type`}
control={hookForm.control}
defaultValue={routerField?.type}
render={({ field: { value, onChange } }) => {
const defaultValue = FieldTypes.find((fieldType) => fieldType.value === value);
return (
<SelectField
label="Type"
isDisabled={!!router}
containerClassName="data-testid-field-type"
options={FieldTypes}
onChange={(option) => {
@@ -161,8 +171,10 @@ function Field({
<div className="mt-2 block items-center sm:flex">
<div className="w-full">
<TextAreaField
disabled={!!router}
rows={3}
label="Options"
defaultValue={routerField?.selectText}
placeholder="Add 1 option per line"
{...hookForm.register(`${hookFieldNamespace}.selectText`)}
/>
@@ -174,8 +186,16 @@ function Field({
<Controller
name={`${hookFieldNamespace}.required`}
control={hookForm.control}
defaultValue={routerField?.required}
render={({ field: { value, onChange } }) => {
return <BooleanToggleGroupField label="Required" value={value} onValueChange={onChange} />;
return (
<BooleanToggleGroupField
disabled={!!router}
label="Required"
value={value}
onValueChange={onChange}
/>
);
}}
/>
</div>
@@ -184,12 +204,15 @@ function Field({
</div>
);
}
const FormEdit = ({
hookForm,
form,
appUrl,
}: {
hookForm: HookForm;
form: inferSSRProps<typeof getServerSideProps>["form"];
appUrl: string;
}) => {
const fieldsNamespace = "fields";
const {
@@ -228,6 +251,7 @@ const FormEdit = ({
{hookFormFields.map((field, key) => {
return (
<Field
appUrl={appUrl}
fieldIndex={key}
hookForm={hookForm}
hookFieldNamespace={`${fieldsNamespace}.${key}`}
@@ -272,14 +296,18 @@ const FormEdit = ({
</div>
</div>
) : (
<button data-testid="add-field" onClick={addField} className="w-full">
<div className="w-full">
<EmptyScreen
Icon={Icon.FiFileText}
headline="Create your first field"
description="Fields are the form fields that the booker would see."
buttonRaw={<Button>Create Field</Button>}
buttonRaw={
<Button data-testid="add-field" onClick={addField}>
Create Field
</Button>
}
/>
</button>
</div>
);
};
@@ -291,7 +319,7 @@ export default function FormEditPage({
<SingleForm
form={form}
appUrl={appUrl}
Page={({ hookForm, form }) => <FormEdit hookForm={hookForm} form={form} />}
Page={({ hookForm, form }) => <FormEdit appUrl={appUrl} hookForm={hookForm} form={form} />}
/>
);
}
@@ -36,7 +36,12 @@ export default function RoutingForms({
function NewFormButton() {
return (
<FormAction routingForm={null} data-testid="new-routing-form" StartIcon={Icon.FiPlus} action="create">
<FormAction
size="fab"
routingForm={null}
data-testid="new-routing-form"
StartIcon={Icon.FiPlus}
action="create">
{t("new")}
</FormAction>
);
@@ -210,7 +215,10 @@ export const getServerSideProps = async function getServerSideProps(
},
});
const serializableForms = forms.map((form) => getSerializableForm(form));
const serializableForms = [];
for (const [, form] of Object.entries(forms)) {
serializableForms.push(await getSerializableForm(form));
}
return {
props: {
@@ -1,20 +1,21 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { App_RoutingForms_Form } from "@prisma/client";
import Link from "next/link";
import React, { useCallback, useState } from "react";
import { Builder, Config, Query, Utils as QbUtils } from "react-awesome-query-builder";
import { Query, Config, Builder, Utils as QbUtils } from "react-awesome-query-builder";
// types
import { BuilderProps, ImmutableTree, JsonTree } from "react-awesome-query-builder";
import { JsonTree, ImmutableTree, BuilderProps } from "react-awesome-query-builder";
import { trpc } from "@calcom/trpc/react";
import { inferSSRProps } from "@calcom/types/inferSSRProps";
import {
Button,
SelectField,
FormCard,
Icon,
SelectWithValidation as Select,
Shell,
TextArea,
TextField,
Badge,
} from "@calcom/ui";
import SingleForm, {
@@ -22,7 +23,9 @@ import SingleForm, {
} from "../../components/SingleForm";
import QueryBuilderInitialConfig from "../../components/react-awesome-query-builder/config/config";
import "../../components/react-awesome-query-builder/styles.css";
import { SerializableForm } from "../../types/types";
import { createFallbackRoute } from "../../lib/createFallbackRoute";
import isRouter from "../../lib/isRouter";
import { GlobalRoute, LocalRoute, SerializableForm, SerializableRoute } from "../../types/types";
import { FieldTypes } from "../form-edit/[...appPages]";
export { getServerSideProps };
@@ -30,8 +33,10 @@ export { getServerSideProps };
type RoutingForm = SerializableForm<App_RoutingForms_Form>;
const InitialConfig = QueryBuilderInitialConfig;
const hasRules = (route: Route) =>
const hasRules = (route: Route) => {
if (isRouter(route)) return false;
route.queryValue.children1 && Object.keys(route.queryValue.children1).length;
};
type QueryBuilderUpdatedConfig = typeof QueryBuilderInitialConfig & { fields: Config["fields"] };
export function getQueryBuilderConfig(form: RoutingForm, forReporting = false) {
const fields: Record<
@@ -49,6 +54,9 @@ export function getQueryBuilderConfig(form: RoutingForm, forReporting = false) {
}
> = {};
form.fields?.forEach((field) => {
if ("routerField" in field) {
field = field.routerField;
}
if (FieldTypes.map((f) => f.value).includes(field.type)) {
const optionValues = field.selectText?.trim().split("\n");
const options = optionValues?.map((value) => {
@@ -90,7 +98,7 @@ export function getQueryBuilderConfig(form: RoutingForm, forReporting = false) {
return config;
}
const getEmptyRoute = (): SerializableRoute => {
const getEmptyRoute = (): Exclude<SerializableRoute, GlobalRoute> => {
const uuid = QbUtils.uuid();
return {
id: uuid,
@@ -102,41 +110,19 @@ const getEmptyRoute = (): SerializableRoute => {
};
};
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 =
| (LocalRoute & {
// This is what's persisted
queryValue: JsonTree;
// `queryValue` is parsed to create state
state: {
tree: ImmutableTree;
config: QueryBuilderUpdatedConfig;
};
})
| GlobalRoute;
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"];
};
export const RoutingPages: { label: string; value: Route["action"]["type"] }[] = [
export const RoutingPages: { label: string; value: NonNullable<LocalRoute["action"]>["type"] }[] = [
{
label: "Custom Page",
value: "customPageMessage",
@@ -159,6 +145,8 @@ const Route = ({
setRoutes,
moveUp,
moveDown,
appUrl,
disabled = false,
}: {
route: Route;
routes: Route[];
@@ -167,6 +155,8 @@ const Route = ({
setRoutes: React.Dispatch<React.SetStateAction<Route[]>>;
moveUp?: { fn: () => void; check: () => boolean } | null;
moveDown?: { fn: () => void; check: () => boolean } | null;
appUrl: string;
disabled?: boolean;
}) => {
const index = routes.indexOf(route);
@@ -202,6 +192,42 @@ const Route = ({
[]
);
if (isRouter(route)) {
return (
<div>
<FormCard
moveUp={moveUp}
moveDown={moveDown}
deleteField={{
check: () => routes.length !== 1,
fn: () => {
const newRoutes = routes.filter((r) => r.id !== route.id);
setRoutes(newRoutes);
},
}}
label={
<div>
<span className="mr-2">{`Route ${index + 1}`}</span>
</div>
}
className="mb-6">
<div className="-mt-3">
<Link href={`/${appUrl}/route-builder/${route.id}`}>
<a>
<Badge variant="gray">
<span className="font-semibold">{route.name}</span>
</Badge>
</a>
</Link>
<p className="mt-2 text-sm text-gray-500">
Fields available in <span className="font-bold">{route.name}</span> will be added to this form.
</p>
</div>
</FormCard>
</div>
);
}
return (
<FormCard
className="mb-6"
@@ -223,14 +249,15 @@ const Route = ({
<span>Send Booker to</span>
</div>
<Select
isDisabled={disabled}
className="block w-full flex-grow px-2"
required
value={RoutingPages.find((page) => page.value === route.action.type)}
value={RoutingPages.find((page) => page.value === route.action?.type)}
onChange={(item) => {
if (!item) {
return;
}
const action: Route["action"] = {
const action: LocalRoute["action"] = {
type: item.value,
value: "",
};
@@ -245,10 +272,11 @@ const Route = ({
}}
options={RoutingPages}
/>
{route.action.type ? (
route.action.type === "customPageMessage" ? (
{route.action?.type ? (
route.action?.type === "customPageMessage" ? (
<TextArea
required
disabled={disabled}
name="customPageMessage"
className="flex w-full flex-grow border-gray-300"
value={route.action.value}
@@ -256,8 +284,9 @@ const Route = ({
setRoute(route.id, { action: { ...route.action, value: e.target.value } });
}}
/>
) : route.action.type === "externalRedirectUrl" ? (
) : route.action?.type === "externalRedirectUrl" ? (
<TextField
disabled={disabled}
name="externalRedirectUrl"
className="flex w-full flex-grow border-gray-300 text-sm"
containerClassName="w-full mt-2"
@@ -274,6 +303,7 @@ const Route = ({
<div className="block w-full">
<Select
required
isDisabled={disabled}
options={eventOptions}
onChange={(option) => {
if (!option) {
@@ -308,7 +338,10 @@ const Route = ({
);
};
const deserializeRoute = (route: SerializableRoute, config: QueryBuilderUpdatedConfig): Route => {
const deserializeRoute = (
route: Exclude<SerializableRoute, GlobalRoute>,
config: QueryBuilderUpdatedConfig
): Route => {
return {
...route,
state: {
@@ -321,18 +354,22 @@ const deserializeRoute = (route: SerializableRoute, config: QueryBuilderUpdatedC
const Routes = ({
form,
hookForm,
appUrl,
}: {
form: inferSSRProps<typeof getServerSideProps>["form"];
// Figure out the type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
hookForm: any;
appUrl: string;
}) => {
const { routes: serializedRoutes } = form;
const config = getQueryBuilderConfig(form);
const [routes, setRoutes] = useState(() => {
const transformRoutes = () => {
const _routes = serializedRoutes || [getEmptyRoute()];
_routes.forEach((r) => {
if (isRouter(r)) return;
if (!r.queryValue?.id) {
r.queryValue = { id: QbUtils.uuid(), type: "group" };
}
@@ -340,13 +377,74 @@ const Routes = ({
return _routes;
};
return transformRoutes().map((route) => deserializeRoute(route, config));
return transformRoutes().map((route) => {
if (isRouter(route)) return route;
return deserializeRoute(route, config);
});
});
const { data: allForms } = trpc.viewer.appRoutingForms.forms.useQuery();
const availableRouters =
allForms
?.filter((router) => {
return router.id !== form.id;
})
.map((router) => {
return {
value: router.id,
label: router.name,
name: router.name,
description: router.description,
isDisabled: false,
};
}) || [];
const isConnectedForm = (id: string) => form.connectedForms.map((f) => f.id).includes(id);
const routerOptions = (
[
{
label: "Create a New Route",
value: "newRoute",
name: null,
description: null,
},
] as {
label: string;
value: string;
name: string | null;
description: string | null;
isDisabled?: boolean;
}[]
).concat(
availableRouters.map((r) => {
// Reset disabled state
r.isDisabled = false;
// Can't select a form as router that is already a connected form. It avoids cyclic dependency
if (isConnectedForm(r.value)) {
r.isDisabled = true;
}
// A route that's already used, can't be reselected
if (routes.find((route) => route.id === r.value)) {
r.isDisabled = true;
}
return r;
})
);
const [animationRef] = useAutoAnimate<HTMLDivElement>();
const mainRoutes = routes.filter((route) => !route.isFallback);
let fallbackRoute = routes.find((route) => route.isFallback);
const mainRoutes = routes.filter((route) => {
if (isRouter(route)) return true;
return !route.isFallback;
});
let fallbackRoute = routes.find((route) => {
if (isRouter(route)) return false;
return route.isFallback;
});
if (!fallbackRoute) {
fallbackRoute = deserializeRoute(createFallbackRoute(), config);
setRoutes((routes) => {
@@ -363,6 +461,7 @@ const Routes = ({
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];
@@ -380,19 +479,27 @@ const Routes = ({
});
};
const routesToSave: SerializableRoute[] = routes.map((route) => ({
id: route.id,
action: route.action,
isFallback: route.isFallback,
queryValue: route.queryValue,
}));
const routesToSave = routes.map((route) => {
if (isRouter(route)) {
return route;
}
return {
id: route.id,
action: route.action,
isFallback: route.isFallback,
queryValue: route.queryValue,
};
});
hookForm.setValue("routes", routesToSave);
return (
<div className="flex flex-col-reverse md:flex-row">
<div ref={animationRef} className="w-full ltr:mr-2 rtl:ml-2">
{mainRoutes.map((route, key) => {
return (
<Route
appUrl={appUrl}
key={route.id}
config={config}
route={route}
@@ -415,28 +522,50 @@ const Routes = ({
/>
);
})}
<Button
type="button"
className="mb-6"
color="secondary"
StartIcon={Icon.FiPlus}
data-testid="add-route"
onClick={() => {
const newEmptyRoute = getEmptyRoute();
const newRoutes = [
...routes,
{
...newEmptyRoute,
state: {
tree: QbUtils.checkTree(QbUtils.loadTree(newEmptyRoute.queryValue), config),
config,
<SelectField
placeholder="Select a router"
containerClassName="mb-6 data-testid-select-router"
isOptionDisabled={(option) => !!option.isDisabled}
label="Add a new Route"
options={routerOptions}
key={mainRoutes.length}
onChange={(option) => {
if (!option) {
return;
}
const router = option.value;
if (router === "newRoute") {
const newEmptyRoute = getEmptyRoute();
const newRoutes = [
...routes,
{
...newEmptyRoute,
state: {
tree: QbUtils.checkTree(QbUtils.loadTree(newEmptyRoute.queryValue), config),
config,
},
},
},
];
setRoutes(newRoutes);
}}>
Add Route
</Button>
];
setRoutes(newRoutes);
} else {
const routerId = router;
if (!routerId) {
return;
}
setRoutes([
...routes,
{
isRouter: true,
id: routerId,
name: option.name,
description: option.description,
} as Route,
]);
}
}}
/>
<div>
<Route
config={config}
@@ -444,6 +573,7 @@ const Routes = ({
routes={routes}
setRoute={setRoute}
setRoutes={setRoutes}
appUrl={appUrl}
/>
</div>
</div>
@@ -461,7 +591,7 @@ export default function RouteBuilder({
appUrl={appUrl}
Page={({ hookForm, form }) => (
<div className="route-config">
<Routes hookForm={hookForm} form={form} />
<Routes hookForm={hookForm} appUrl={appUrl} form={form} />
</div>
)}
/>
@@ -56,7 +56,7 @@ export const getServerSideProps = async function getServerSideProps(
notFound: true,
};
}
const serializableForm = getSerializableForm(form);
const serializableForm = await getSerializableForm(form);
const response: Record<string, Pick<Response[string], "value">> = {};
serializableForm.fields?.forEach((field) => {
@@ -199,7 +199,7 @@ export const getServerSideProps = async function getServerSideProps(
brandColor: form.user.brandColor,
darkBrandColor: form.user.darkBrandColor,
},
form: getSerializableForm(form),
form: await getSerializableForm(form),
},
};
};
@@ -2,88 +2,13 @@ import { expect, Page } from "@playwright/test";
import { Fixtures, test } from "@calcom/web/playwright/lib/fixtures";
async function gotoRoutingLink(page: Page, formId: string) {
await page.goto(`/forms/${formId}`);
// HACK: There seems to be some issue with the inputs to the form getting reset if we don't wait.
await new Promise((resolve) => setTimeout(resolve, 500));
}
export async function addForm(page: Page) {
await page.click('[data-testid="new-routing-form"]');
await page.fill("input[name]", "Test Form Name");
await page.click('[data-testid="add-form"]');
await page.waitForSelector('[data-testid="add-field"]');
const url = page.url();
const formId = new URL(url).pathname.split("/").at(-1);
if (!formId) {
throw new Error("Form ID couldn't be determined from url");
}
return formId;
}
async function 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,
};
}
export async function fillForm(
page: Page,
form: { description: string; field?: { typeIndex: number; label: string } }
) {
await page.click('[data-testid="add-field"]');
await page.fill('[data-testid="description"]', form.description);
// Verify all Options of SelectBox
const { optionsInUi: types } = await verifySelectOptions(
{ selector: ".data-testid-field-type", nth: 0 },
["Email", "Long Text", "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-field-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,
};
function todo(title: string) {
// eslint-disable-next-line playwright/no-skipped-test, @typescript-eslint/no-empty-function
test.skip(title, () => {});
}
test.describe("Routing Forms", () => {
test.afterEach(async ({ users }) => {
// This also delete forms on cascade
await users.deleteAll();
});
test.describe("Zero State Routing Forms", () => {
test.beforeEach(async ({ page, users }) => {
const user = await users.create({ username: "routing-forms" });
await user.login();
// Install app
await page.goto(`/apps/routing-forms`);
await page.click('[data-testid="install-app-button"]');
await page.waitForNavigation({
url: (url) => url.pathname === `/apps/routing-forms/forms`,
});
});
test("should be able to add a new form and view it", async ({ page }) => {
await page.waitForSelector('[data-testid="empty-screen"]');
@@ -107,7 +32,7 @@ test.describe("Routing Forms", () => {
});
test("should be able to edit the form", async ({ page }) => {
await addForm(page);
const formId = await addForm(page);
const description = "Test Description";
const field = {
@@ -115,7 +40,7 @@ test.describe("Routing Forms", () => {
typeIndex: 1,
};
const { types } = await fillForm(page, {
const { types } = await addOneFieldAndDescriptionAndSaveForm(formId, page, {
description,
field: field,
});
@@ -124,26 +49,91 @@ test.describe("Routing Forms", () => {
expect(await page.inputValue(`[data-testid="description"]`), description);
expect(await page.locator('[data-testid="field"]').count()).toBe(1);
expect(await page.inputValue('[name="fields.0.label"]')).toBe(field.label);
expect(await page.locator(".data-testid-field-type").first().locator("div").nth(1).innerText()).toBe(
types[field.typeIndex]
);
await expectCurrentFormToHaveFields(page, { 0: field }, types);
await page.click('[href*="/apps/routing-forms/route-builder/"]');
await page.click('[data-testid="add-route"]');
await selectNewRoute(page);
await page.click('[data-testid="add-rule"]');
await verifySelectOptions(
{
selector: ".rule-container .data-testid-field-select",
nth: 0,
},
[field.label],
page
);
await verifyFieldOptionsInRule([field.label], page);
});
test.describe("F1<-F2 Relationship", () => {
test("Create relationship by adding F1 as route.Editing F1 should update F2", async ({ page }) => {
const form1Id = await addForm(page, { name: "F1" });
const form2Id = await addForm(page, { name: "F2" });
await addOneFieldAndDescriptionAndSaveForm(form1Id, page, {
description: "Form 1 Description",
field: {
label: "F1 Field1",
typeIndex: 1,
},
});
const { types } = await addOneFieldAndDescriptionAndSaveForm(form2Id, page, {
description: "Form 2 Description",
field: {
label: "F2 Field1",
//TODO: Maybe choose some other type and choose type by it's name and not index
typeIndex: 1,
},
});
// Add F1 as Router to F2
await page.goto(`/apps/routing-forms/route-builder/${form2Id}`);
await selectNewRoute(page, {
// It should be F1. TODO: Verify that it's F1
routeSelectNumber: 2,
});
await saveCurrentForm(page);
// Expect F1 fields to be available in F2
await page.goto(`/apps/routing-forms/form-edit/${form2Id}`);
expect(await page.locator('[data-testid="field"]').count()).toBe(2);
await expectCurrentFormToHaveFields(page, { 1: { label: "F1 Field1", typeIndex: 1 } }, types);
// Add 1 more field in F1
await addOneFieldAndDescriptionAndSaveForm(form1Id, page, {
field: {
label: "F1 Field2",
typeIndex: 1,
},
});
await page.goto(`/apps/routing-forms/form-edit/${form2Id}`);
expect(await page.locator('[data-testid="field"]').count()).toBe(3);
await expectCurrentFormToHaveFields(page, { 2: { label: "F1 Field2", typeIndex: 1 } }, types);
});
todo("Create relationship by using duplicate with live connect");
});
// TODO: How to install the app just once?
test.beforeEach(async ({ page, users }) => {
const user = await users.create({ username: "routing-forms" });
await user.login();
// Install app
await page.goto(`/apps/routing-forms`);
await page.click('[data-testid="install-app-button"]');
await page.waitForNavigation({
url: (url) => url.pathname === `/apps/routing-forms/forms`,
});
});
test.afterEach(async ({ users }) => {
// This also delete forms on cascade
await users.deleteAll();
});
});
todo("should be able to duplicate form");
test.describe("Seeded Routing Form ", () => {
test.afterEach(async ({ users }) => {
// This also delete forms on cascade
await users.deleteAll();
});
const createUserAndLoginAndInstallApp = async function ({
users,
page,
@@ -324,6 +314,27 @@ test.describe("Routing Forms", () => {
});
});
});
async function expectCurrentFormToHaveFields(
page: Page,
fields: {
[key: number]: { label: string; typeIndex: number };
},
types: string[]
) {
for (const [index, field] of Object.entries(fields)) {
expect(await page.inputValue(`[name="fields.${index}.label"]`)).toBe(field.label);
expect(
await page
.locator(".data-testid-field-type")
.nth(+index)
.locator("div")
.nth(1)
.innerText()
).toBe(types[field.typeIndex]);
}
}
async function fillSeededForm(page: Page, routingFormId: string) {
await gotoRoutingLink(page, routingFormId);
await page.fill('[data-testid="form-field"]', "event-routing");
@@ -348,3 +359,127 @@ async function fillSeededForm(page: Page, routingFormId: string) {
await page.click('button[type="submit"]');
await page.isVisible("text=Custom Page Result");
}
export async function addForm(page: Page, { name = "Test Form Name" } = {}) {
await page.goto("/apps/routing-forms/forms");
await page.click('[data-testid="new-routing-form"]');
await page.fill("input[name]", name);
await page.click('[data-testid="add-form"]');
await page.waitForSelector('[data-testid="add-field"]');
const url = page.url();
const formId = new URL(url).pathname.split("/").at(-1);
if (!formId) {
throw new Error("Form ID couldn't be determined from url");
}
return formId;
}
export async function addOneFieldAndDescriptionAndSaveForm(
formId: string,
page: Page,
form: { description?: string; field?: { typeIndex: number; label: string } }
) {
await page.goto(`apps/routing-forms/form-edit/${formId}`);
await page.click('[data-testid="add-field"]');
if (form.description) {
await page.fill('[data-testid="description"]', form.description);
}
// Verify all Options of SelectBox
const { optionsInUi: types } = await verifySelectOptions(
{ selector: ".data-testid-field-type", nth: 0 },
["Email", "Long Text", "MultiSelect", "Number", "Phone", "Select", "Short Text"],
page
);
const nextFieldIndex = (await page.locator('[data-testid="field"]').count()) - 1;
if (form.field) {
await page.fill(`[name="fields.${nextFieldIndex}.label"]`, form.field.label);
await page
.locator('[data-testid="field"]')
.nth(nextFieldIndex)
.locator(".data-testid-field-type")
.click();
await page
.locator('[data-testid="field"]')
.nth(nextFieldIndex)
.locator('[id*="react-select-"][aria-disabled]')
.nth(form.field.typeIndex)
.click();
}
await saveCurrentForm(page);
return {
types,
};
}
async function selectOption({
page,
selector,
option,
}: {
page: Page;
selector: { selector: string; nth: number };
option: number;
}) {
const locatorForSelect = page.locator(selector.selector).nth(selector.nth);
await locatorForSelect.click();
await locatorForSelect
.locator('[id*="react-select-"][aria-disabled]')
.nth(option - 1)
.click();
}
async function verifyFieldOptionsInRule(options: string[], page: Page) {
await verifySelectOptions(
{
selector: ".rule-container .data-testid-field-select",
nth: 0,
},
options,
page
);
}
async function verifySelectOptions(
selector: { selector: string; nth: number },
expectedOptions: string[],
page: Page
) {
await page.locator(selector.selector).nth(selector.nth).click();
const selectOptions = await page
.locator(selector.selector)
.nth(selector.nth)
.locator('[id*="react-select-"][aria-disabled]')
.allInnerTexts();
const sortedSelectOptions = [...selectOptions].sort();
const sortedExpectedOptions = [...expectedOptions].sort();
expect(sortedSelectOptions).toEqual(sortedExpectedOptions);
return {
optionsInUi: selectOptions,
};
}
async function selectNewRoute(page: Page, { routeSelectNumber = 1 } = {}) {
await selectOption({
selector: {
selector: ".data-testid-select-router",
nth: 0,
},
option: routeSelectNumber,
page,
});
}
async function gotoRoutingLink(page: Page, formId: string) {
await page.goto(`/forms/${formId}`);
// HACK: There seems to be some issue with the inputs to the form getting reset if we don't wait.
await new Promise((resolve) => setTimeout(resolve, 500));
}
async function saveCurrentForm(page: Page) {
await page.click('[data-testid="update-form"]');
await page.waitForSelector(".data-testid-toast-success");
}
@@ -1,5 +1,4 @@
import { App_RoutingForms_Form, Prisma, User, WebhookTriggerEvents } from "@prisma/client";
import { v4 as uuidv4 } from "uuid";
import { z } from "zod";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
@@ -12,10 +11,15 @@ import { Ensure } from "@calcom/types/utils";
import ResponseEmail from "./emails/templates/response-email";
import { jsonLogicToPrisma } from "./jsonLogicToPrisma";
import { createFallbackRoute } from "./lib/createFallbackRoute";
import getConnectedForms from "./lib/getConnectedForms";
import { getSerializableForm } from "./lib/getSerializableForm";
import { isAllowed } from "./lib/isAllowed";
import { isFallbackRoute } from "./lib/isFallbackRoute";
import { isFormEditAllowed } from "./lib/isFormEditAllowed";
import isRouter from "./lib/isRouter";
import isRouterLinkedField from "./lib/isRouterLinkedField";
import { Response, SerializableForm } from "./types/types";
import { zodFields, zodRoutes } from "./zod";
import { zodFields, zodRouterRoute, zodRoutes } from "./zod";
async function onFormSubmission(
form: Ensure<SerializableForm<App_RoutingForms_Form> & { user: User }, "fields">,
@@ -106,7 +110,7 @@ const appRoutingForms = router({
});
}
const serializableForm = getSerializableForm(form);
const serializableForm = await getSerializableForm(form);
if (!serializableForm.fields) {
// There is no point in submitting a form that doesn't have fields defined
throw new TRPCError({
@@ -191,7 +195,10 @@ const appRoutingForms = router({
},
});
const serializableForms = forms.map((form) => getSerializableForm(form));
const serializableForms = [];
for (let i = 0; i < forms.length; i++) {
serializableForms.push(await getSerializableForm(forms[i]));
}
return serializableForms;
}),
formQuery: authedProcedure
@@ -220,7 +227,7 @@ const appRoutingForms = router({
return null;
}
return getSerializableForm(form);
return await getSerializableForm(form);
}),
formMutation: authedProcedure
.input(
@@ -233,54 +240,29 @@ const appRoutingForms = router({
routes: zodRoutes,
addFallback: z.boolean().optional(),
duplicateFrom: z.string().nullable().optional(),
shouldConnect: z.boolean().optional(),
settings: RoutingFormSettings.optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { user, prisma } = ctx;
const { name, id, description, settings, disabled, addFallback, duplicateFrom } = input;
if (!(await isAllowed({ userId: user.id, formId: id }))) {
const { name, id, description, settings, disabled, addFallback, duplicateFrom, shouldConnect } = input;
if (!(await isFormEditAllowed({ userId: user.id, formId: id }))) {
throw new TRPCError({
code: "FORBIDDEN",
});
}
let { routes } = input;
let { fields } = input;
let { routes: inputRoutes } = input;
let { fields: inputFields } = input;
inputFields = inputFields || [];
inputRoutes = inputRoutes || [];
type InputFields = typeof inputFields;
type InputRoutes = typeof inputRoutes;
let routes: InputRoutes;
let fields: InputFields;
type DuplicateFrom = NonNullable<typeof duplicateFrom>;
if (duplicateFrom) {
const sourceForm = await prisma.app_RoutingForms_Form.findFirst({
where: {
userId: user.id,
id: duplicateFrom,
},
select: {
fields: true,
routes: true,
},
});
if (!sourceForm) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Form to duplicate: ${duplicateFrom} not found`,
});
}
const fieldParsed = zodFields.safeParse(sourceForm.fields);
const routesParsed = zodRoutes.safeParse(sourceForm.routes);
if (!fieldParsed.success || !routesParsed.success) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Could not parse source form's fields or routes",
});
}
// Duplicate just routes and fields
// We don't want name, description and responses to be copied
routes = routesParsed.data;
fields = fieldParsed.data;
}
fields = fields || [];
const form = await prisma.app_RoutingForms_Form.findUnique({
const dbForm = await prisma.app_RoutingForms_Form.findUnique({
where: {
id: id,
},
@@ -299,35 +281,28 @@ const appRoutingForms = router({
},
});
// Add back deleted fields in the end. Fields can't be deleted, to make sure columns never decrease which hugely simplifies CSV generation
if (form) {
const serializedForm = getSerializableForm(form, true);
// Find all fields that are in DB(including deleted) but not in the mutation
const deletedFields =
serializedForm.fields?.filter((f) => !fields!.find((field) => field.id === f.id)) || [];
const dbSerializedForm = dbForm ? await getSerializableForm(dbForm, true) : null;
fields = fields.concat(
deletedFields.map((f) => {
f.deleted = true;
return f;
})
);
if (duplicateFrom) {
({ routes, fields } = await getRoutesAndFieldsForDuplication(duplicateFrom));
} else {
[fields, routes] = [inputFields, inputRoutes];
if (dbSerializedForm) {
fields = markMissingFieldsDeleted(dbSerializedForm, fields);
}
}
if (dbSerializedForm) {
// If it's an existing form being mutated, update fields in the connected forms(if any).
await updateFieldsInConnectedForms(dbSerializedForm, inputFields);
}
fields = await getUpdatedRouterLinkedFields(fields, routes);
if (addFallback) {
const uuid = uuidv4();
routes = routes || [];
// Add a fallback route if there is none
if (!routes.find((route) => route.isFallback)) {
routes.push({
id: uuid,
isFallback: true,
action: {
type: "customPageMessage",
value: "Thank you for your interest! We will be in touch soon.",
},
queryValue: { id: uuid, type: "group" },
});
if (!routes.find(isFallbackRoute)) {
routes.push(createFallbackRoute());
}
}
@@ -341,7 +316,7 @@ const appRoutingForms = router({
id: user.id,
},
},
fields: fields,
fields,
name: name,
description,
// Prisma doesn't allow setting null value directly for JSON. It recommends using JsonNull for that case.
@@ -350,13 +325,230 @@ const appRoutingForms = router({
},
update: {
disabled: disabled,
fields: fields,
fields,
name: name,
description,
settings: settings === null ? Prisma.JsonNull : settings,
routes: routes === null ? Prisma.JsonNull : routes,
},
});
/**
* If Form has Router Linked fields, enrich them with the latest info from the Router
* If Form doesn't have Router fields but there is a Router used in routes, add all the fields from the Router
*/
async function getUpdatedRouterLinkedFields(fields: InputFields, routes: InputRoutes) {
const routerLinkedFields: Record<string, boolean> = {};
for (const [, field] of Object.entries(fields)) {
if (!isRouterLinkedField(field)) {
continue;
}
routerLinkedFields[field.routerId] = true;
if (!routes.some((route) => route.id === field.routerId)) {
// If the field is from a router that is not available anymore, mark it as deleted
field.deleted = true;
continue;
}
// Get back deleted field as now the Router is there for it.
if (field.deleted) field.deleted = false;
const router = await prisma.app_RoutingForms_Form.findFirst({
where: {
id: field.routerId,
userId: user.id,
},
});
if (router) {
assertIfInvalidRouter(router);
const parsedRouterFields = zodFields.parse(router.fields);
// There is a field from some router available, make sure that the field has up-to-date info from the router
const routerField = parsedRouterFields?.find((f) => f.id === field.id);
// Update local field(cache) with router field on every mutation
Object.assign(field, routerField);
}
}
for (const [, route] of Object.entries(routes)) {
if (!isRouter(route)) {
continue;
}
// If there is a field that belongs to router, then all fields must be there already. So, need to add Router fields
if (routerLinkedFields[route.id]) {
continue;
}
const router = await prisma.app_RoutingForms_Form.findFirst({
where: {
id: route.id,
userId: user.id,
},
});
if (router) {
assertIfInvalidRouter(router);
const parsedRouterFields = zodFields.parse(router.fields);
const fieldsFromRouter = parsedRouterFields
?.filter((f) => !f.deleted)
.map((f) => {
return {
...f,
routerId: route.id,
};
});
if (fieldsFromRouter) {
fields = fields.concat(fieldsFromRouter);
}
}
}
return fields;
}
function findFieldWithId(id: string, fields: InputFields) {
return fields.find((field) => field.id === id);
}
/**
* Update fields in connected forms as per the inputFields
*/
async function updateFieldsInConnectedForms(
serializedForm: SerializableForm<App_RoutingForms_Form>,
inputFields: InputFields
) {
for (const [, connectedForm] of Object.entries(serializedForm.connectedForms)) {
const connectedFormDb = await prisma.app_RoutingForms_Form.findFirst({
where: {
id: connectedForm.id,
},
});
if (!connectedFormDb) {
continue;
}
const connectedFormFields = zodFields.parse(connectedFormDb.fields);
const fieldsThatAreNotInConnectedForm = (
inputFields?.filter((f) => !findFieldWithId(f.id, connectedFormFields || [])) || []
).map((f) => ({
...f,
routerId: serializedForm.id,
}));
const updatedConnectedFormFields = connectedFormFields
// Update fields that are already in connected form
?.map((field) => {
if (isRouterLinkedField(field) && field.routerId === serializedForm.id) {
return {
...field,
...findFieldWithId(field.id, inputFields || []),
};
}
return field;
})
// Add fields that are not there
.concat(fieldsThatAreNotInConnectedForm);
await prisma.app_RoutingForms_Form.update({
where: {
id: connectedForm.id,
},
data: {
fields: updatedConnectedFormFields,
},
});
}
}
async function getRoutesAndFieldsForDuplication(duplicateFrom: DuplicateFrom) {
const sourceForm = await prisma.app_RoutingForms_Form.findFirst({
where: {
userId: user.id,
id: duplicateFrom,
},
select: {
id: true,
fields: true,
routes: true,
},
});
if (!sourceForm) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Form to duplicate: ${duplicateFrom} not found`,
});
}
//TODO: Instead of parsing separately, use getSerializableForm. That would automatically remove deleted fields as well.
const fieldsParsed = zodFields.safeParse(sourceForm.fields);
const routesParsed = zodRoutes.safeParse(sourceForm.routes);
if (!fieldsParsed.success || !routesParsed.success) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Could not parse source form's fields or routes",
});
}
let fields, routes;
if (shouldConnect) {
routes = [
// This connected route would automatically link the fields
zodRouterRoute.parse({
id: sourceForm.id,
isRouter: true,
}),
];
fields =
fieldsParsed.data
// Deleted fields in the form shouldn't be added to the new form
?.filter((f) => !f.deleted)
.map((f) => {
return {
id: f.id,
routerId: sourceForm.id,
label: "",
type: "",
};
}) || [];
} else {
// Duplicate just routes and fields
// We don't want name, description and responses to be copied
routes = routesParsed.data || [];
// FIXME: Deleted fields shouldn't come in duplicate
fields = fieldsParsed.data || [];
}
return { routes, fields };
}
function markMissingFieldsDeleted(
serializedForm: SerializableForm<App_RoutingForms_Form>,
fields: InputFields
) {
// Find all fields that are in DB(including deleted) but not in the mutation
// e.g. inputFields is [A,B,C]. DB is [A,B,C,D,E,F]. It means D,E,F got deleted
const deletedFields =
serializedForm.fields?.filter((f) => !fields.find((field) => field.id === f.id)) || [];
// Add back deleted fields in the end and mark them deleted.
// Fields mustn't be deleted, to make sure columns never decrease which hugely simplifies CSV generation
fields = fields.concat(
deletedFields.map((f) => {
f.deleted = true;
return f;
})
);
return fields;
}
function assertIfInvalidRouter(router: App_RoutingForms_Form) {
const routesOfRouter = zodRoutes.parse(router.routes);
if (routesOfRouter) {
if (routesOfRouter.find(isRouter)) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"A form being used as a Router must be a Origin form. It must not be using any other Router.",
});
}
}
}
}),
deleteForm: authedProcedure
.input(
@@ -366,11 +558,24 @@ const appRoutingForms = router({
)
.mutation(async ({ ctx, input }) => {
const { user, prisma } = ctx;
if (!(await isAllowed({ userId: user.id, formId: input.id }))) {
if (!(await isFormEditAllowed({ userId: user.id, formId: input.id }))) {
throw new TRPCError({
code: "FORBIDDEN",
});
}
const areFormsUsingIt = (
await getConnectedForms(prisma, {
id: input.id,
userId: user.id,
})
).length;
if (areFormsUsingIt) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "This form is being used by other forms. Please remove it's usage from there first.",
});
}
return await prisma.app_RoutingForms_Form.deleteMany({
where: {
id: input.id,
@@ -378,6 +583,7 @@ const appRoutingForms = router({
},
});
}),
report: authedProcedure
.input(
z.object({
@@ -414,7 +620,7 @@ const appRoutingForms = router({
});
}
// TODO: Second argument is required to return deleted operators.
const serializedForm = getSerializableForm(form, true);
const serializedForm = await getSerializableForm(form, true);
const rows = await prisma.app_RoutingForms_FormResponse.findMany({
where: {
+15 -3
View File
@@ -3,7 +3,7 @@ import z from "zod";
import { RoutingFormSettings } from "@calcom/prisma/zod-utils";
import { zodFields, zodRoutes } from "../zod";
import { zodRouterRouteView, zodNonRouterRoute, zodFieldsView, zodRoutesView } from "../zod";
export type Response = Record<
// Field ID
@@ -14,9 +14,9 @@ export type Response = Record<
}
>;
export type Fields = z.infer<typeof zodFields>;
export type Fields = z.infer<typeof zodFieldsView>;
export type Field = Fields[number];
export type Routes = z.infer<typeof zodRoutes>;
export type Routes = z.infer<typeof zodRoutesView>;
export type Route = Routes[0];
export type SerializableForm<T extends App_RoutingForms_Form> = Omit<
T,
@@ -27,4 +27,16 @@ export type SerializableForm<T extends App_RoutingForms_Form> = Omit<
settings: z.infer<typeof RoutingFormSettings>;
createdAt: string;
updatedAt: string;
connectedForms: { name: string; description: string | null; id: string }[];
routers: { name: string; description: string | null; id: string }[];
};
export type LocalRoute = z.infer<typeof zodNonRouterRoute>;
export type GlobalRoute = z.infer<typeof zodRouterRouteView>;
export type SerializableRoute =
| (LocalRoute & {
queryValue: LocalRoute["queryValue"];
isFallback?: LocalRoute["isFallback"];
})
| GlobalRoute;
+75 -39
View File
@@ -1,45 +1,81 @@
import { z } from "zod";
export const zodFields = z
.array(
z.object({
id: z.string(),
label: z.string(),
identifier: z.string().optional(),
type: z.string(),
selectText: z.string().optional(),
required: z.boolean().optional(),
deleted: z.boolean().optional(),
})
)
.optional();
export const zodNonRouterField = z.object({
id: z.string(),
label: z.string(),
identifier: z.string().optional(),
type: z.string(),
selectText: z.string().optional(),
required: z.boolean().optional(),
deleted: z.boolean().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();
export const zodRouterField = zodNonRouterField.extend({
routerId: z.string(),
});
// This ordering is important - If routerId is present then it should be in the parsed object. Moving zodNonRouterField to first position doesn't do that
export const zodField = z.union([zodRouterField, zodNonRouterField]);
export const zodFields = z.array(zodField).optional();
export const zodNonRouterFieldView = zodNonRouterField;
export const zodRouterFieldView = zodRouterField.extend({
routerField: zodNonRouterFieldView,
router: z.object({
name: z.string(),
description: z.string(),
id: z.string(),
}),
});
/**
* Has some additional fields that are not supposed to be saved to DB but are required for the UI
*/
export const zodFieldView = z.union([zodNonRouterFieldView, zodRouterFieldView]);
export const zodFieldsView = z.array(zodFieldView).optional();
export const zodNonRouterRoute = 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(),
}),
});
export const zodNonRouterRouteView = zodNonRouterRoute;
export const zodRouterRoute = z.object({
// This is the id of the Form being used as router
id: z.string(),
isRouter: z.literal(true),
});
export const zodRoute = z.union([zodNonRouterRoute, zodRouterRoute]);
export const zodRouterRouteView = zodRouterRoute.extend({
//TODO: Extend it from form
name: z.string(),
description: z.string().nullable(),
routes: z.array(z.union([zodRoute, z.null()])),
});
export const zodRoutes = z.union([z.array(zodRoute), z.null()]).optional();
export const zodRouteView = z.union([zodNonRouterRouteView, zodRouterRouteView]);
export const zodRoutesView = z.union([z.array(zodRouteView), z.null()]).optional();
// TODO: This is a requirement right now that zod.ts file (if it exists) must have appDataSchema export(which is only required by apps having EventTypeAppCard interface)
// This is a temporary solution and will be removed in future
@@ -77,7 +77,7 @@ export default function Exchange2013CalendarSetup() {
</fieldset>
{errorMessage && <Alert severity="error" title={errorMessage} className="my-4" />}
<div className="mt-5 justify-end space-x-2 sm:mt-4 sm:flex">
<div className="mt-5 justify-end space-x-2 rtl:space-x-reverse sm:mt-4 sm:flex">
<Button type="button" color="secondary" onClick={() => router.back()}>
{t("cancel")}
</Button>
@@ -77,7 +77,7 @@ export default function Exchange2016CalendarSetup() {
</fieldset>
{errorMessage && <Alert severity="error" title={errorMessage} className="my-4" />}
<div className="mt-5 justify-end space-x-2 sm:mt-4 sm:flex">
<div className="mt-5 justify-end space-x-2 rtl:space-x-reverse sm:mt-4 sm:flex">
<Button type="button" color="secondary" onClick={() => router.back()}>
{t("cancel")}
</Button>
@@ -120,7 +120,7 @@ export default function ExchangeSetup() {
/>
</fieldset>
{errorMessage && <Alert severity="error" title={errorMessage} className="my-4" />}
<div className="mt-4 flex justify-end space-x-2">
<div className="mt-4 flex justify-end space-x-2 rtl:space-x-reverse">
<Button type="button" color="secondary" onClick={() => router.back()}>
{t("cancel")}
</Button>

Some files were not shown because too many files have changed in this diff Show More