Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b719ac070 | ||
|
|
baa9045af7 | ||
|
|
b004587262 | ||
|
|
8a7beff339 | ||
|
|
9001eb7795 | ||
|
|
50c4578c2e | ||
|
|
4ff2ce90f9 | ||
|
|
86f8c6c5fa | ||
|
|
0611bbd50e | ||
|
|
b946abab02 | ||
|
|
a091c60618 | ||
|
|
bba7fc1a30 | ||
|
|
864c46736b | ||
|
|
4416709ff6 | ||
|
|
591d42fc8f | ||
|
|
d73b84157a | ||
|
|
680e748b7a | ||
|
|
99b7aaa62d | ||
|
|
3a01e6390f | ||
|
|
a6c25ab7d3 | ||
|
|
df98274a8f | ||
|
|
b540a2ec45 | ||
|
|
8e4459f2fe | ||
|
|
bc5a9f231f | ||
|
|
88984f6175 | ||
|
|
fc592a17ac | ||
|
|
a51524e673 |
@@ -12,7 +12,7 @@
|
||||
## Mandatory Tasks (DO NOT REMOVE)
|
||||
|
||||
- [ ] I have self-reviewed the code (A decent size PR without self-review might be rejected).
|
||||
- [ ] I have added a Docs issue [here](https://github.com/calcom/docs/issues/new) if this PR makes changes that would require a [documentation change](https://docs.cal.com). If N/A, write N/A here and check the checkbox.
|
||||
- [ ] I have added a Docs issue [here](https://github.com/calcom/docs/issues/new) if this PR makes changes that would require a [documentation change](https://docs.cal.com/docs). If N/A, write N/A here and check the checkbox.
|
||||
- [ ] I confirm automated tests are in place that prove my fix is effective or that my feature works.
|
||||
|
||||
## How should this be tested?
|
||||
|
||||
@@ -10,7 +10,8 @@ jobs:
|
||||
continue-on-error: true
|
||||
env:
|
||||
# Unique URL path for each workflow run attempt
|
||||
HTML_REPORT_URL_PATH: reports/${{ github.ref_name }}/${{ github.run_id }}/${{ github.run_attempt }}
|
||||
HTML_REPORT_URL_PATH: reports/${{ github.head_ref }}/${{ github.run_id }}/${{ github.run_attempt }}
|
||||
BRANCH_REPORTS_DIR: reports/${{ github.head_ref }}
|
||||
steps:
|
||||
- name: Checkout GitHub Pages Branch
|
||||
uses: actions/checkout@v4
|
||||
@@ -21,6 +22,20 @@ jobs:
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
- name: Check for workflow reports
|
||||
run: |
|
||||
if [ -z "$(ls -A $BRANCH_REPORTS_DIR)" ]; then
|
||||
echo "BRANCH_REPORTS_EXIST="false"" >> $GITHUB_ENV
|
||||
else
|
||||
echo "BRANCH_REPORTS_EXIST="true"" >> $GITHUB_ENV
|
||||
fi
|
||||
- name: Cleanup old HTML reports
|
||||
if: ${{ env.BRANCH_REPORTS_EXIST == 'true' }}
|
||||
timeout-minutes: 3
|
||||
run: |
|
||||
rm -rf $BRANCH_REPORTS_DIR
|
||||
git add .
|
||||
git commit -m "workflow: remove all reports for branch ${{ github.head_ref }}"
|
||||
- name: Download zipped HTML report
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -68,11 +83,14 @@ jobs:
|
||||
body: |
|
||||
<!-- GENERATED-E2E-RESULTS -->
|
||||
## E2E results are ready!
|
||||
- [Attempt #${{ github.run_number }}.${{ github.run_attempt }} results](${{ steps.url.outputs.link }})
|
||||
- [Workflow #${{ github.run_number }}.${{ github.run_attempt }} latest results](${{ steps.url.outputs.link }})
|
||||
- name: Update comment
|
||||
if: steps.fc.outputs.comment-id != ''
|
||||
uses: peter-evans/create-or-update-comment@v3
|
||||
with:
|
||||
comment-id: ${{ steps.fc.outputs.comment-id }}
|
||||
edit-mode: replace
|
||||
body: |
|
||||
- [Attempt #${{ github.run_number }}.${{ github.run_attempt }} results](${{ steps.url.outputs.link }})
|
||||
<!-- GENERATED-E2E-RESULTS -->
|
||||
## E2E results are ready!
|
||||
- [Workflow #${{ github.run_number }}.${{ github.run_attempt }} latest results](${{ steps.url.outputs.link }})
|
||||
|
||||
@@ -380,7 +380,7 @@ Currently Vercel Pro Plan is required to be able to Deploy this application with
|
||||
|
||||
### Elestio
|
||||
|
||||
[](https://elest.io/open-source/cal.com)
|
||||
[](https://elest.io/open-source/cal.com)
|
||||
|
||||
<!-- ROADMAP -->
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { _PaymentModel as Payment } from "@calcom/prisma/zod";
|
||||
|
||||
// FIXME: Payment seems a delicate endpoint, do we need to remove anything here?
|
||||
export const schemaPaymentBodyParams = Payment.omit({ id: true });
|
||||
export const schemaPaymentPublic = Payment.omit({ externalId: true });
|
||||
export const schemaPaymentPublic = Payment.pick({
|
||||
id: true,
|
||||
amount: true,
|
||||
success: true,
|
||||
refunded: true,
|
||||
fee: true,
|
||||
paymentOption: true,
|
||||
currency: true,
|
||||
bookingId: true,
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ export const schemaTeamBaseBodyParams = Team.omit({ id: true, createdAt: true })
|
||||
isOrganization: true,
|
||||
isPlatform: true,
|
||||
smsLockState: true,
|
||||
smsLockReviewedByAdmin: true,
|
||||
});
|
||||
|
||||
const schemaTeamRequiredParams = z.object({
|
||||
|
||||
@@ -27,7 +27,7 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform
|
||||
* tags:
|
||||
* - availabilities
|
||||
* externalDocs:
|
||||
* url: https://docs.cal.com/availability
|
||||
* url: https://docs.cal.com/docs/core-features/availability
|
||||
* responses:
|
||||
* 201:
|
||||
* description: OK, availability removed successfully
|
||||
|
||||
@@ -28,7 +28,7 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform
|
||||
* tags:
|
||||
* - availabilities
|
||||
* externalDocs:
|
||||
* url: https://docs.cal.com/availability
|
||||
* url: https://docs.cal.com/docs/core-features/availability
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OK
|
||||
|
||||
@@ -63,7 +63,7 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform
|
||||
* tags:
|
||||
* - availabilities
|
||||
* externalDocs:
|
||||
* url: https://docs.cal.com/availability
|
||||
* url: https://docs.cal.com/docs/core-features/availability
|
||||
* responses:
|
||||
* 201:
|
||||
* description: OK, availability edited successfully
|
||||
|
||||
@@ -62,7 +62,7 @@ import {
|
||||
* tags:
|
||||
* - availabilities
|
||||
* externalDocs:
|
||||
* url: https://docs.cal.com/availability
|
||||
* url: https://docs.cal.com/docs/core-features/availability
|
||||
* responses:
|
||||
* 201:
|
||||
* description: OK, availability created
|
||||
|
||||
@@ -11,8 +11,8 @@ const swaggerHandler = withSwagger({
|
||||
{ url: "https://api.cal.com/v1" },
|
||||
],
|
||||
externalDocs: {
|
||||
url: "https://docs.cal.com",
|
||||
description: "Find more info at our main docs: https://docs.cal.com/",
|
||||
url: "https://docs.cal.com/docs",
|
||||
description: "Find more info at our main docs: https://docs.cal.com/docs/",
|
||||
},
|
||||
info: {
|
||||
title: `${pjson.name}: ${pjson.description}`,
|
||||
|
||||
@@ -28,7 +28,7 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform
|
||||
* tags:
|
||||
* - event-types
|
||||
* externalDocs:
|
||||
* url: https://docs.cal.com/core-features/event-types
|
||||
* url: https://docs.cal.com/docs/core-features/event-types
|
||||
* responses:
|
||||
* 201:
|
||||
* description: OK, eventType removed successfully
|
||||
|
||||
@@ -34,7 +34,7 @@ import getCalLink from "../_utils/getCalLink";
|
||||
* tags:
|
||||
* - event-types
|
||||
* externalDocs:
|
||||
* url: https://docs.cal.com/core-features/event-types
|
||||
* url: https://docs.cal.com/docs/core-features/event-types
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OK
|
||||
|
||||
@@ -194,7 +194,7 @@ import checkTeamEventEditPermission from "../_utils/checkTeamEventEditPermission
|
||||
* tags:
|
||||
* - event-types
|
||||
* externalDocs:
|
||||
* url: https://docs.cal.com/core-features/event-types
|
||||
* url: https://docs.cal.com/docs/core-features/event-types
|
||||
* responses:
|
||||
* 201:
|
||||
* description: OK, eventType edited successfully
|
||||
|
||||
@@ -33,7 +33,7 @@ import getCalLink from "./_utils/getCalLink";
|
||||
* tags:
|
||||
* - event-types
|
||||
* externalDocs:
|
||||
* url: https://docs.cal.com/core-features/event-types
|
||||
* url: https://docs.cal.com/docs/core-features/event-types
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OK
|
||||
|
||||
@@ -255,7 +255,7 @@ import ensureOnlyMembersAsHosts from "./_utils/ensureOnlyMembersAsHosts";
|
||||
* tags:
|
||||
* - event-types
|
||||
* externalDocs:
|
||||
* url: https://docs.cal.com/core-features/event-types
|
||||
* url: https://docs.cal.com/docs/core-features/event-types
|
||||
* responses:
|
||||
* 201:
|
||||
* description: OK, event type created
|
||||
|
||||
@@ -125,6 +125,7 @@ export async function patchHandler(req: NextApiRequest) {
|
||||
metadata: NonNullable<typeof data.metadata> | undefined;
|
||||
} = {
|
||||
...data,
|
||||
smsLockReviewedByAdmin: false,
|
||||
metadata: data.metadata === null ? {} : data.metadata || undefined,
|
||||
};
|
||||
const team = await prisma.team.update({ where: { id: teamId }, data: cloneData });
|
||||
|
||||
@@ -32,7 +32,7 @@ const querySchema = z.object({
|
||||
* tags:
|
||||
* - event-types
|
||||
* externalDocs:
|
||||
* url: https://docs.cal.com/core-features/event-types
|
||||
* url: https://docs.cal.com/docs/core-features/event-types
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OK
|
||||
|
||||
@@ -125,6 +125,7 @@ async function postHandler(req: NextApiRequest) {
|
||||
metadata: NonNullable<typeof data.metadata> | undefined;
|
||||
} = {
|
||||
...data,
|
||||
smsLockReviewedByAdmin: false,
|
||||
metadata: data.metadata === null ? {} : data.metadata || undefined,
|
||||
};
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import { schemaQueryIdAsString } from "~/lib/validations/shared/queryIdString";
|
||||
* tags:
|
||||
* - webhooks
|
||||
* externalDocs:
|
||||
* url: https://docs.cal.com/core-features/webhooks
|
||||
* url: https://docs.cal.com/docs/core-features/webhooks
|
||||
* responses:
|
||||
* 201:
|
||||
* description: OK, hook removed successfully
|
||||
|
||||
@@ -28,7 +28,7 @@ import { schemaWebhookReadPublic } from "~/lib/validations/webhook";
|
||||
* tags:
|
||||
* - webhooks
|
||||
* externalDocs:
|
||||
* url: https://docs.cal.com/core-features/webhooks
|
||||
* url: https://docs.cal.com/docs/core-features/webhooks
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OK
|
||||
|
||||
@@ -58,7 +58,7 @@ import { schemaWebhookEditBodyParams, schemaWebhookReadPublic } from "~/lib/vali
|
||||
* tags:
|
||||
* - webhooks
|
||||
* externalDocs:
|
||||
* url: https://docs.cal.com/core-features/webhooks
|
||||
* url: https://docs.cal.com/docs/core-features/webhooks
|
||||
* responses:
|
||||
* 201:
|
||||
* description: OK, webhook edited successfully
|
||||
|
||||
@@ -24,7 +24,7 @@ import { schemaWebhookReadPublic } from "~/lib/validations/webhook";
|
||||
* tags:
|
||||
* - webhooks
|
||||
* externalDocs:
|
||||
* url: https://docs.cal.com/core-features/webhooks
|
||||
* url: https://docs.cal.com/docs/core-features/webhooks
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OK
|
||||
|
||||
@@ -56,7 +56,7 @@ import { schemaWebhookCreateBodyParams, schemaWebhookReadPublic } from "~/lib/va
|
||||
* tags:
|
||||
* - webhooks
|
||||
* externalDocs:
|
||||
* url: https://docs.cal.com/core-features/webhooks
|
||||
* url: https://docs.cal.com/docs/core-features/webhooks
|
||||
* responses:
|
||||
* 201:
|
||||
* description: OK, webhook created
|
||||
|
||||
+4
@@ -177,6 +177,10 @@ export class BookingField_2024_04_15 {
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => Source)
|
||||
sources?: Source[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
disableOnPrefill?: boolean;
|
||||
}
|
||||
|
||||
export class RecurringEvent_2024_04_15 {
|
||||
|
||||
+4
@@ -87,6 +87,10 @@ class BookingField {
|
||||
@Type(() => Source)
|
||||
@IsOptional()
|
||||
sources?: Source[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
disableOnPrefill?: boolean;
|
||||
}
|
||||
|
||||
class Organization {
|
||||
|
||||
@@ -29,7 +29,7 @@ const AccountSelector: FC<AccountSelectorProps> = ({
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"hover:bg-muted flex cursor-pointer flex-row items-center gap-2 p-1",
|
||||
"hover:bg-muted flex cursor-pointer flex-row items-center gap-2 p-1 transition",
|
||||
(alreadyInstalled || loading) && "cursor-not-allowed",
|
||||
selected && loading && "bg-muted animate-pulse"
|
||||
)}
|
||||
|
||||
@@ -94,7 +94,7 @@ const EventTypeCard: FC<EventTypeCardProps> = ({
|
||||
return (
|
||||
<div
|
||||
data-testid={`select-event-type-${id}`}
|
||||
className="hover:bg-muted min-h-20 box-border flex w-full cursor-pointer select-none items-center space-x-4 px-4 py-3"
|
||||
className="hover:bg-muted min-h-20 box-border flex w-full cursor-pointer select-none items-center space-x-4 px-4 py-3 transition"
|
||||
onClick={() => handleSelect()}>
|
||||
<input
|
||||
id={`${id}`}
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
Tooltip,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import { AddGuestsDialog } from "@components/dialog/AddGuestsDialog";
|
||||
import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog";
|
||||
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
|
||||
import { ReassignDialog } from "@components/dialog/ReassignDialog";
|
||||
@@ -189,6 +190,14 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
},
|
||||
icon: "map-pin" as const,
|
||||
},
|
||||
{
|
||||
id: "add_members",
|
||||
label: t("additional_guests"),
|
||||
onClick: () => {
|
||||
setIsOpenAddGuestsDialog(true);
|
||||
},
|
||||
icon: "user-plus" as const,
|
||||
},
|
||||
];
|
||||
|
||||
if (booking.eventType.schedulingType === SchedulingType.ROUND_ROBIN) {
|
||||
@@ -256,6 +265,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false);
|
||||
const [isOpenReassignDialog, setIsOpenReassignDialog] = useState(false);
|
||||
const [isOpenSetLocationDialog, setIsOpenLocationDialog] = useState(false);
|
||||
const [isOpenAddGuestsDialog, setIsOpenAddGuestsDialog] = useState(false);
|
||||
const setLocationMutation = trpc.viewer.bookings.editLocation.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast(t("location_updated"), "success");
|
||||
@@ -344,6 +354,11 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
setShowLocationModal={setIsOpenLocationDialog}
|
||||
teamId={booking.eventType?.team?.id}
|
||||
/>
|
||||
<AddGuestsDialog
|
||||
isOpenDialog={isOpenAddGuestsDialog}
|
||||
setIsOpenDialog={setIsOpenAddGuestsDialog}
|
||||
bookingId={booking.id}
|
||||
/>
|
||||
{booking.paid && booking.payment[0] && (
|
||||
<ChargeCardDialog
|
||||
isOpenDialog={chargeCardDialogIsOpen}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
MultiEmail,
|
||||
Icon,
|
||||
showToast,
|
||||
} from "@calcom/ui";
|
||||
|
||||
interface IAddGuestsDialog {
|
||||
isOpenDialog: boolean;
|
||||
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
|
||||
bookingId: number;
|
||||
}
|
||||
|
||||
export const AddGuestsDialog = (props: IAddGuestsDialog) => {
|
||||
const { t } = useLocale();
|
||||
const ZAddGuestsInputSchema = z.array(z.string().email()).refine((emails) => {
|
||||
const uniqueEmails = new Set(emails);
|
||||
return uniqueEmails.size === emails.length;
|
||||
});
|
||||
const { isOpenDialog, setIsOpenDialog, bookingId } = props;
|
||||
const utils = trpc.useUtils();
|
||||
const [multiEmailValue, setMultiEmailValue] = useState<string[]>([""]);
|
||||
const [isInvalidEmail, setIsInvalidEmail] = useState(false);
|
||||
|
||||
const addGuestsMutation = trpc.viewer.bookings.addGuests.useMutation({
|
||||
onSuccess: async () => {
|
||||
showToast(t("guests_added"), "success");
|
||||
setIsOpenDialog(false);
|
||||
setMultiEmailValue([""]);
|
||||
utils.viewer.bookings.invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
const message = `${err.data?.code}: ${t(err.message)}`;
|
||||
showToast(message || t("unable_to_add_guests"), "error");
|
||||
},
|
||||
});
|
||||
|
||||
const handleAdd = () => {
|
||||
if (multiEmailValue.length === 0) {
|
||||
return;
|
||||
}
|
||||
const validationResult = ZAddGuestsInputSchema.safeParse(multiEmailValue);
|
||||
if (validationResult.success) {
|
||||
addGuestsMutation.mutate({ bookingId, guests: multiEmailValue });
|
||||
} else {
|
||||
setIsInvalidEmail(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||
<DialogContent enableOverflow>
|
||||
<div className="flex flex-row space-x-3">
|
||||
<div className="bg-subtle flex h-10 w-10 flex-shrink-0 justify-center rounded-full ">
|
||||
<Icon name="user-plus" className="m-auto h-6 w-6" />
|
||||
</div>
|
||||
<div className="w-full pt-1">
|
||||
<DialogHeader title={t("additional_guests")} />
|
||||
<MultiEmail
|
||||
label={t("add_emails")}
|
||||
value={multiEmailValue}
|
||||
readOnly={false}
|
||||
setValue={setMultiEmailValue}
|
||||
/>
|
||||
|
||||
{isInvalidEmail && (
|
||||
<div className="my-4 flex text-sm text-red-700">
|
||||
<div className="flex-shrink-0">
|
||||
<Icon name="triangle-alert" className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="font-medium">{t("emails_must_be_unique_valid")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setMultiEmailValue([""]);
|
||||
setIsInvalidEmail(false);
|
||||
setIsOpenDialog(false);
|
||||
}}
|
||||
type="button"
|
||||
color="secondary">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button data-testid="add_members" loading={addGuestsMutation.isPending} onClick={handleAdd}>
|
||||
{t("add")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { EventTypeSetupProps } from "pages/event-types/[type]";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import type { UseFormGetValues, UseFormSetValue, Control, FormState } from "react-hook-form";
|
||||
import type { MultiValue } from "react-select";
|
||||
@@ -12,45 +12,10 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
import { slugify } from "@calcom/lib/slugify";
|
||||
import turndown from "@calcom/lib/turndownService";
|
||||
import {
|
||||
Label,
|
||||
Select,
|
||||
SettingsToggle,
|
||||
Skeleton,
|
||||
TextField,
|
||||
Editor,
|
||||
SkeletonContainer,
|
||||
SkeletonText,
|
||||
} from "@calcom/ui";
|
||||
import { Label, Select, SettingsToggle, Skeleton, TextField, Editor } from "@calcom/ui";
|
||||
|
||||
import Locations from "@components/eventtype/Locations";
|
||||
|
||||
const DescriptionEditor = ({ isEditable }: { isEditable: boolean }) => {
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const [mounted, setIsMounted] = useState(false);
|
||||
const { t } = useLocale();
|
||||
const [firstRender, setFirstRender] = useState(true);
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
return mounted ? (
|
||||
<Editor
|
||||
getText={() => md.render(formMethods.getValues("description") || "")}
|
||||
setText={(value: string) => formMethods.setValue("description", turndown(value), { shouldDirty: true })}
|
||||
excludedToolbarItems={["blockType"]}
|
||||
placeholder={t("quick_video_meeting")}
|
||||
editable={isEditable}
|
||||
firstRender={firstRender}
|
||||
setFirstRender={setFirstRender}
|
||||
/>
|
||||
) : (
|
||||
<SkeletonContainer>
|
||||
<SkeletonText className="block h-24 w-full" />
|
||||
</SkeletonContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export const EventSetupTab = (
|
||||
props: Pick<
|
||||
EventTypeSetupProps,
|
||||
@@ -63,6 +28,7 @@ export const EventSetupTab = (
|
||||
const [multipleDuration, setMultipleDuration] = useState(
|
||||
formMethods.getValues("metadata")?.multipleDuration
|
||||
);
|
||||
const [firstRender, setFirstRender] = useState(true);
|
||||
const orgBranding = useOrgBranding();
|
||||
const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled");
|
||||
|
||||
@@ -111,7 +77,17 @@ export const EventSetupTab = (
|
||||
{t("description")}
|
||||
{(isManagedEventType || isChildrenManagedEventType) && shouldLockIndicator("description")}
|
||||
</Label>
|
||||
<DescriptionEditor isEditable={!descriptionLockedProps.disabled} />
|
||||
<Editor
|
||||
getText={() => md.render(formMethods.getValues("description") || "")}
|
||||
setText={(value: string) =>
|
||||
formMethods.setValue("description", turndown(value), { shouldDirty: true })
|
||||
}
|
||||
excludedToolbarItems={["blockType"]}
|
||||
placeholder={t("quick_video_meeting")}
|
||||
editable={!descriptionLockedProps.disabled}
|
||||
firstRender={firstRender}
|
||||
setFirstRender={setFirstRender}
|
||||
/>
|
||||
</div>
|
||||
<TextField
|
||||
required
|
||||
|
||||
@@ -330,7 +330,7 @@ function EventTypeSingleLayout({
|
||||
<>
|
||||
<div
|
||||
className={classNames(
|
||||
"sm:hover:bg-muted hidden cursor-pointer items-center rounded-md",
|
||||
"sm:hover:bg-muted hidden cursor-pointer items-center rounded-md transition",
|
||||
formMethods.watch("hidden") ? "pl-2" : "",
|
||||
"lg:flex"
|
||||
)}>
|
||||
@@ -459,7 +459,7 @@ function EventTypeSingleLayout({
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="hover:bg-subtle flex h-9 cursor-pointer flex-row items-center justify-between px-4 py-2">
|
||||
<div className="hover:bg-subtle flex h-9 cursor-pointer flex-row items-center justify-between px-4 py-2 transition">
|
||||
<Skeleton
|
||||
as={Label}
|
||||
htmlFor="hiddenSwitch"
|
||||
|
||||
@@ -31,7 +31,7 @@ const Member = ({ member, teamName }: { member: MemberType; teamName: string | n
|
||||
<Link
|
||||
key={member.id}
|
||||
href={{ pathname: `${member.bookerUrl}/${member.username}`, query: queryParamsToForward }}>
|
||||
<div className="sm:min-w-80 sm:max-w-80 bg-default hover:bg-muted border-subtle group flex min-h-full flex-col space-y-2 rounded-md border p-4 hover:cursor-pointer">
|
||||
<div className="sm:min-w-80 sm:max-w-80 bg-default hover:bg-muted border-subtle group flex min-h-full flex-col space-y-2 rounded-md border p-4 transition hover:cursor-pointer">
|
||||
<UserAvatar noOrganizationIndicator size="md" user={member} />
|
||||
<section className="mt-2 line-clamp-4 w-full space-y-1">
|
||||
<p className="text-default font-medium">{member.name}</p>
|
||||
|
||||
@@ -413,7 +413,7 @@ export default function Success(props: PageProps) {
|
||||
<Link
|
||||
href={allRemainingBookings ? "/bookings/recurring" : "/bookings/upcoming"}
|
||||
data-testid="back-to-bookings"
|
||||
className="hover:bg-subtle text-subtle hover:text-default mt-2 inline-flex px-1 py-2 text-sm dark:hover:bg-transparent">
|
||||
className="hover:bg-subtle text-subtle hover:text-default mt-2 inline-flex px-1 py-2 text-sm transition dark:hover:bg-transparent">
|
||||
<Icon name="chevron-left" className="h-5 w-5 rtl:rotate-180" /> {t("back_to_bookings")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -739,7 +739,7 @@ export const EventTypeList = ({
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
{!isManagedEventType && (
|
||||
<div className="hover:bg-subtle flex h-9 cursor-pointer flex-row items-center justify-between px-4 py-2">
|
||||
<div className="hover:bg-subtle flex h-9 cursor-pointer flex-row items-center justify-between px-4 py-2 transition">
|
||||
<Skeleton
|
||||
as={Label}
|
||||
htmlFor="hiddenSwitch"
|
||||
@@ -1280,7 +1280,7 @@ export const InfiniteEventTypeList = ({
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
{!isManagedEventType && (
|
||||
<div className="hover:bg-subtle flex h-9 cursor-pointer flex-row items-center justify-between px-4 py-2">
|
||||
<div className="hover:bg-subtle flex h-9 cursor-pointer flex-row items-center justify-between px-4 py-2 transition">
|
||||
<Skeleton
|
||||
as={Label}
|
||||
htmlFor="hiddenSwitch"
|
||||
|
||||
@@ -145,7 +145,7 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
||||
length: eventData.length,
|
||||
metadata: {
|
||||
...eventData.metadata,
|
||||
multipleDuration: [15, 30, 60],
|
||||
multipleDuration: [15, 30, 45, 60, 90],
|
||||
},
|
||||
},
|
||||
user: usernames.join("+"),
|
||||
|
||||
@@ -23,6 +23,18 @@ const BUTTONS = {
|
||||
iconPath: TRANSCRIPTION_STOPPED_ICON,
|
||||
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
|
||||
},
|
||||
WAIT_FOR_TRANSCRIPTION_TO_START: {
|
||||
label: "Starting..",
|
||||
tooltip: "Please wait while we start transcription",
|
||||
iconPath: TRANSCRIPTION_STOPPED_ICON,
|
||||
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
|
||||
},
|
||||
WAIT_FOR_TRANSCRIPTION_TO_STOP: {
|
||||
label: "Stopping..",
|
||||
tooltip: "Please wait while we stop transcription",
|
||||
iconPath: TRANSCRIPTION_STOPPED_ICON,
|
||||
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
|
||||
},
|
||||
START_RECORDING: {
|
||||
label: "Record",
|
||||
tooltip: "Start recording",
|
||||
@@ -35,6 +47,12 @@ const BUTTONS = {
|
||||
iconPath: RECORDING_DEFAULT_ICON,
|
||||
iconPathDarkMode: RECORDING_DEFAULT_ICON,
|
||||
},
|
||||
WAIT_FOR_RECORDING_TO_STOP: {
|
||||
label: "Stopping..",
|
||||
tooltip: "Please wait while we stop recording",
|
||||
iconPath: RECORDING_DEFAULT_ICON,
|
||||
iconPathDarkMode: RECORDING_DEFAULT_ICON,
|
||||
},
|
||||
STOP_RECORDING: {
|
||||
label: "Stop",
|
||||
tooltip: "Stop recording",
|
||||
@@ -93,6 +111,12 @@ export const CalAiTranscribe = () => {
|
||||
|
||||
const toggleRecording = async () => {
|
||||
if (recording?.isRecording) {
|
||||
daily?.updateCustomTrayButtons({
|
||||
recording: BUTTONS.WAIT_FOR_RECORDING_TO_STOP,
|
||||
transcription: transcription?.isTranscribing
|
||||
? BUTTONS.STOP_TRANSCRIPTION
|
||||
: BUTTONS.START_TRANSCRIPTION,
|
||||
});
|
||||
await daily?.stopRecording();
|
||||
} else {
|
||||
daily?.updateCustomTrayButtons({
|
||||
@@ -111,8 +135,16 @@ export const CalAiTranscribe = () => {
|
||||
|
||||
const toggleTranscription = async () => {
|
||||
if (transcription?.isTranscribing) {
|
||||
daily?.updateCustomTrayButtons({
|
||||
recording: recording?.isRecording ? BUTTONS.STOP_RECORDING : BUTTONS.START_RECORDING,
|
||||
transcription: BUTTONS.WAIT_FOR_TRANSCRIPTION_TO_STOP,
|
||||
});
|
||||
daily?.stopTranscription();
|
||||
} else {
|
||||
daily?.updateCustomTrayButtons({
|
||||
recording: recording?.isRecording ? BUTTONS.STOP_RECORDING : BUTTONS.START_RECORDING,
|
||||
transcription: BUTTONS.WAIT_FOR_TRANSCRIPTION_TO_START,
|
||||
});
|
||||
daily?.startTranscription();
|
||||
}
|
||||
};
|
||||
@@ -146,25 +178,23 @@ export const CalAiTranscribe = () => {
|
||||
});
|
||||
}, [transcriptHeight]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
id="cal-ai-transcription"
|
||||
style={{
|
||||
textShadow: "0 0 20px black, 0 0 20px black, 0 0 20px black",
|
||||
backgroundColor: "rgba(0,0,0,0.6)",
|
||||
}}
|
||||
ref={transcriptRef}
|
||||
className="flex max-h-full justify-center overflow-x-hidden overflow-y-scroll p-2 text-center text-white">
|
||||
{transcript
|
||||
? transcript.split("\n").map((line, i) => (
|
||||
<Fragment key={`transcript-${i}`}>
|
||||
{i > 0 && <br />}
|
||||
{line}
|
||||
</Fragment>
|
||||
))
|
||||
: ""}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
return transcript ? (
|
||||
<div
|
||||
id="cal-ai-transcription"
|
||||
style={{
|
||||
textShadow: "0 0 20px black, 0 0 20px black, 0 0 20px black",
|
||||
backgroundColor: "rgba(0,0,0,0.6)",
|
||||
}}
|
||||
ref={transcriptRef}
|
||||
className="flex max-h-full justify-center overflow-x-hidden overflow-y-scroll p-2 text-center text-white">
|
||||
{transcript
|
||||
? transcript.split("\n").map((line, i) => (
|
||||
<Fragment key={`transcript-${i}`}>
|
||||
{i > 0 && <br />}
|
||||
{line}
|
||||
</Fragment>
|
||||
))
|
||||
: ""}
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "4.4.1",
|
||||
"version": "4.4.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -84,7 +84,7 @@ function TeamPage({
|
||||
<li
|
||||
key={index}
|
||||
className={classNames(
|
||||
"dark:bg-darkgray-100 bg-default hover:bg-muted border-subtle group relative border-b first:rounded-t-md last:rounded-b-md last:border-b-0",
|
||||
"dark:bg-darkgray-100 bg-default hover:bg-muted border-subtle group relative border-b transition first:rounded-t-md last:rounded-b-md last:border-b-0",
|
||||
!isEmbed && "bg-default"
|
||||
)}>
|
||||
<div className="px-6 py-4 ">
|
||||
@@ -129,7 +129,7 @@ function TeamPage({
|
||||
(mem) => mem.subteams?.includes(ch.slug) && mem.accepted
|
||||
).length;
|
||||
return (
|
||||
<li key={i} className="hover:bg-muted w-full rounded-md">
|
||||
<li key={i} className="hover:bg-muted w-full rounded-md transition">
|
||||
<Link href={`/${ch.slug}`} className="flex items-center justify-between">
|
||||
<div className="flex items-center px-5 py-5">
|
||||
<div className="ms-3 inline-block truncate">
|
||||
|
||||
@@ -72,23 +72,23 @@ test("dynamic booking info prefilled by query params", async ({ page, users }) =
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const badgeByDurationTestId = (duration: number) => `multiple-choice-${duration}mins`;
|
||||
const listItemByDurationTestId = (duration: number) => `multiple-choice-${duration}mins`;
|
||||
|
||||
let badgeLocator = await page.getByTestId(badgeByDurationTestId(duration));
|
||||
let activeState = await badgeLocator.getAttribute("data-active");
|
||||
let listItemLocator = await page.getByTestId(listItemByDurationTestId(duration));
|
||||
let activeState = await listItemLocator.getAttribute("data-active");
|
||||
|
||||
expect(activeState).toEqual("true");
|
||||
|
||||
duration = 30;
|
||||
await page.goto(`/${pro.username}+${free.username}?duration=${duration}`);
|
||||
badgeLocator = await page.getByTestId(badgeByDurationTestId(duration));
|
||||
activeState = await badgeLocator.getAttribute("data-active");
|
||||
listItemLocator = await page.getByTestId(listItemByDurationTestId(duration));
|
||||
activeState = await listItemLocator.getAttribute("data-active");
|
||||
|
||||
expect(activeState).toEqual("true");
|
||||
|
||||
// Check another badge just to ensure its not selected
|
||||
badgeLocator = await page.getByTestId(badgeByDurationTestId(15));
|
||||
activeState = await badgeLocator.getAttribute("data-active");
|
||||
listItemLocator = await page.getByTestId(listItemByDurationTestId(15));
|
||||
activeState = await listItemLocator.getAttribute("data-active");
|
||||
expect(activeState).toEqual("false");
|
||||
});
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
|
||||
@@ -64,6 +64,7 @@ testBothFutureAndLegacyRoutes.describe("Event Types tests", () => {
|
||||
const nonce = randomString(3);
|
||||
const eventTitle = `my recurring event ${nonce}`;
|
||||
await createNewEventType(page, { eventTitle });
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await page.click("[data-testid=vertical-tab-recurring]");
|
||||
await expect(page.locator("[data-testid=recurring-event-collapsible]")).toBeHidden();
|
||||
|
||||
@@ -121,7 +121,7 @@ test.describe("Insights", async () => {
|
||||
.locator('div[class="flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1"]')
|
||||
.click();
|
||||
const teamSelectFilter = await page.locator(
|
||||
'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 hover:cursor-pointer"]'
|
||||
'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]'
|
||||
);
|
||||
|
||||
await expect(teamSelectFilter).toHaveCount(3);
|
||||
@@ -142,7 +142,7 @@ test.describe("Insights", async () => {
|
||||
await page.getByTestId("dashboard-shell").getByText("All").nth(1).click();
|
||||
|
||||
const teamSelectFilter = await page.locator(
|
||||
'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 hover:cursor-pointer"]'
|
||||
'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]'
|
||||
);
|
||||
|
||||
await expect(teamSelectFilter).toHaveCount(4);
|
||||
@@ -164,7 +164,7 @@ test.describe("Insights", async () => {
|
||||
.locator('div[class="flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1"]')
|
||||
.click();
|
||||
const teamSelectFilter = await page.locator(
|
||||
'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 hover:cursor-pointer"]'
|
||||
'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]'
|
||||
);
|
||||
|
||||
await expect(teamSelectFilter).toHaveCount(3);
|
||||
@@ -186,7 +186,7 @@ test.describe("Insights", async () => {
|
||||
.locator('div[class="flex flex-col gap-0.5 [&>*:first-child]:mt-1 [&>*:last-child]:mb-1"]')
|
||||
.click();
|
||||
const teamSelectFilter = await page.locator(
|
||||
'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 hover:cursor-pointer"]'
|
||||
'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]'
|
||||
);
|
||||
|
||||
await expect(teamSelectFilter).toHaveCount(3);
|
||||
@@ -218,13 +218,17 @@ test.describe("Insights", async () => {
|
||||
await page.locator('div[class="flex select-none truncate font-medium"]').getByText("People").click();
|
||||
|
||||
await page
|
||||
.locator('div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 hover:cursor-pointer"]')
|
||||
.locator(
|
||||
'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]'
|
||||
)
|
||||
.nth(0)
|
||||
.click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await page
|
||||
.locator('div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 hover:cursor-pointer"]')
|
||||
.locator(
|
||||
'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]'
|
||||
)
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
@@ -654,6 +654,7 @@
|
||||
"default_duration": "Výchozí doba trvání",
|
||||
"default_duration_no_options": "Nejprve vyberte dostupné doby trvání",
|
||||
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
|
||||
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
|
||||
"minutes": "min",
|
||||
"round_robin": "Plánování Round Robin",
|
||||
"round_robin_description": "Schůzky v řadě mezi několika členy týmu.",
|
||||
|
||||
@@ -554,6 +554,7 @@
|
||||
"default_duration": "Standard varighed",
|
||||
"default_duration_no_options": "Vælg venligst tilgængelige varigheder først",
|
||||
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
|
||||
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
|
||||
"minutes": "Minutter",
|
||||
"round_robin": "Round Robin",
|
||||
"round_robin_description": "Cyklusmøder mellem flere teammedlemmer.",
|
||||
|
||||
@@ -713,6 +713,7 @@
|
||||
"default_duration_no_options": "Please choose available durations first",
|
||||
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
|
||||
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
|
||||
"multiple_duration_timeUnit_short": "{{count}}$t({{unit}}_short)",
|
||||
"minutes": "Minutes",
|
||||
"use_cal_ai_to_make_call_description": "Use Cal.ai to get an AI powered phone number or make calls to guests.",
|
||||
"round_robin": "Round Robin",
|
||||
@@ -1118,7 +1119,9 @@
|
||||
"impersonating_user_warning": "Impersonating username \"{{user}}\".",
|
||||
"impersonating_stop_instructions": "Click here to stop",
|
||||
"event_location_changed": "Updated - Your event changed the location",
|
||||
"new_guests_added": "Added - New guests added to your event",
|
||||
"location_changed_event_type_subject": "Location Changed: {{eventType}} with {{name}} at {{date}}",
|
||||
"guests_added_event_type_subject": "Guests Added: {{eventType}} with {{name}} at {{date}}",
|
||||
"current_location": "Current Location",
|
||||
"new_location": "New Location",
|
||||
"session": "Session",
|
||||
@@ -1130,7 +1133,10 @@
|
||||
"set_location": "Set Location",
|
||||
"update_location": "Update Location",
|
||||
"location_updated": "Location updated",
|
||||
"guests_added": "Guests added",
|
||||
"unable_to_add_guests": "Unable to add guests",
|
||||
"email_validation_error": "That doesn't look like an email address",
|
||||
"emails_must_be_unique_valid": "Emails must be unique and valid",
|
||||
"url_validation_error": "That doesn't look like a URL",
|
||||
"place_where_cal_widget_appear": "Place this code in your HTML where you want your {{appName}} widget to appear.",
|
||||
"create_update_react_component": "Create or update an existing React component as shown below.",
|
||||
@@ -1224,6 +1230,8 @@
|
||||
"day_timeUnit": "days",
|
||||
"hour_timeUnit": "hours",
|
||||
"minute_timeUnit": "mins",
|
||||
"minute_short": "m",
|
||||
"hour_short": "h",
|
||||
"new_workflow_heading": "Create your first workflow",
|
||||
"new_workflow_description": "Workflows enable you to automate sending reminders and notifications.",
|
||||
"active_on": "Active on",
|
||||
@@ -1277,8 +1285,10 @@
|
||||
"reminder_email": "Reminder: {{eventType}} with {{name}} at {{date}}",
|
||||
"not_triggering_existing_bookings": "Won't trigger for already existing bookings as user will be asked for phone number when booking the event.",
|
||||
"minute_one": "{{count}} minute",
|
||||
"minute_one_short": "{{count}}m",
|
||||
"minute_other": "{{count}} minutes",
|
||||
"hour_one": "{{count}} hour",
|
||||
"hour_one_short": "{{count}}h",
|
||||
"hour_other": "{{count}} hours",
|
||||
"invalid_input": "Invalid input",
|
||||
"broken_video_action": "We could not add the <1>{{location}}</1> meeting link to your scheduled event. Contact your invitees or update your calendar event to add the details. You can either <3> change your location on the event type </3> or try <5>removing and adding the app again.</5>",
|
||||
@@ -2421,6 +2431,7 @@
|
||||
"primary": "Primary",
|
||||
"make_primary": "Make primary",
|
||||
"add_email": "Add Email",
|
||||
"add_emails": "Add Emails",
|
||||
"add_email_description": "Add an email address to replace your primary or to use as an alternative email on your event types.",
|
||||
"confirm_email": "Confirm your email",
|
||||
"scheduler_first_name": "The first name of the person booking",
|
||||
@@ -2561,5 +2572,6 @@
|
||||
"number_of_options":"{{count}} options",
|
||||
"reschedule_with_same_round_robin_host_title": "Reschedule with same Round-Robin host",
|
||||
"reschedule_with_same_round_robin_host_description": "Rescheduled events will be assigned to the same host as initially scheduled",
|
||||
"disable_input_if_prefilled": "Disable input if the URL identifier is prefilled",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -653,6 +653,7 @@
|
||||
"default_duration": "Duración predeterminada",
|
||||
"default_duration_no_options": "Seleccione primero las duraciones disponibles",
|
||||
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
|
||||
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
|
||||
"minutes": "Minutos",
|
||||
"round_robin": "Petición Firmada por Turnos",
|
||||
"round_robin_description": "Ciclo de reuniones entre varios miembros del equipo.",
|
||||
|
||||
@@ -616,6 +616,8 @@
|
||||
"day_timeUnit": "egun",
|
||||
"hour_timeUnit": "ordu",
|
||||
"minute_timeUnit": "minutu",
|
||||
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
|
||||
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
|
||||
"current": "Unekoa",
|
||||
"confirm_username_change_dialog_title": "Baieztatu erabiltzaile-izenaren aldaketa",
|
||||
"requires_confirmation": "Baieztapena behar du",
|
||||
|
||||
@@ -654,6 +654,7 @@
|
||||
"default_duration": "デフォルトの時間",
|
||||
"default_duration_no_options": "最初に空いている時間を選択してください",
|
||||
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
|
||||
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
|
||||
"minutes": "分",
|
||||
"round_robin": "ラウンドロビン",
|
||||
"round_robin_description": "複数のチームメンバー間のミーティングを定期化する。",
|
||||
|
||||
@@ -654,6 +654,7 @@
|
||||
"default_duration": "기본 기간",
|
||||
"default_duration_no_options": "사용 가능한 기간을 먼저 선택하세요",
|
||||
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
|
||||
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
|
||||
"minutes": "분",
|
||||
"round_robin": "라운드 로빈",
|
||||
"round_robin_description": "여러 팀 구성원 간의 주기적인 회의",
|
||||
|
||||
@@ -654,6 +654,7 @@
|
||||
"default_duration": "Standaardduur",
|
||||
"default_duration_no_options": "Kies eerst de beschikbare duur",
|
||||
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
|
||||
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
|
||||
"minutes": "Minuten",
|
||||
"round_robin": "Round Robin",
|
||||
"round_robin_description": "Afspraken wisselen tussen meerdere teamleden.",
|
||||
|
||||
@@ -545,6 +545,7 @@
|
||||
"default_duration": "Standard varighet",
|
||||
"default_duration_no_options": "Vennligst velg tilgjengelige varigheter først",
|
||||
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
|
||||
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
|
||||
"minutes": "Minutter",
|
||||
"round_robin": "Round Robin",
|
||||
"round_robin_description": "Varier møter mellom flere teammedlemmer.",
|
||||
|
||||
@@ -654,6 +654,7 @@
|
||||
"default_duration": "Domyślny czas trwania",
|
||||
"default_duration_no_options": "Proszę najpierw wybrać dostępne czasy trwania",
|
||||
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
|
||||
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
|
||||
"minutes": "Minuty",
|
||||
"round_robin": "Round Robin",
|
||||
"round_robin_description": "Cykl spotkań między wieloma członkami zespołu.",
|
||||
|
||||
@@ -699,6 +699,7 @@
|
||||
"default_duration": "Duração padrão",
|
||||
"default_duration_no_options": "Escolha as durações disponíveis primeiro",
|
||||
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
|
||||
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
|
||||
"minutes": "Minutos",
|
||||
"use_cal_ai_to_make_call_description": "Use Cal.ai para adquirir um número de telefone com IA ou faça ligações para convidados.",
|
||||
"round_robin": "Round Robin",
|
||||
|
||||
@@ -654,6 +654,7 @@
|
||||
"default_duration": "Durată implicită",
|
||||
"default_duration_no_options": "Alege mai întâi duratele disponibile",
|
||||
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
|
||||
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
|
||||
"minutes": "Minute",
|
||||
"round_robin": "Round Robin",
|
||||
"round_robin_description": "Întâlniri ciclice între mai mulţi membri ai echipei.",
|
||||
|
||||
@@ -655,6 +655,7 @@
|
||||
"default_duration": "Продолжительность по умолчанию",
|
||||
"default_duration_no_options": "Пожалуйста, выберите сначала доступную продолжительность",
|
||||
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
|
||||
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
|
||||
"minutes": "мин.",
|
||||
"round_robin": "По кругу",
|
||||
"round_robin_description": "Цикл встреч между несколькими членами команды.",
|
||||
|
||||
@@ -654,6 +654,7 @@
|
||||
"default_duration": "Podrazumevano trajanje",
|
||||
"default_duration_no_options": "Najpre izaberite dostupna trajanja",
|
||||
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
|
||||
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
|
||||
"minutes": "Minuti",
|
||||
"round_robin": "Round Robin",
|
||||
"round_robin_description": "Ciklusirajte sastanke između više članova tima.",
|
||||
|
||||
@@ -654,6 +654,7 @@
|
||||
"default_duration": "Standardvaraktighet",
|
||||
"default_duration_no_options": "Välj först tillgänglig varaktighet",
|
||||
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
|
||||
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
|
||||
"minutes": "Minuter",
|
||||
"round_robin": "Tur och ordning",
|
||||
"round_robin_description": "Variera möten mellan olika teammedlemmar.",
|
||||
|
||||
@@ -654,6 +654,7 @@
|
||||
"default_duration": "Varsayılan süre",
|
||||
"default_duration_no_options": "Lütfen önce uygun süreleri seçin",
|
||||
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
|
||||
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
|
||||
"minutes": "Dakika",
|
||||
"round_robin": "Round Robin",
|
||||
"round_robin_description": "Toplantıları birden fazla ekip üyesi arasında döndürün.",
|
||||
|
||||
@@ -686,6 +686,7 @@
|
||||
"default_duration": "Тривалість за замовчуванням",
|
||||
"default_duration_no_options": "Спочатку виберіть доступні варіанти тривалості",
|
||||
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
|
||||
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
|
||||
"minutes": "Хвилини",
|
||||
"round_robin": "Ротація",
|
||||
"round_robin_description": "Кілька учасників команди призначаються для нарад циклічно й по черзі.",
|
||||
|
||||
@@ -654,7 +654,8 @@
|
||||
"available_durations": "Khoảng thời gian khả dụng",
|
||||
"default_duration": "Khoảng thời gian mặc định",
|
||||
"default_duration_no_options": "Vui lòng chọn trước tiên những khoảng thời gian khả dụng",
|
||||
"multiple_duration_mins": "{{count}}$t(minute_timeUnit)",
|
||||
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
|
||||
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
|
||||
"minutes": "Phút",
|
||||
"round_robin": "Round Robin",
|
||||
"round_robin_description": "Luân phiên những cuộc họp giữa các thành viên trong nhóm.",
|
||||
|
||||
@@ -691,6 +691,7 @@
|
||||
"default_duration": "默认时长",
|
||||
"default_duration_no_options": "请先选择可用时长",
|
||||
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
|
||||
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
|
||||
"minutes": "分钟",
|
||||
"use_cal_ai_to_make_call_description": "使用 Cal.ai 获得人工智能驱动的电话号码或拨打电话给访客。",
|
||||
"round_robin": "轮流模式",
|
||||
|
||||
@@ -660,6 +660,7 @@
|
||||
"default_duration": "預設時間長度",
|
||||
"default_duration_no_options": "請先選擇可預約的時間長度",
|
||||
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
|
||||
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
|
||||
"minutes": "分鐘",
|
||||
"round_robin": "循環制",
|
||||
"round_robin_description": "與多位團隊成員進行週期會議。",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Controller, useForm } from "react-hook-form";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import z from "zod";
|
||||
|
||||
import { WebAppURL } from "@calcom/lib/WebAppURL";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||
import { Button, Form, showToast, TextField } from "@calcom/ui";
|
||||
@@ -66,7 +67,7 @@ export default function CloseComSetup() {
|
||||
form={form}
|
||||
handleSubmit={async (values) => {
|
||||
const { returnTo } = query;
|
||||
const url = new URL("/api/integrations/closecom/add");
|
||||
const url = new WebAppURL("/api/integrations/closecom/add");
|
||||
if (returnTo) url.searchParams.append("returnTo", `${returnTo}`);
|
||||
const res = await fetch(url.href, {
|
||||
method: "POST",
|
||||
|
||||
@@ -18,7 +18,7 @@ const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({ eventTyp
|
||||
<a download href={QR_URL} target="_blank" rel="noreferrer">
|
||||
<img
|
||||
className={classNames(
|
||||
"hover:bg-muted border-default border hover:shadow-sm",
|
||||
"hover:bg-muted border-default border transition hover:shadow-sm",
|
||||
size >= 256 && "min-h-32"
|
||||
)}
|
||||
style={{ padding: size / 16, borderRadius: size / 20 }}
|
||||
|
||||
@@ -465,7 +465,7 @@ export const FormAction = forwardRef(function FormAction<T extends typeof Button
|
||||
<div
|
||||
{...restProps}
|
||||
className={classNames(
|
||||
"sm:hover:bg-subtle self-center rounded-md p-2 hover:bg-gray-200",
|
||||
"sm:hover:bg-subtle self-center rounded-md p-2 transition hover:bg-gray-200",
|
||||
extraClassNames
|
||||
)}>
|
||||
<Switch
|
||||
|
||||
@@ -47,7 +47,7 @@ export default function FormInputFields(props: Props) {
|
||||
</label>
|
||||
</div>
|
||||
<Component
|
||||
value={response[field.id]?.value}
|
||||
value={response[field.id]?.value ?? ""}
|
||||
placeholder={field.placeholder ?? ""}
|
||||
// required property isn't accepted by query-builder types
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
|
||||
@@ -207,7 +207,7 @@ const Actions = ({
|
||||
action="toggle"
|
||||
routingForm={form}
|
||||
label="Disable Form"
|
||||
extraClassNames="hover:bg-subtle cursor-pointer rounded-[5px] pr-4"
|
||||
extraClassNames="hover:bg-subtle cursor-pointer rounded-[5px] pr-4 transition"
|
||||
/>
|
||||
</div>
|
||||
</FormActionsDropdown>
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import widgets from "./widgets";
|
||||
|
||||
const { SelectWidget, MultiSelectWidget } = widgets;
|
||||
|
||||
// Mock the dynamic import of Select component
|
||||
vi.mock("next/dynamic", () => ({
|
||||
__esModule: true,
|
||||
default: () => {
|
||||
return function MockSelect({
|
||||
options,
|
||||
onChange,
|
||||
value,
|
||||
isMulti,
|
||||
}: {
|
||||
options: { value: string; label: string }[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onChange: (value: any) => void;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
value: any;
|
||||
isMulti: boolean;
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
data-testid="mock-select"
|
||||
multiple={isMulti}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
value={isMulti ? value.map((v: any) => v.value) : value?.value}
|
||||
onChange={(e) => {
|
||||
const selectedOptions = Array.from(e.target.selectedOptions, (option) => ({
|
||||
value: option.value,
|
||||
label: option.text,
|
||||
}));
|
||||
onChange(isMulti ? selectedOptions : selectedOptions[0]);
|
||||
}}>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Select Widgets", () => {
|
||||
describe("SelectWidget", () => {
|
||||
const listValues = [
|
||||
{ title: "Option 1", value: "1" },
|
||||
{ title: "Option 2", value: "2" },
|
||||
{ title: "Option 3", value: "3" },
|
||||
];
|
||||
|
||||
it("should handle render the value in option correctly", () => {
|
||||
const setValue = vi.fn();
|
||||
render(<SelectWidget value="2" setValue={setValue} listValues={listValues} />);
|
||||
|
||||
const select = screen.getByTestId("mock-select");
|
||||
expect(select).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("option")).toHaveLength(3);
|
||||
expect(setValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle a value that is not in the list and reset the value to empty string", () => {
|
||||
const setValue = vi.fn();
|
||||
render(<SelectWidget value="4" setValue={setValue} listValues={listValues} />);
|
||||
|
||||
const select = screen.getByTestId("mock-select");
|
||||
expect(select).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("option")).toHaveLength(3);
|
||||
expect(setValue).toHaveBeenCalledWith("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MultiSelectWidget", () => {
|
||||
const listValues = [
|
||||
{ title: "Option 1", value: "1" },
|
||||
{ title: "Option 2", value: "2" },
|
||||
{ title: "Option 3", value: "3" },
|
||||
];
|
||||
|
||||
it("renders options correctly", () => {
|
||||
const setValue = vi.fn();
|
||||
|
||||
render(<MultiSelectWidget value={[]} setValue={setValue} listValues={listValues} />);
|
||||
|
||||
const select = screen.getByTestId("mock-select");
|
||||
expect(select).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("option")).toHaveLength(3);
|
||||
expect(setValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets value to empty array when no options match", () => {
|
||||
const setValue = vi.fn();
|
||||
render(<MultiSelectWidget value={["4", "5"]} setValue={setValue} listValues={listValues} />);
|
||||
|
||||
expect(setValue).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
+15
-2
@@ -150,8 +150,6 @@ const MultiSelectWidget = ({
|
||||
value,
|
||||
...remainingProps
|
||||
}: SelectLikeComponentPropsRAQB<string[]>) => {
|
||||
//TODO: Use Select here.
|
||||
//TODO: Let's set listValue itself as label and value instead of using title.
|
||||
if (!listValues) {
|
||||
return null;
|
||||
}
|
||||
@@ -164,6 +162,14 @@ const MultiSelectWidget = ({
|
||||
|
||||
const optionsFromList = selectItems.filter((item) => value?.includes(item.value));
|
||||
|
||||
// If no value could be found in the list, then we set the value to undefined.
|
||||
// This is to update the value back to the source that we couldn't set it. This is important otherwise the outside party thinks that the value is set but it is not.
|
||||
// Do it only when it is not already empty, this is to avoid infinite state updates
|
||||
// NOTE: value is some times sent as undefined even though the type will tell you that it can't be
|
||||
if (optionsFromList.length === 0 && value?.length) {
|
||||
setValue([]);
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
aria-label="multi-select-dropdown"
|
||||
@@ -192,6 +198,13 @@ function SelectWidget({ listValues, setValue, value, ...remainingProps }: Select
|
||||
});
|
||||
const optionFromList = selectItems.find((item) => item.value === value);
|
||||
|
||||
// If the value is not in the list, then we set the value to undefined.
|
||||
// This is to update the value back to the source that we couldn't set it. This is important otherwise the outside party thinks that the value is set but it is not.
|
||||
// Do it only when it is not already empty string, this is to avoid infinite state updates
|
||||
if (!optionFromList && value) {
|
||||
setValue("");
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
aria-label="select-dropdown"
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { EmailVerifyLink } from "./templates/account-verify-email";
|
||||
import AccountVerifyEmail from "./templates/account-verify-email";
|
||||
import type { OrganizationNotification } from "./templates/admin-organization-notification";
|
||||
import AdminOrganizationNotification from "./templates/admin-organization-notification";
|
||||
import AttendeeAddGuestsEmail from "./templates/attendee-add-guests-email";
|
||||
import AttendeeAwaitingPaymentEmail from "./templates/attendee-awaiting-payment-email";
|
||||
import AttendeeCancelledEmail from "./templates/attendee-cancelled-email";
|
||||
import AttendeeCancelledSeatEmail from "./templates/attendee-cancelled-seat-email";
|
||||
@@ -48,6 +49,7 @@ import type { OrganizationCreation } from "./templates/organization-creation-ema
|
||||
import OrganizationCreationEmail from "./templates/organization-creation-email";
|
||||
import type { OrganizationEmailVerify } from "./templates/organization-email-verification";
|
||||
import OrganizationEmailVerification from "./templates/organization-email-verification";
|
||||
import OrganizerAddGuestsEmail from "./templates/organizer-add-guests-email";
|
||||
import OrganizerAttendeeCancelledSeatEmail from "./templates/organizer-attendee-cancelled-seat-email";
|
||||
import OrganizerCancelledEmail from "./templates/organizer-cancelled-email";
|
||||
import OrganizerDailyVideoDownloadRecordingEmail from "./templates/organizer-daily-video-download-recording-email";
|
||||
@@ -378,7 +380,7 @@ export const sendCancelledEmails = async (
|
||||
t: attendee.language.translate,
|
||||
attendeeName: attendee.name,
|
||||
host: calendarEvent.organizer.name,
|
||||
eventType: calendarEvent.type,
|
||||
eventType: calendarEvent.title,
|
||||
eventDuration,
|
||||
...(calendarEvent.responses && { bookingFields: calendarEvent.responses }),
|
||||
...(calendarEvent.location && { location: calendarEvent.location }),
|
||||
@@ -520,6 +522,32 @@ export const sendLocationChangeEmails = async (
|
||||
|
||||
await Promise.all(emailsToSend);
|
||||
};
|
||||
export const sendAddGuestsEmails = async (calEvent: CalendarEvent, newGuests: string[]) => {
|
||||
const calendarEvent = formatCalEvent(calEvent);
|
||||
|
||||
const emailsToSend: Promise<unknown>[] = [];
|
||||
emailsToSend.push(sendEmail(() => new OrganizerAddGuestsEmail({ calEvent: calendarEvent })));
|
||||
|
||||
if (calendarEvent.team?.members) {
|
||||
for (const teamMember of calendarEvent.team.members) {
|
||||
emailsToSend.push(
|
||||
sendEmail(() => new OrganizerAddGuestsEmail({ calEvent: calendarEvent, teamMember }))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
emailsToSend.push(
|
||||
...calendarEvent.attendees.map((attendee) => {
|
||||
if (newGuests.includes(attendee.email)) {
|
||||
return sendEmail(() => new AttendeeScheduledEmail(calendarEvent, attendee));
|
||||
} else {
|
||||
return sendEmail(() => new AttendeeAddGuestsEmail(calendarEvent, attendee));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(emailsToSend);
|
||||
};
|
||||
export const sendFeedbackEmail = async (feedback: Feedback) => {
|
||||
await sendEmail(() => new FeedbackEmail(feedback));
|
||||
};
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { AttendeeScheduledEmail } from "./AttendeeScheduledEmail";
|
||||
|
||||
export const AttendeeAddGuestsEmail = (props: React.ComponentProps<typeof AttendeeScheduledEmail>) => (
|
||||
<AttendeeScheduledEmail
|
||||
title="new_guests_added"
|
||||
headerType="calendarCircle"
|
||||
subject="guests_added_event_type_subject"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
import { OrganizerScheduledEmail } from "./OrganizerScheduledEmail";
|
||||
|
||||
export const OrganizerAddGuestsEmail = (props: React.ComponentProps<typeof OrganizerScheduledEmail>) => (
|
||||
<OrganizerScheduledEmail
|
||||
title="new_guests_added"
|
||||
headerType="calendarCircle"
|
||||
subject="guests_added_event_type_subject"
|
||||
callToAction={null}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -35,4 +35,6 @@ export { AdminOrganizationNotificationEmail } from "./AdminOrganizationNotificat
|
||||
export { BookingRedirectEmailNotification } from "./BookingRedirectEmailNotification";
|
||||
export { VerifyEmailChangeEmail } from "./VerifyEmailChangeEmail";
|
||||
export { OrganizationCreationEmail } from "./OrganizationCreationEmail";
|
||||
export { OrganizerAddGuestsEmail } from "./OrganizerAddGuestsEmail";
|
||||
export { AttendeeAddGuestsEmail } from "./AttendeeAddGuestsEmail";
|
||||
export { OrganizationAdminNoSlotsEmail } from "./OrganizationAdminNoSlots";
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { renderEmail } from "../";
|
||||
import generateIcsString from "../lib/generateIcsString";
|
||||
import AttendeeScheduledEmail from "./attendee-scheduled-email";
|
||||
|
||||
export default class AttendeeAddGuestsEmail extends AttendeeScheduledEmail {
|
||||
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
|
||||
return {
|
||||
icalEvent: {
|
||||
filename: "event.ics",
|
||||
content: generateIcsString({
|
||||
event: this.calEvent,
|
||||
title: this.t("new_guests_added"),
|
||||
subtitle: this.t("emailed_you_and_any_other_attendees"),
|
||||
role: "attendee",
|
||||
status: "CONFIRMED",
|
||||
}),
|
||||
method: "REQUEST",
|
||||
},
|
||||
to: `${this.attendee.name} <${this.attendee.email}>`,
|
||||
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
|
||||
replyTo: this.calEvent.organizer.email,
|
||||
subject: `${this.t("guests_added_event_type_subject", {
|
||||
eventType: this.calEvent.type,
|
||||
name: this.calEvent.team?.name || this.calEvent.organizer.name,
|
||||
date: this.getFormattedDate(),
|
||||
})}`,
|
||||
html: await renderEmail("AttendeeAddGuestsEmail", {
|
||||
calEvent: this.calEvent,
|
||||
attendee: this.attendee,
|
||||
}),
|
||||
text: this.getTextBody("new_guests_added"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
|
||||
import { renderEmail } from "../";
|
||||
import generateIcsString from "../lib/generateIcsString";
|
||||
import OrganizerScheduledEmail from "./organizer-scheduled-email";
|
||||
|
||||
export default class OrganizerAddGuestsEmail extends OrganizerScheduledEmail {
|
||||
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
|
||||
const toAddresses = [this.teamMember?.email || this.calEvent.organizer.email];
|
||||
|
||||
return {
|
||||
icalEvent: {
|
||||
filename: "event.ics",
|
||||
content: generateIcsString({
|
||||
event: this.calEvent,
|
||||
title: this.t("new_guests_added"),
|
||||
subtitle: this.t("emailed_you_and_any_other_attendees"),
|
||||
role: "organizer",
|
||||
status: "CONFIRMED",
|
||||
}),
|
||||
method: "REQUEST",
|
||||
},
|
||||
from: `${APP_NAME} <${this.getMailerOptions().from}>`,
|
||||
to: toAddresses.join(","),
|
||||
replyTo: [this.calEvent.organizer.email, ...this.calEvent.attendees.map(({ email }) => email)],
|
||||
subject: `${this.t("guests_added_event_type_subject", {
|
||||
eventType: this.calEvent.type,
|
||||
name: this.calEvent.attendees[0].name,
|
||||
date: this.getFormattedDate(),
|
||||
})}`,
|
||||
html: await renderEmail("OrganizerAddGuestsEmail", {
|
||||
attendee: this.calEvent.organizer,
|
||||
calEvent: this.calEvent,
|
||||
}),
|
||||
text: this.getTextBody("new_guests_added"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,7 @@ export const getEmbedIframe = async ({
|
||||
}, hardTimeout);
|
||||
});
|
||||
},
|
||||
!process.env.CI ? 150000 : 15000
|
||||
!process.env.CI ? 15000 : 15000
|
||||
);
|
||||
if (!iframeReady) {
|
||||
return null;
|
||||
|
||||
@@ -315,16 +315,18 @@ const AdminAppsListContainer = () => {
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
<EditKeysModal
|
||||
keys={modalState.keys}
|
||||
dirName={modalState.dirName}
|
||||
handleModelClose={handleModelClose}
|
||||
isOpen={modalState.isOpen === "editKeys"}
|
||||
slug={modalState.slug}
|
||||
type={modalState.type}
|
||||
fromEnabled={modalState.fromEnabled}
|
||||
appName={modalState.appName}
|
||||
/>
|
||||
{modalState.isOpen === "editKeys" && (
|
||||
<EditKeysModal
|
||||
keys={modalState.keys}
|
||||
dirName={modalState.dirName}
|
||||
handleModelClose={handleModelClose}
|
||||
isOpen={modalState.isOpen === "editKeys"}
|
||||
slug={modalState.slug}
|
||||
type={modalState.type}
|
||||
fromEnabled={modalState.fromEnabled}
|
||||
appName={modalState.appName}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -169,7 +169,7 @@ export const EventMeta = ({
|
||||
timezoneSelectCustomClassname={classNames?.eventMetaTimezoneSelect}
|
||||
classNames={{
|
||||
control: () => "!min-h-0 p-0 w-full border-0 bg-transparent focus-within:ring-0",
|
||||
menu: () => "!w-64 max-w-[90vw]",
|
||||
menu: () => "!w-64 max-w-[90vw] mb-1 ",
|
||||
singleValue: () => "text-text py-1",
|
||||
indicatorsContainer: () => "ml-auto",
|
||||
container: () => "max-w-full",
|
||||
|
||||
@@ -142,7 +142,7 @@ export const EventDetails = ({ event, blocks = defaultEventDetailsBlocks }: Even
|
||||
switch (block) {
|
||||
case EventDetailBlocks.DURATION:
|
||||
return (
|
||||
<EventMetaBlock key={block} icon="clock">
|
||||
<EventMetaBlock key={block} icon="clock" className="items-center">
|
||||
<EventDuration event={event} />
|
||||
</EventMetaBlock>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { TFunction } from "next-i18next";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { useIsPlatform } from "@calcom/atoms/monorepo";
|
||||
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
|
||||
import type { BookerEvent } from "@calcom/features/bookings/types";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Badge } from "@calcom/ui";
|
||||
import { useShouldShowArrows, Icon } from "@calcom/ui";
|
||||
|
||||
/** Render X mins as X hours or X hours Y mins instead of in minutes once >= 60 minutes */
|
||||
export const getDurationFormatted = (mins: number | undefined, t: TFunction) => {
|
||||
@@ -19,16 +19,16 @@ export const getDurationFormatted = (mins: number | undefined, t: TFunction) =>
|
||||
if (mins > 0) {
|
||||
minStr =
|
||||
mins === 1
|
||||
? t("minute_one", { count: 1 })
|
||||
: t("multiple_duration_timeUnit", { count: mins, unit: "minute" });
|
||||
? t("minute_one_short", { count: 1 })
|
||||
: t("multiple_duration_timeUnit_short", { count: mins, unit: "minute" });
|
||||
}
|
||||
// format hours string
|
||||
let hourStr = "";
|
||||
if (hours > 0) {
|
||||
hourStr =
|
||||
hours === 1
|
||||
? t("hour_one", { count: 1 })
|
||||
: t("multiple_duration_timeUnit", { count: hours, unit: "hour" });
|
||||
? t("hour_one_short", { count: 1 })
|
||||
: t("multiple_duration_timeUnit_short", { count: hours, unit: "hour" });
|
||||
}
|
||||
|
||||
if (hourStr && minStr) return `${hourStr} ${minStr}`;
|
||||
@@ -41,6 +41,7 @@ export const EventDuration = ({
|
||||
event: Pick<BookerEvent, "length" | "metadata" | "isDynamic">;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const itemRefs = useRef<(HTMLLIElement | null)[]>([]);
|
||||
const isPlatform = useIsPlatform();
|
||||
const [selectedDuration, setSelectedDuration, state] = useBookerStore((state) => [
|
||||
state.selectedDuration,
|
||||
@@ -48,6 +49,20 @@ export const EventDuration = ({
|
||||
state.state,
|
||||
]);
|
||||
|
||||
const { ref, calculateScroll, leftVisible, rightVisible } = useShouldShowArrows();
|
||||
|
||||
const handleLeft = () => {
|
||||
if (ref.current) {
|
||||
ref.current.scrollLeft -= 100;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRight = () => {
|
||||
if (ref.current) {
|
||||
ref.current.scrollLeft += 100;
|
||||
}
|
||||
};
|
||||
|
||||
const isDynamicEvent = "isDynamic" in event && event.isDynamic;
|
||||
|
||||
// Sets initial value of selected duration to the default duration.
|
||||
@@ -57,27 +72,63 @@ export const EventDuration = ({
|
||||
setSelectedDuration(event.length);
|
||||
}, [selectedDuration, setSelectedDuration, event.metadata?.multipleDuration, event.length, isDynamicEvent]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (selectedDuration && itemRefs.current[selectedDuration]) {
|
||||
itemRefs.current[selectedDuration]?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
inline: "center",
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [selectedDuration]);
|
||||
|
||||
if ((!event?.metadata?.multipleDuration && !isDynamicEvent) || isPlatform)
|
||||
return <>{getDurationFormatted(event.length, t)}</>;
|
||||
|
||||
const durations = event?.metadata?.multipleDuration || [15, 30, 60, 90];
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{durations
|
||||
.filter((dur) => state !== "booking" || dur === selectedDuration)
|
||||
.map((duration) => (
|
||||
<Badge
|
||||
data-testId={`multiple-choice-${duration}mins`}
|
||||
data-active={selectedDuration === duration ? "true" : "false"}
|
||||
variant="gray"
|
||||
className={classNames(selectedDuration === duration && "bg-brand-default text-brand")}
|
||||
size="md"
|
||||
key={duration}
|
||||
onClick={() => setSelectedDuration(duration)}>
|
||||
{getDurationFormatted(duration, t)}
|
||||
</Badge>
|
||||
))}
|
||||
return selectedDuration ? (
|
||||
<div className="border-default relative mr-5 flex flex-row items-center justify-between rounded-md border">
|
||||
{leftVisible && (
|
||||
<button onClick={handleLeft} className="absolute bottom-0 left-0 flex">
|
||||
<div className="bg-default flex h-9 w-5 items-center justify-end rounded-md">
|
||||
<Icon name="chevron-left" className="text-subtle h-4 w-4" />
|
||||
</div>
|
||||
<div className="to-default flex h-9 w-5 bg-gradient-to-l from-transparent" />
|
||||
</button>
|
||||
)}
|
||||
<ul
|
||||
className="bg-default no-scrollbar flex max-w-full items-center gap-0.5 overflow-x-auto rounded-md p-1"
|
||||
onScroll={(e) => calculateScroll(e)}
|
||||
ref={ref}>
|
||||
{durations
|
||||
.filter((dur) => state !== "booking" || dur === selectedDuration)
|
||||
.map((duration, index) => (
|
||||
<li
|
||||
data-testId={`multiple-choice-${duration}mins`}
|
||||
data-active={selectedDuration === duration ? "true" : "false"}
|
||||
key={index}
|
||||
onClick={() => setSelectedDuration(duration)}
|
||||
ref={(el) => (itemRefs.current[duration] = el)}
|
||||
className={classNames(
|
||||
selectedDuration === duration ? "bg-emphasis" : "hover:text-emphasis",
|
||||
"text-default cursor-pointer rounded-[4px] px-3 py-1.5 text-sm leading-tight transition"
|
||||
)}>
|
||||
<div className="w-max">{getDurationFormatted(duration, t)}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{rightVisible && (
|
||||
<button onClick={handleRight} className="absolute bottom-0 right-0 flex">
|
||||
<div className="to-default flex h-9 w-5 bg-gradient-to-r from-transparent" />
|
||||
<div className="bg-default flex h-9 w-5 items-center justify-end rounded-md">
|
||||
<Icon name="chevron-right" className="text-subtle h-4 w-4" />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
) : null;
|
||||
};
|
||||
|
||||
@@ -483,15 +483,8 @@ async function handler(
|
||||
if (eventType.seatsPerTimeSlot) {
|
||||
const booking = await prisma.booking.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
uid: rescheduleUid || reqBody.bookingUid,
|
||||
},
|
||||
{
|
||||
eventTypeId: eventType.id,
|
||||
startTime: new Date(dayjs(reqBody.start).utc().format()),
|
||||
},
|
||||
],
|
||||
eventTypeId: eventType.id,
|
||||
startTime: new Date(dayjs(reqBody.start).utc().format()),
|
||||
status: BookingStatus.ACCEPTED,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2413,6 +2413,151 @@ describe("handleSeats", () => {
|
||||
// const rescheduledBooking = await handleNewBooking(req);
|
||||
await expect(() => handleNewBooking(req)).rejects.toThrowError(ErrorCode.NotEnoughAvailableSeats);
|
||||
});
|
||||
|
||||
test("When trying to reschedule in a non-available slot, throw an error", async () => {
|
||||
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
|
||||
|
||||
const booker = getBooker({
|
||||
email: "booker@example.com",
|
||||
name: "Booker",
|
||||
});
|
||||
|
||||
const organizer = getOrganizer({
|
||||
name: "Organizer",
|
||||
email: "organizer@example.com",
|
||||
id: 101,
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
});
|
||||
|
||||
const firstBookingId = 1;
|
||||
const firstBookingUid = "abc123";
|
||||
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
|
||||
const firstBookingStartTime = `${plus1DateString}T04:00:00Z`;
|
||||
const firstBookingEndTime = `${plus1DateString}T04:30:00Z`;
|
||||
|
||||
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
|
||||
// Non-available time slot choosen (7:30PM - 8:00PM IST) while rescheduling
|
||||
const secondBookingStartTime = `${plus2DateString}T14:00:00Z`;
|
||||
const secondBookingEndTime = `${plus2DateString}T14:30:00Z`;
|
||||
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
slug: "seated-event",
|
||||
slotInterval: 30,
|
||||
length: 30,
|
||||
users: [
|
||||
{
|
||||
id: 101,
|
||||
},
|
||||
],
|
||||
seatsPerTimeSlot: 3,
|
||||
seatsShowAttendees: false,
|
||||
},
|
||||
],
|
||||
bookings: [
|
||||
{
|
||||
id: firstBookingId,
|
||||
uid: firstBookingUid,
|
||||
eventTypeId: 1,
|
||||
userId: organizer.id,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
startTime: firstBookingStartTime,
|
||||
endTime: firstBookingEndTime,
|
||||
metadata: {
|
||||
videoCallUrl: "https://existing-daily-video-call-url.example.com",
|
||||
},
|
||||
references: [
|
||||
{
|
||||
type: appStoreMetadata.dailyvideo.type,
|
||||
uid: "MOCK_ID",
|
||||
meetingId: "MOCK_ID",
|
||||
meetingPassword: "MOCK_PASS",
|
||||
meetingUrl: "http://mock-dailyvideo.example.com",
|
||||
credentialId: null,
|
||||
},
|
||||
],
|
||||
attendees: [
|
||||
getMockBookingAttendee({
|
||||
id: 1,
|
||||
name: "Seat 1",
|
||||
email: "seat1@test.com",
|
||||
locale: "en",
|
||||
timeZone: "America/Toronto",
|
||||
bookingSeat: {
|
||||
referenceUid: "booking-seat-1",
|
||||
data: {},
|
||||
},
|
||||
noShow: false,
|
||||
}),
|
||||
getMockBookingAttendee({
|
||||
id: 2,
|
||||
name: "Seat 2",
|
||||
email: "seat2@test.com",
|
||||
locale: "en",
|
||||
timeZone: "America/Toronto",
|
||||
bookingSeat: {
|
||||
referenceUid: "booking-seat-2",
|
||||
data: {},
|
||||
},
|
||||
noShow: false,
|
||||
}),
|
||||
getMockBookingAttendee({
|
||||
id: 3,
|
||||
name: "Seat 3",
|
||||
email: "seat3@test.com",
|
||||
locale: "en",
|
||||
timeZone: "America/Toronto",
|
||||
bookingSeat: {
|
||||
referenceUid: "booking-seat-3",
|
||||
data: {},
|
||||
},
|
||||
noShow: false,
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
organizer,
|
||||
})
|
||||
);
|
||||
|
||||
mockSuccessfulVideoMeetingCreation({
|
||||
metadataLookupKey: "dailyvideo",
|
||||
videoMeetingData: {
|
||||
id: "MOCK_ID",
|
||||
password: "MOCK_PASS",
|
||||
url: `http://mock-dailyvideo.example.com/meeting-1`,
|
||||
},
|
||||
});
|
||||
|
||||
const reqBookingUser = "seatedAttendee";
|
||||
|
||||
const mockBookingData = getMockRequestDataForBooking({
|
||||
data: {
|
||||
eventTypeId: 1,
|
||||
responses: {
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
location: { optionValue: "", value: BookingLocations.CalVideo },
|
||||
},
|
||||
rescheduleUid: firstBookingUid,
|
||||
start: secondBookingStartTime,
|
||||
end: secondBookingEndTime,
|
||||
user: reqBookingUser,
|
||||
},
|
||||
});
|
||||
|
||||
const { req } = createMockNextJsRequest({
|
||||
method: "POST",
|
||||
body: mockBookingData,
|
||||
});
|
||||
|
||||
req.userId = organizer.id;
|
||||
|
||||
await expect(() => handleNewBooking(req)).rejects.toThrowError(ErrorCode.NoAvailableUsersFound);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cancelling a booking", () => {
|
||||
|
||||
@@ -69,7 +69,7 @@ function UserToInviteItem({
|
||||
key={member.userId}
|
||||
onClick={() => onChange()} // We handle this on click on the div also - for a11y we handle it with label and checkbox below
|
||||
className={classNames(
|
||||
"flex cursor-pointer items-center rounded-md px-2 py-1",
|
||||
"flex cursor-pointer items-center rounded-md px-2 py-1 transition",
|
||||
isSelected ? "bg-emphasis" : "hover:bg-subtle "
|
||||
)}>
|
||||
<div className="flex items-center space-x-2 rtl:space-x-reverse">
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function OtherTeamListItem(props: Props) {
|
||||
|
||||
return (
|
||||
<li>
|
||||
<div className="hover:bg-muted group flex items-center justify-between">
|
||||
<div className="hover:bg-muted group flex items-center justify-between transition">
|
||||
{teamInfo}
|
||||
<div className="px-5 py-5">
|
||||
<div className="flex space-x-2 rtl:space-x-reverse">
|
||||
|
||||
@@ -43,7 +43,7 @@ const AdminAPIViewWrapper = () => {
|
||||
buttons={
|
||||
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
|
||||
<ButtonGroup>
|
||||
<Button color="primary" href="https://i.cal.com/sales/enterprise" target="_blank">
|
||||
<Button color="primary" href="https://cal.com/sales" target="_blank">
|
||||
{t("contact_sales")}
|
||||
</Button>
|
||||
<Button color="minimal" href="https://cal.com/enterprise" target="_blank">
|
||||
|
||||
+5
-4
@@ -27,15 +27,16 @@ function CreateAttributesPage() {
|
||||
const utils = trpc.useUtils();
|
||||
const { t } = useLocale();
|
||||
// Get the attribute id from the url
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const params = useParams<{ id: string }>();
|
||||
const id = params?.id || "";
|
||||
// ensure string with zod
|
||||
const attribute = trpc.viewer.attributes.get.useQuery({ id: id as string });
|
||||
const attribute = trpc.viewer.attributes.get.useQuery({ id });
|
||||
|
||||
const mutation = trpc.viewer.attributes.edit.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast(t("attribute_edited_successfully"), "success");
|
||||
utils.viewer.attributes.get.invalidate({
|
||||
id: id as string,
|
||||
id,
|
||||
});
|
||||
utils.viewer.attributes.list.invalidate();
|
||||
router.push("/settings/organizations/attributes");
|
||||
@@ -59,7 +60,7 @@ function CreateAttributesPage() {
|
||||
header={<EditAttributeHeader isPending={mutation.isPending} />}
|
||||
onSubmit={(values) => {
|
||||
mutation.mutate({
|
||||
attributeId: id as string,
|
||||
attributeId: id,
|
||||
name: values.attrName,
|
||||
type: values.type,
|
||||
options: values.options,
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function ContactMenuItem(props: ContactMenuItem) {
|
||||
<a
|
||||
href={JOIN_COMMUNITY}
|
||||
target="_blank"
|
||||
className="hover:bg-subtle hover:text-emphasis text-default flex w-full px-5 py-2 pr-4 text-sm font-medium">
|
||||
className="hover:bg-subtle hover:text-emphasis text-default flex w-full px-5 py-2 pr-4 text-sm font-medium transition">
|
||||
{t("community_support")}{" "}
|
||||
<Icon
|
||||
name="external-link"
|
||||
|
||||
@@ -61,7 +61,7 @@ export default function HelpMenuItem({ onHelpItemSelect }: HelpMenuItemProps) {
|
||||
onClick={() => onHelpItemSelect()}
|
||||
href="https://cal.com/docs/"
|
||||
target="_blank"
|
||||
className="hover:bg-subtle hover:text-emphasis text-default flex w-full px-5 py-2 pr-4 text-sm font-medium"
|
||||
className="hover:bg-subtle hover:text-emphasis text-default flex w-full px-5 py-2 pr-4 text-sm font-medium transition"
|
||||
rel="noreferrer">
|
||||
{t("documentation")}
|
||||
<Icon
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function FreshChatMenuItem(props: FreshChatMenuItemProps) {
|
||||
setActive(true);
|
||||
onHelpItemSelect();
|
||||
}}
|
||||
className="hover:bg-subtle hover:text-emphasis text-default flex w-full px-5 py-2 pr-4 text-sm font-medium">
|
||||
className="hover:bg-subtle hover:text-emphasis text-default flex w-full px-5 py-2 pr-4 text-sm font-medium transition">
|
||||
{t("contact_support")}
|
||||
</button>
|
||||
</>
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function HelpscoutMenuItem(props: HelpscoutMenuItemProps) {
|
||||
<>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="hover:bg-subtle hover:text-emphasis text-default flex w-full px-5 py-2 pr-4 text-sm font-medium">
|
||||
className="hover:bg-subtle hover:text-emphasis text-default flex w-full px-5 py-2 pr-4 text-sm font-medium transition">
|
||||
{t("contact_support")}
|
||||
</button>
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function ZendeskMenuItem(props: ZendeskMenuItemProps) {
|
||||
setActive(true);
|
||||
onHelpItemSelect();
|
||||
}}
|
||||
className="hover:bg-subtle hover:text-emphasis text-default flex w-full px-5 py-2 pr-4 text-sm font-medium">
|
||||
className="hover:bg-subtle hover:text-emphasis text-default flex w-full px-5 py-2 pr-4 text-sm font-medium transition">
|
||||
{t("contact_support")}
|
||||
</button>
|
||||
{active && (
|
||||
|
||||
@@ -116,7 +116,7 @@ export default function WorkflowListPage({ workflows }: Props) {
|
||||
{!(lastItem && lastItem.id === workflow.id) && (
|
||||
<ArrowButton onClick={() => moveWorkflow(index, 1)} arrowDirection="down" />
|
||||
)}
|
||||
<div className="first-line:group hover:bg-muted flex w-full items-center justify-between p-4 sm:px-6">
|
||||
<div className="first-line:group hover:bg-muted flex w-full items-center justify-between p-4 transition sm:px-6">
|
||||
<Link href={`/workflows/${workflow.id}`} className="flex-grow cursor-pointer">
|
||||
<div className="rtl:space-x-reverse">
|
||||
<div className="flex">
|
||||
|
||||
@@ -136,7 +136,7 @@ const Filter = (props: {
|
||||
return (
|
||||
<div className={classNames("-mb-2", noFilter ? "w-16" : "w-[100px]")}>
|
||||
<AnimatedPopover text={noFilter ? "All" : "Filtered"}>
|
||||
<div className="item-center focus-within:bg-subtle hover:bg-muted flex px-4 py-[6px] hover:cursor-pointer">
|
||||
<div className="item-center focus-within:bg-subtle hover:bg-muted flex px-4 py-[6px] transition hover:cursor-pointer">
|
||||
<Avatar
|
||||
imageSrc={userAvatar || ""}
|
||||
size="sm"
|
||||
@@ -171,7 +171,7 @@ const Filter = (props: {
|
||||
</div>
|
||||
{teams.map((profile) => (
|
||||
<div
|
||||
className="item-center focus-within:bg-subtle hover:bg-muted flex px-4 py-[6px] hover:cursor-pointer"
|
||||
className="item-center focus-within:bg-subtle hover:bg-muted flex px-4 py-[6px] transition hover:cursor-pointer"
|
||||
key={`${profile.teamId || 0}`}>
|
||||
<Avatar
|
||||
imageSrc={profile.image || ""}
|
||||
|
||||
@@ -114,7 +114,7 @@ const ChooseEmbedTypesDialogContent = ({ types }: { types: EmbedTypes }) => {
|
||||
<div className="items-start space-y-2 md:flex md:space-y-0">
|
||||
{types.map((embed, index) => (
|
||||
<button
|
||||
className="hover:bg-subtle bg-muted w-full self-stretch rounded-md border border-transparent p-6 text-left hover:rounded-md ltr:mr-4 ltr:last:mr-0 rtl:ml-4 rtl:last:ml-0 lg:w-1/3"
|
||||
className="hover:bg-subtle bg-muted w-full self-stretch rounded-md border border-transparent p-6 text-left transition hover:rounded-md ltr:mr-4 ltr:last:mr-0 rtl:ml-4 rtl:last:ml-0 lg:w-1/3"
|
||||
key={index}
|
||||
data-testid={embed.type}
|
||||
onClick={() => {
|
||||
|
||||
@@ -254,6 +254,7 @@ export default function CreateEventTypeDialog({
|
||||
placeholder={t("quick_video_meeting")}
|
||||
firstRender={firstRender}
|
||||
setFirstRender={setFirstRender}
|
||||
maxHeight="200px"
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
|
||||
@@ -143,7 +143,7 @@ type Props = InputHTMLAttributes<HTMLInputElement> & {
|
||||
|
||||
export const FilterCheckboxField = forwardRef<HTMLInputElement, Props>(({ label, icon, ...rest }, ref) => {
|
||||
return (
|
||||
<div className="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 hover:cursor-pointer">
|
||||
<div className="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer">
|
||||
<label className="flex w-full max-w-full items-center justify-between hover:cursor-pointer">
|
||||
<div className="flex items-center truncate">
|
||||
{icon && (
|
||||
|
||||
@@ -223,6 +223,7 @@ export const Components: Record<FieldType, Component> = {
|
||||
props.setValue(val);
|
||||
}}
|
||||
{...props}
|
||||
disabled={props.readOnly}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -364,9 +365,10 @@ export const Components: Record<FieldType, Component> = {
|
||||
},
|
||||
radio: {
|
||||
propsType: propsTypes.radio,
|
||||
factory: ({ setValue, name, value, options }) => {
|
||||
factory: ({ setValue, name, value, options, readOnly }) => {
|
||||
return (
|
||||
<Group
|
||||
disabled={readOnly}
|
||||
value={value}
|
||||
onValueChange={(e) => {
|
||||
setValue(e);
|
||||
@@ -441,7 +443,7 @@ export const Components: Record<FieldType, Component> = {
|
||||
type="radio"
|
||||
disabled={readOnly}
|
||||
name={name}
|
||||
className="bg-default after:bg-default border-emphasis focus:ring-brand-default hover:bg-subtle hover:after:bg-subtle dark:checked:after:bg-brand-accent flex h-4 w-4 cursor-pointer items-center justify-center text-[--cal-brand] after:h-[6px] after:w-[6px] after:rounded-full after:content-[''] after:hover:block focus:ring-2 focus:ring-offset-0 ltr:mr-2 rtl:ml-2 dark:checked:hover:text-[--cal-brand]"
|
||||
className="bg-default after:bg-default border-emphasis focus:ring-brand-default hover:bg-subtle hover:after:bg-subtle dark:checked:after:bg-brand-accent flex h-4 w-4 cursor-pointer items-center justify-center text-[--cal-brand] transition after:h-[6px] after:w-[6px] after:rounded-full after:content-[''] after:hover:block focus:ring-2 focus:ring-offset-0 ltr:mr-2 rtl:ml-2 dark:checked:hover:text-[--cal-brand]"
|
||||
value={option.value}
|
||||
onChange={(e) => {
|
||||
setValue({
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Badge,
|
||||
BooleanToggleGroupField,
|
||||
Button,
|
||||
CheckboxField,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
@@ -179,7 +180,7 @@ export const FormBuilder = function FormBuilder({
|
||||
<li
|
||||
key={field.name}
|
||||
data-testid={`field-${field.name}`}
|
||||
className="hover:bg-muted group relative flex items-center justify-between p-4 ">
|
||||
className="hover:bg-muted group relative flex items-center justify-between p-4 transition">
|
||||
{!disabled && (
|
||||
<>
|
||||
{index >= 1 && (
|
||||
@@ -237,7 +238,7 @@ export const FormBuilder = function FormBuilder({
|
||||
onCheckedChange={(checked) => {
|
||||
update(index, { ...field, hidden: !checked });
|
||||
}}
|
||||
classNames={{ container: "p-2 hover:bg-subtle rounded" }}
|
||||
classNames={{ container: "p-2 hover:bg-subtle rounded transition" }}
|
||||
tooltip={t("show_on_booking_page")}
|
||||
/>
|
||||
)}
|
||||
@@ -491,6 +492,10 @@ function FieldEditDialog({
|
||||
}
|
||||
label={t("identifier")}
|
||||
/>
|
||||
<CheckboxField
|
||||
description={t("disable_input_if_prefilled")}
|
||||
{...fieldForm.register("disableOnPrefill", { setValueAs: Boolean })}
|
||||
/>
|
||||
<InputField
|
||||
{...fieldForm.register("label")}
|
||||
// System fields have a defaultLabel, so there a label is not required
|
||||
@@ -709,7 +714,7 @@ function VariantFields({
|
||||
onCheckedChange={(checked) => {
|
||||
fieldForm.setValue("variant", checked ? otherVariant : defaultVariant);
|
||||
}}
|
||||
classNames={{ container: "p-2 mt-2 sm:hover:bg-muted rounded" }}
|
||||
classNames={{ container: "p-2 mt-2 sm:hover:bg-muted rounded transition" }}
|
||||
tooltip={t("Toggle Variant")}
|
||||
/>
|
||||
) : (
|
||||
@@ -727,6 +732,11 @@ function VariantFields({
|
||||
label={t("identifier")}
|
||||
/>
|
||||
|
||||
<CheckboxField
|
||||
description={t("disable_input_if_prefilled")}
|
||||
{...fieldForm.register("disableOnPrefill", { setValueAs: Boolean })}
|
||||
/>
|
||||
|
||||
<ul
|
||||
className={classNames(
|
||||
!isSimpleVariant ? "border-subtle divide-subtle mt-2 divide-y rounded-md border" : ""
|
||||
|
||||
@@ -11,6 +11,10 @@ import { Components, isValidValueProp } from "./Components";
|
||||
import { fieldTypesConfigMap } from "./fieldTypes";
|
||||
import { fieldsThatSupportLabelAsSafeHtml } from "./fieldsThatSupportLabelAsSafeHtml";
|
||||
import type { fieldsSchema } from "./schema";
|
||||
import {
|
||||
useShouldBeDisabledDueToPrefill,
|
||||
getFieldNameFromErrorMessage,
|
||||
} from "./useShouldBeDisabledDueToPrefill";
|
||||
import { getTranslatedConfig as getTranslatedVariantsConfig } from "./utils/variantsConfig";
|
||||
|
||||
type RhfForm = {
|
||||
@@ -59,6 +63,7 @@ export const FormBuilderField = ({
|
||||
t
|
||||
);
|
||||
|
||||
const shouldBeDisabled = useShouldBeDisabledDueToPrefill(field);
|
||||
return (
|
||||
<div data-fob-field-name={field.name} className={classNames(className, hidden ? "hidden" : "")}>
|
||||
<Controller
|
||||
@@ -71,7 +76,7 @@ export const FormBuilderField = ({
|
||||
<ComponentForField
|
||||
field={{ ...field, label, placeholder, hidden }}
|
||||
value={value}
|
||||
readOnly={readOnly}
|
||||
readOnly={readOnly || shouldBeDisabled}
|
||||
setValue={(val: unknown) => {
|
||||
onChange(val);
|
||||
}}
|
||||
@@ -84,7 +89,7 @@ export const FormBuilderField = ({
|
||||
render={({ message }: { message: string | undefined }) => {
|
||||
message = message || "";
|
||||
// If the error comes due to parsing the `responses` object(which can have error for any field), we need to identify the field that has the error from the message
|
||||
const name = message.replace(/\{([^}]+)\}.*/, "$1");
|
||||
const name = getFieldNameFromErrorMessage(message);
|
||||
const isResponsesErrorForThisField = name === field.name;
|
||||
// If the error comes for the specific property of responses(Possible for system fields), then also we would go ahead and show the error
|
||||
if (!isResponsesErrorForThisField && !error) {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { TooltipProvider } from "@radix-ui/react-tooltip";
|
||||
import { render } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { FormBuilder } from "@calcom/features/form-builder/FormBuilder";
|
||||
|
||||
import { mockProps, questionUtils, setMockIntersectionObserver, setMockMatchMedia } from "./testUtils";
|
||||
|
||||
vi.mock("@formkit/auto-animate/react", () => ({
|
||||
useAutoAnimate: () => [null],
|
||||
}));
|
||||
|
||||
const renderComponent = () => {
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => {
|
||||
const methods = useForm();
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<FormProvider {...methods}>{children}</FormProvider>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
return render(<FormBuilder {...mockProps} />, { wrapper: Wrapper });
|
||||
};
|
||||
|
||||
const questionTypes = [
|
||||
{ questionType: "email", label: "Email Field" },
|
||||
{ questionType: "phone", label: "Phone Field" },
|
||||
{ questionType: "address", label: "Address Field" },
|
||||
{ questionType: "text", label: "Short Text Field" },
|
||||
{ questionType: "number", label: "Number Field" },
|
||||
{ questionType: "textarea", label: "Long Text Field" },
|
||||
{ questionType: "select", label: "Select Field" },
|
||||
{ questionType: "multiselect", label: "MultiSelect Field" },
|
||||
{ questionType: "multiemail", label: "Multiple Emails Field" },
|
||||
{ questionType: "checkbox", label: "CheckBox Group Field" },
|
||||
{ questionType: "radio", label: "Radio Group Field" },
|
||||
{ questionType: "boolean", label: "Checkbox Field" },
|
||||
];
|
||||
|
||||
describe("Form Builder - Add and Edit", () => {
|
||||
beforeAll(() => {
|
||||
setMockMatchMedia();
|
||||
setMockIntersectionObserver();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
renderComponent();
|
||||
});
|
||||
|
||||
for (const { questionType, label } of questionTypes) {
|
||||
it(`should successfully create or edit a ${label} question type in the form builder, with the option to disable the field on prefill`, async () => {
|
||||
const defaultIdentifier = `${questionType}-id`;
|
||||
const newIdentifier = `${defaultIdentifier}-edited`;
|
||||
|
||||
// Add a question
|
||||
await questionUtils.addQuestion({
|
||||
questionType,
|
||||
identifier: defaultIdentifier,
|
||||
label,
|
||||
disableOnPrefill: true,
|
||||
});
|
||||
questionUtils.expectedValueForDisablePreFillCheckox(true);
|
||||
|
||||
// Edit the question
|
||||
await questionUtils.editQuestion({
|
||||
identifier: newIdentifier,
|
||||
existingQuestionId: defaultIdentifier,
|
||||
disableOnPrefill: false,
|
||||
});
|
||||
questionUtils.expectedValueForDisablePreFillCheckox(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { fireEvent, waitFor, screen } from "@testing-library/react";
|
||||
import { vi } from "vitest";
|
||||
import type { z } from "zod";
|
||||
|
||||
import type { fieldsSchema } from "@calcom/features/form-builder/schema";
|
||||
|
||||
type RhfForm = {
|
||||
fields: z.infer<typeof fieldsSchema>;
|
||||
};
|
||||
|
||||
type RhfFormFields = RhfForm["fields"];
|
||||
|
||||
export type RhfFormField = RhfFormFields[number];
|
||||
|
||||
export type FieldType = RhfFormField["type"];
|
||||
export interface QuestionProps {
|
||||
questionType: string;
|
||||
identifier: string;
|
||||
label: string;
|
||||
disableOnPrefill?: boolean;
|
||||
}
|
||||
|
||||
export interface FormBuilderProps {
|
||||
formProp: string;
|
||||
title: string;
|
||||
description: string;
|
||||
addFieldLabel: string;
|
||||
disabled: boolean;
|
||||
LockedIcon: false | JSX.Element;
|
||||
dataStore: {
|
||||
options: Record<string, { label: string; value: string; inputPlaceholder?: string }[]>;
|
||||
};
|
||||
disableOnPrefill?: boolean;
|
||||
}
|
||||
|
||||
export interface FormBuildFieldProps {
|
||||
field: Omit<RhfFormField, "editable" | "label"> & {
|
||||
// Label is optional because radioInput doesn't have a label
|
||||
label?: string;
|
||||
value: string;
|
||||
};
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
export const mockProps: FormBuilderProps = {
|
||||
formProp: "formProp",
|
||||
title: "FormBuilder Title",
|
||||
description: "FormBuilder Description",
|
||||
addFieldLabel: "Add Field",
|
||||
disabled: false,
|
||||
LockedIcon: false,
|
||||
dataStore: { options: {} },
|
||||
disableOnPrefill: false,
|
||||
};
|
||||
|
||||
export const mockFieldProps: FormBuildFieldProps = {
|
||||
field: {
|
||||
type: "text",
|
||||
name: "test-text-n",
|
||||
variant: "text",
|
||||
placeholder: "text-test-p",
|
||||
required: false,
|
||||
label: "text-test-l",
|
||||
disableOnPrefill: false,
|
||||
value: "",
|
||||
},
|
||||
readOnly: false,
|
||||
};
|
||||
|
||||
export const setMockMatchMedia = () => {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
value: vi.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
export const setMockIntersectionObserver = () => {
|
||||
Object.defineProperty(window, "IntersectionObserver", {
|
||||
value: vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
takeRecords: vi.fn(() => []),
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
export const clickDisablePrefillBasedOnUserInput = (disableOnPrefill?: boolean) => {
|
||||
if (typeof disableOnPrefill === "boolean") {
|
||||
// Get the checkbox element
|
||||
const checkbox = getDisablePrefillCheckbox(disableOnPrefill) as HTMLInputElement;
|
||||
|
||||
// Determine if the checkbox's current state differs from the `disableOnPrefill` prop
|
||||
const isChecked = checkbox ? checkbox.checked : false;
|
||||
|
||||
// Click the checkbox only if its current state differs from the `disableOnPrefill` value
|
||||
if (isChecked !== disableOnPrefill) {
|
||||
fireEvent.click(checkbox);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getDisablePrefillCheckbox = (disableOnPrefill?: boolean) => {
|
||||
if (typeof disableOnPrefill === "boolean") {
|
||||
const checkbox = screen.getByRole("checkbox", { name: /disable_input_if_prefilled/i });
|
||||
return checkbox;
|
||||
}
|
||||
};
|
||||
|
||||
export const checkForDisablePreFillCheckox = (disableOnPrefill?: boolean) => {
|
||||
const checkbox = getDisablePrefillCheckbox(disableOnPrefill);
|
||||
expect(checkbox).toBeInTheDocument();
|
||||
};
|
||||
|
||||
export const questionUtils = {
|
||||
addQuestion: async (props: QuestionProps) => {
|
||||
fireEvent.click(screen.getByTestId("add-field"));
|
||||
checkForDisablePreFillCheckox(props?.disableOnPrefill);
|
||||
fireEvent.keyDown(screen.getByTestId("test-field-type"), { key: "ArrowDown", code: "ArrowDown" });
|
||||
fireEvent.click(screen.getByTestId(`select-option-${props.questionType}`));
|
||||
fireEvent.change(screen.getAllByRole("textbox")[0], { target: { value: props.identifier } });
|
||||
fireEvent.change(screen.getAllByRole("textbox")[1], { target: { value: props.label } });
|
||||
clickDisablePrefillBasedOnUserInput(props?.disableOnPrefill);
|
||||
fireEvent.click(screen.getByTestId("field-add-save"));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId(`field-${props.identifier}`)).toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
editQuestion: async (props: {
|
||||
identifier: string;
|
||||
existingQuestionId: string;
|
||||
disableOnPrefill?: boolean;
|
||||
}) => {
|
||||
fireEvent.click(screen.getByTestId("edit-field-action"));
|
||||
checkForDisablePreFillCheckox(props?.disableOnPrefill);
|
||||
fireEvent.change(screen.getAllByRole("textbox")[0], { target: { value: props.identifier } });
|
||||
clickDisablePrefillBasedOnUserInput(props?.disableOnPrefill);
|
||||
|
||||
fireEvent.click(screen.getByTestId("field-add-save"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId(`field-${props.identifier}`)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(`field-${props.existingQuestionId}`)).not.toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
deleteQuestion: async (existingQuestionId: string) => {
|
||||
expect(screen.queryByTestId(`field-${existingQuestionId}`)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId("delete-field-action"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId(`field-${existingQuestionId}`)).not.toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
hideQuestion: async () => {
|
||||
fireEvent.click(screen.getByTestId("toggle-field"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/hidden/i)).toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
requiredAndOptionalQuestion: async () => {
|
||||
expect(screen.queryByTestId("required")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId("edit-field-action"));
|
||||
fireEvent.click(screen.getAllByRole("radio")[1]);
|
||||
fireEvent.click(screen.getByTestId("field-add-save"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("optional")).toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
expectedValueForDisablePreFillCheckox: async (expectedValue: boolean) => {
|
||||
fireEvent.click(screen.getByTestId("edit-field-action"));
|
||||
const checkbox = getDisablePrefillCheckbox(expectedValue) as HTMLInputElement;
|
||||
await expect(checkbox?.checked).toBe(expectedValue);
|
||||
},
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user