chore: UX Fixes (#26643)

* Copy changes

* Move search bar inline with new button

* Get rid of no more results message

* Change hidden badge to (hidden)

* Remove Cal.ai badge from sidebar

* Add dropdown to create button when there is multiple options

* Fix delete dialog

* Saved filters updates

* More string fixes

* Switch members table to use names

* Fix member spacing

* Fix routing form identifier field

* Fix routing forms stuff

* Only show SMS hint on SMS options

* Make workflow delete button minimal

* Fix padding on workflow steps

* Remove min width on workflow title

* Fix delete workflow PR

* Fix org profile buttons

* Fix org profile screen partially scrolled down

* Improve logos & banner uploads

* Personal profile fixes

* Fix settings general view stuff

* Sentence case consistency

* Fix stuff I broke

* Fix fab

* Fix hidden translation string

* Fix text fields

* Make button small for solo users too

* fix: update E2E tests to match sentence case labels in routing forms

* fix: update tests to match sentence case label changes

- insights.e2e.ts: chart titles (14 strings)
- event-types.e2e.ts: Organizer phone number location
- EditLocationDialog.test.tsx: phone number labels

* fix: address Cubic AI review feedback (confidence 9+)

- Replace hardcoded text-gray-500 with text-muted in TextField.tsx hint section
- Replace text locator with data-testid in E2E test for location select

Co-Authored-By: unknown <>

* fix: update E2E tests for sentence case label changes

- Use data-testid selectors for location options (more reliable than text)
- Update field identifiers in routing-forms tests to match new labels
- Fix Long text selector in manage-booking-questions test

* fix: replace text locator with data-testid in manage-booking-questions E2E test

Replace fragile text="Long text" locator with resilient
page.getByTestId("select-option-textarea") selector per E2E best practices.

Addresses Cubic AI review feedback (confidence 9/10).

Co-Authored-By: unknown <>

* fix: use .last() for multiple location select items

---------

Co-authored-by: Pedro Castro <pedro@cal.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Bailey Pumfleet
2026-01-13 00:56:08 -05:00
committed by GitHub
co-authored by unknown <> Pedro Castro Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 32d97fbea4
commit 7c66f33de2
31 changed files with 3350 additions and 1887 deletions
@@ -0,0 +1,45 @@
"use client";
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
import type { ReactElement } from "react";
import { useState } from "react";
import EventTypes, { EventTypesCTA, SearchContext } from "~/event-types/views/event-types-listing-view";
type GetUserEventGroupsResponse = Parameters<typeof EventTypesCTA>[0]["userEventGroupsData"];
const CTAWithContext = ({
userEventGroupsData,
}: {
userEventGroupsData: GetUserEventGroupsResponse;
}): ReactElement => {
return <EventTypesCTA userEventGroupsData={userEventGroupsData} />;
};
export function EventTypesWrapper({
userEventGroupsData,
user,
}: {
userEventGroupsData: GetUserEventGroupsResponse;
user: {
id: number;
completedOnboarding?: boolean;
} | null;
}): ReactElement {
const { t } = useLocale();
const [searchTerm, setSearchTerm] = useState("");
const debouncedSearchTerm = useDebounce(searchTerm, 500);
return (
<SearchContext.Provider value={{ searchTerm, setSearchTerm, debouncedSearchTerm }}>
<ShellMainAppDir
heading={t("event_types_page_title")}
subtitle={t("event_types_page_subtitle")}
CTA={<CTAWithContext userEventGroupsData={userEventGroupsData} />}>
<EventTypes userEventGroupsData={userEventGroupsData} user={user} />
</ShellMainAppDir>
</SearchContext.Provider>
);
}
@@ -1,55 +1,60 @@
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { checkOnboardingRedirect } from "@calcom/features/auth/lib/onboardingUtils";
import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
import type { RouterOutputs } from "@calcom/trpc/react";
import { eventTypesRouter } from "@calcom/trpc/server/routers/viewer/eventTypes/_router";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { createRouterCaller, getTRPCContext } from "app/_trpc/context";
import type { PageProps, ReadonlyHeaders, ReadonlyRequestCookies } from "app/_types";
import { _generateMetadata, getTranslate } from "app/_utils";
import type {
PageProps,
ReadonlyHeaders,
ReadonlyRequestCookies,
} from "app/_types";
import { _generateMetadata } from "app/_utils";
import { unstable_cache } from "next/cache";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import type { ReactElement } from "react";
import { checkOnboardingRedirect } from "@calcom/features/auth/lib/onboardingUtils";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
import { eventTypesRouter } from "@calcom/trpc/server/routers/viewer/eventTypes/_router";
import { EventTypesWrapper } from "./EventTypesWrapper";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import EventTypes, { EventTypesCTA } from "~/event-types/views/event-types-listing-view";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("event_types_page_title"),
(t) => t("event_types_page_subtitle"),
undefined,
undefined,
"/event-types"
const getCachedEventGroups: (
headers: ReadonlyHeaders,
cookies: ReadonlyRequestCookies,
filters?: {
teamIds?: number[] | undefined;
userIds?: number[] | undefined;
upIds?: string[] | undefined;
}
) => Promise<RouterOutputs["viewer"]["eventTypes"]["getUserEventGroups"]> =
unstable_cache(
async (
headers: ReadonlyHeaders,
cookies: ReadonlyRequestCookies,
filters?: {
teamIds?: number[] | undefined;
userIds?: number[] | undefined;
upIds?: string[] | undefined;
}
): Promise<RouterOutputs["viewer"]["eventTypes"]["getUserEventGroups"]> => {
const eventTypesCaller = await createRouterCaller(
eventTypesRouter,
await getTRPCContext(headers, cookies)
);
return await eventTypesCaller.getUserEventGroups({ filters });
},
["viewer.eventTypes.getUserEventGroups"],
{ revalidate: 3600 } // seconds
);
const getCachedEventGroups = unstable_cache(
async (
headers: ReadonlyHeaders,
cookies: ReadonlyRequestCookies,
filters?: {
teamIds?: number[] | undefined;
userIds?: number[] | undefined;
upIds?: string[] | undefined;
}
) => {
const eventTypesCaller = await createRouterCaller(
eventTypesRouter,
await getTRPCContext(headers, cookies)
);
return await eventTypesCaller.getUserEventGroups({ filters });
},
["viewer.eventTypes.getUserEventGroups"],
{ revalidate: 3600 } // seconds
);
const Page = async ({ searchParams }: PageProps) => {
const Page = async ({ searchParams }: PageProps): Promise<ReactElement> => {
const _searchParams = await searchParams;
const _headers = await headers();
const _cookies = await cookies();
const session = await getServerSession({ req: buildLegacyRequest(_headers, _cookies) });
const session = await getServerSession({
req: buildLegacyRequest(_headers, _cookies),
});
if (!session?.user?.id) {
return redirect("/auth/login");
}
@@ -65,18 +70,30 @@ const Page = async ({ searchParams }: PageProps) => {
return redirect(onboardingPath);
}
const t = await getTranslate();
const filters = getTeamsFiltersFromQuery(_searchParams);
const userEventGroupsData = await getCachedEventGroups(_headers, _cookies, filters);
const userEventGroupsData = await getCachedEventGroups(
_headers,
_cookies,
filters
);
return (
<ShellMainAppDir
heading={t("event_types_page_title")}
subtitle={t("event_types_page_subtitle")}
CTA={<EventTypesCTA userEventGroupsData={userEventGroupsData} />}>
<EventTypes userEventGroupsData={userEventGroupsData} user={session.user} />
</ShellMainAppDir>
<EventTypesWrapper
userEventGroupsData={userEventGroupsData}
user={session.user}
/>
);
};
export const generateMetadata = async (): Promise<
ReturnType<typeof _generateMetadata>
> =>
await _generateMetadata(
(t) => t("event_types_page_title"),
(t) => t("event_types_page_subtitle"),
undefined,
undefined,
"/event-types"
);
export default Page;
@@ -88,9 +88,16 @@ function Field({
moveUp={moveUp}
moveDown={moveDown}
badge={
router ? { text: router.name, variant: "gray", href: `${appUrl}/form-edit/${router.id}` } : null
router
? {
text: router.name,
variant: "gray",
href: `${appUrl}/form-edit/${router.id}`,
}
: null
}
deleteField={router ? null : deleteField}>
deleteField={router ? null : deleteField}
>
<FormCardBody>
<div className="mb-3 w-full">
<TextField
@@ -107,13 +114,25 @@ function Field({
// Use label from useWatch which is guaranteed to be the previous value
// since useWatch updates reactively (after re-render), not synchronously
const previousLabel = label || "";
hookForm.setValue(`${hookFieldNamespace}.label`, newLabel, { shouldDirty: true });
const currentIdentifier = hookForm.getValues(`${hookFieldNamespace}.identifier`);
hookForm.setValue(`${hookFieldNamespace}.label`, newLabel, {
shouldDirty: true,
});
const currentIdentifier = hookForm.getValues(
`${hookFieldNamespace}.identifier`
);
// Only auto-update identifier if it was auto-generated from the previous label
// This preserves manual identifier changes
const isIdentifierGeneratedFromPreviousLabel = currentIdentifier === getFieldIdentifier(previousLabel);
if (!currentIdentifier || isIdentifierGeneratedFromPreviousLabel) {
hookForm.setValue(`${hookFieldNamespace}.identifier`, getFieldIdentifier(newLabel), { shouldDirty: true });
const isIdentifierGeneratedFromPreviousLabel =
currentIdentifier === getFieldIdentifier(previousLabel);
if (
!currentIdentifier ||
isIdentifierGeneratedFromPreviousLabel
) {
hookForm.setValue(
`${hookFieldNamespace}.identifier`,
getFieldIdentifier(newLabel),
{ shouldDirty: true }
);
}
}}
/>
@@ -122,12 +141,23 @@ function Field({
<TextField
disabled={!!router}
label={t("identifier_url_parameter")}
hint={t("identifier_url_parameter_hint")}
name={`${hookFieldNamespace}.identifier`}
required
placeholder={t("identifies_name_field")}
value={identifier || routerField?.identifier || label || routerField?.label || ""}
value={
identifier ||
routerField?.identifier ||
label ||
routerField?.label ||
""
}
onChange={(e) => {
hookForm.setValue(`${hookFieldNamespace}.identifier`, e.target.value, { shouldDirty: true });
hookForm.setValue(
`${hookFieldNamespace}.identifier`,
e.target.value,
{ shouldDirty: true }
);
}}
/>
</div>
@@ -137,7 +167,9 @@ function Field({
control={hookForm.control}
defaultValue={routerField?.type}
render={({ field: { value, onChange } }) => {
const defaultValue = FieldTypes.find((fieldType) => fieldType.value === value);
const defaultValue = FieldTypes.find(
(fieldType) => fieldType.value === value
);
if (disableTypeChange) {
return (
<div className="data-testid-field-type">
@@ -150,9 +182,15 @@ function Field({
className={classNames(
"h-8 w-full justify-between text-left text-sm",
!!router && "bg-subtle cursor-not-allowed"
)}>
<span className="text-default">{defaultValue?.label || "Select field type"}</span>
<Icon name="chevron-down" className="text-default h-4 w-4" />
)}
>
<span className="text-default">
{defaultValue?.label || "Select field type"}
</span>
<Icon
name="chevron-down"
className="text-default h-4 w-4"
/>
</Button>
</Tooltip>
</div>
@@ -273,7 +311,9 @@ const FormEdit = ({
<div className="w-full py-4 lg:py-8">
<div ref={animationRef} className="flex w-full flex-col rounded-md">
{hookFormFields.map((field, key) => {
const existingField = Boolean((form.fields || []).find((f) => f.id === field.id));
const existingField = Boolean(
(form.fields || []).find((f) => f.id === field.id)
);
const hasFormResponses = (form._count?.responses ?? 0) > 0;
return (
<Field
@@ -310,7 +350,13 @@ const FormEdit = ({
</div>
{hookFormFields.length ? (
<div className={classNames("flex")}>
<Button data-testid="add-field" type="button" StartIcon="plus" color="secondary" onClick={addField}>
<Button
data-testid="add-field"
type="button"
StartIcon="plus"
color="secondary"
onClick={addField}
>
Add question
</Button>
</div>
@@ -319,7 +365,7 @@ const FormEdit = ({
) : (
<div className="w-full py-4 lg:py-8">
{/* TODO: remake empty screen for V3 */}
<div className="border-sublte bg-cal-muted flex flex-col items-center gap-6 rounded-xl border p-11">
<div className="border-subtle bg-cal-muted flex flex-col items-center gap-6 rounded-xl border p-11">
<div className="mb-3 grid">
{/* Icon card - Top */}
<div className="bg-default border-subtle z-30 col-start-1 col-end-1 row-start-1 row-end-1 h-10 w-10 transform rounded-md border shadow-sm">
@@ -350,7 +396,12 @@ const FormEdit = ({
Fields are the form fields that the booker would see.
</p>
</div>
<Button data-testid="add-field" onClick={addField} StartIcon="plus" className="mt-6">
<Button
data-testid="add-field"
onClick={addField}
StartIcon="plus"
className="mt-6"
>
Add question
</Button>
</div>
@@ -370,7 +421,9 @@ export default function FormEditPage({
{...props}
appUrl={appUrl}
permissions={permissions}
Page={({ hookForm, form }) => <FormEdit appUrl={appUrl} hookForm={hookForm} form={form} />}
Page={({ hookForm, form }) => (
<FormEdit appUrl={appUrl} hookForm={hookForm} form={form} />
)}
/>
</>
);
@@ -46,8 +46,8 @@ vi.mock("@calcom/features/form/components/LocationSelect", () => {
};
});
const AttendeePhoneNumberLabel = "Attendee Phone Number";
const OrganizerPhoneLabel = "Organizer Phone Number";
const AttendeePhoneNumberLabel = "Attendee phone number";
const OrganizerPhoneLabel = "Organizer phone number";
const CampfireLabel = "Campfire";
const ZoomVideoLabel = "Zoom Video";
const OrganizerDefaultConferencingAppLabel = "Organizer's default app";
@@ -1,6 +1,10 @@
"use client";
import { useReactTable, getCoreRowModel, getSortedRowModel } from "@tanstack/react-table";
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
} from "@tanstack/react-table";
import { useRouter } from "next/navigation";
import React, { useState, useMemo, useEffect, useCallback } from "react";
@@ -31,7 +35,11 @@ import {
BookingDetailsSheetStoreProvider,
useBookingDetailsSheetStore,
} from "../store/bookingDetailsSheetStore";
import type { RowData, BookingListingStatus, BookingsGetOutput } from "../types";
import type {
RowData,
BookingListingStatus,
BookingsGetOutput,
} from "../types";
import { BookingDetailsSheet } from "./BookingDetailsSheet";
import { BookingList } from "./BookingList";
import { ViewToggleButton } from "./ViewToggleButton";
@@ -42,7 +50,11 @@ interface FilterButtonProps {
setShowFilters: (value: boolean | ((prev: boolean) => boolean)) => void;
}
function FilterButton({ table, displayedFilterCount, setShowFilters }: FilterButtonProps) {
function FilterButton({
table,
displayedFilterCount,
setShowFilters,
}: FilterButtonProps) {
const { t } = useLocale();
if (displayedFilterCount === 0) {
@@ -55,7 +67,8 @@ function FilterButton({ table, displayedFilterCount, setShowFilters }: FilterBut
StartIcon="list-filter"
className="h-full"
size="sm"
onClick={() => setShowFilters((value) => !value)}>
onClick={() => setShowFilters((value) => !value)}
>
{t("filter")}
<Badge variant="gray" className="ml-1">
{displayedFilterCount}
@@ -96,7 +109,9 @@ function BookingListInner({
}: BookingListInnerProps) {
const { t } = useLocale();
const user = useMeQuery().data;
const setSelectedBookingUid = useBookingDetailsSheetStore((state) => state.setSelectedBookingUid);
const setSelectedBookingUid = useBookingDetailsSheetStore(
(state) => state.setSelectedBookingUid
);
const router = useRouter();
const [showFilters, setShowFilters] = useState(true);
@@ -104,7 +119,11 @@ function BookingListInner({
useListAutoSelector(bookings);
const ErrorView = errorMessage ? (
<Alert severity="error" title={t("something_went_wrong")} message={errorMessage} />
<Alert
severity="error"
title={t("something_went_wrong")}
message={errorMessage}
/>
) : undefined;
const handleBookingClick = useCallback(
@@ -122,7 +141,11 @@ function BookingListInner({
handleBookingClick,
});
const finalData = useBookingListData({ data, status, userTimeZone: user?.timeZone });
const finalData = useBookingListData({
data,
status,
userTimeZone: user?.timeZone,
});
const getFacetedUniqueValues = useFacetedUniqueValues();
@@ -167,7 +190,9 @@ function BookingListInner({
value={currentTab}
onValueChange={(value) => {
if (!value) return;
const selectedTab = tabOptions.find((tab) => tab.value === value);
const selectedTab = tabOptions.find(
(tab) => tab.value === value
);
if (selectedTab?.href) {
router.push(selectedTab.href);
}
@@ -187,8 +212,10 @@ function BookingListInner({
{/* Desktop: auto-pushed to right via flex-grow spacer, Mobile: continue on second row */}
<div className="hidden grow md:block" />
<DataTableSegment.Select shortLabel />
{bookingsV3Enabled && <ViewToggleButton bookingsV3Enabled={bookingsV3Enabled} />}
<DataTableSegment.Select />
{bookingsV3Enabled && (
<ViewToggleButton bookingsV3Enabled={bookingsV3Enabled} />
)}
</div>
{displayedFilterCount > 0 && showFilters && (
<div className="mt-3 flex flex-wrap items-center gap-2">
@@ -203,7 +230,11 @@ function BookingListInner({
</div>
)}
{status === "upcoming" && !isEmpty && (
<WipeMyCalActionButton className="mt-4" bookingStatus={status} bookingsEmpty={isEmpty} />
<WipeMyCalActionButton
className="mt-4"
bookingStatus={status}
bookingsEmpty={isEmpty}
/>
)}
<div className="mt-4">
<BookingList
@@ -219,7 +250,9 @@ function BookingListInner({
{bookingsV3Enabled && (
<BookingDetailsSheet
userTimeZone={user?.timeZone}
userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat}
userTimeFormat={
user?.timeFormat === null ? undefined : user?.timeFormat
}
userId={user?.id}
userEmail={user?.email}
bookingAuditEnabled={bookingAuditEnabled}
@@ -231,8 +264,15 @@ function BookingListInner({
export function BookingListContainer(props: BookingListContainerProps) {
const { limit, offset, setPageIndex } = useDataTable();
const { eventTypeIds, teamIds, userIds, dateRange, attendeeName, attendeeEmail, bookingUid } =
useBookingFilters();
const {
eventTypeIds,
teamIds,
userIds,
dateRange,
attendeeName,
attendeeEmail,
bookingUid,
} = useBookingFilters();
// Build query input once - shared between query and prefetching
const queryInput = useMemo(
@@ -250,7 +290,9 @@ export function BookingListContainer(props: BookingListContainerProps) {
afterStartDate: dateRange?.startDate
? dayjs(dateRange?.startDate).startOf("day").toISOString()
: undefined,
beforeEndDate: dateRange?.endDate ? dayjs(dateRange?.endDate).endOf("day").toISOString() : undefined,
beforeEndDate: dateRange?.endDate
? dayjs(dateRange?.endDate).endOf("day").toISOString()
: undefined,
},
}),
[
@@ -272,7 +314,10 @@ export function BookingListContainer(props: BookingListContainerProps) {
gcTime: 30 * 60 * 1000, // 30 minutes - cache retention time
});
const bookings = useMemo(() => query.data?.bookings ?? [], [query.data?.bookings]);
const bookings = useMemo(
() => query.data?.bookings ?? [],
[query.data?.bookings]
);
// Always call the hook and provide navigation capabilities
// The BookingDetailsSheet is only rendered when bookingsV3Enabled is true (see line 212)
@@ -285,7 +330,10 @@ export function BookingListContainer(props: BookingListContainerProps) {
});
return (
<BookingDetailsSheetStoreProvider bookings={bookings} capabilities={capabilities}>
<BookingDetailsSheetStoreProvider
bookings={bookings}
capabilities={capabilities}
>
<BookingListInner
{...props}
data={query.data}
+151 -77
View File
@@ -25,7 +25,10 @@ import { Form } from "@calcom/ui/components/form";
import { Label } from "@calcom/ui/components/form";
import { TextField } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { BannerUploader, ImageUploader } from "@calcom/ui/components/image-uploader";
import {
BannerUploader,
ImageUploader,
} from "@calcom/ui/components/image-uploader";
// if I include this in the above barrel import, I get a runtime error that the component is not exported.
import { OrgBanner } from "@calcom/ui/components/organization-banner";
import {
@@ -108,6 +111,13 @@ const OrgProfileView = ({
[error, router]
);
// Scroll to top when page loads
useEffect(() => {
if (!isPending && orgBranding && currentOrganisation) {
window.scrollTo(0, 0);
}
}, [isPending, orgBranding, currentOrganisation]);
if (isPending || !orgBranding || !currentOrganisation) {
return <SkeletonLoader />;
}
@@ -126,7 +136,8 @@ const OrgProfileView = ({
calVideoLogo: currentOrganisation?.calVideoLogo || "",
slug:
currentOrganisation?.slug ||
((currentOrganisation?.metadata as Prisma.JsonObject)?.requestedSlug as string) ||
((currentOrganisation?.metadata as Prisma.JsonObject)
?.requestedSlug as string) ||
"",
};
@@ -142,15 +153,18 @@ const OrgProfileView = ({
<div className="border-subtle flex rounded-b-md border border-t-0 px-4 py-8 sm:px-6">
<div className="grow">
<div>
<Label className="text-emphasis">{t("organization_name")}</Label>
<p className="text-default text-sm">{currentOrganisation?.name}</p>
<Label className="text-emphasis">
{t("organization_name")}
</Label>
<p className="text-default text-sm">
{currentOrganisation?.name}
</p>
</div>
{!isBioEmpty && (
<>
<Label className="text-emphasis mt-5">{t("about")}</Label>
<div
className=" text-subtle wrap-break-word text-sm [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
dangerouslySetInnerHTML={{
__html: markdownToSafeHTML(currentOrganisation.bio || ""),
}}
@@ -164,7 +178,8 @@ const OrgProfileView = ({
onClick={() => {
navigator.clipboard.writeText(orgBranding.fullDomain);
showToast("Copied to clipboard", "success");
}}>
}}
>
{t("copy_link_org")}
</LinkIconButton>
</div>
@@ -189,7 +204,10 @@ const OrgProfileForm = ({ defaultValues }: { defaultValues: FormValues }) => {
const mutation = trpc.viewer.organizations.update.useMutation({
onError: (err) => {
// Handle JSON parsing errors from body size limit exceeded
if (err.message.includes("Unexpected token") && err.message.includes("Body excee")) {
if (
err.message.includes("Unexpected token") &&
err.message.includes("Body excee")
) {
showToast(t("converted_image_size_limit_exceed"), "error");
return;
}
@@ -239,40 +257,87 @@ const OrgProfileForm = ({ defaultValues }: { defaultValues: FormValues }) => {
};
mutation.mutate(variables);
}}>
}}
>
<div className="border-subtle border-x px-4 py-8 sm:px-6">
<div className="flex items-center">
<div className="grid grid-cols-2 gap-8">
<Controller
control={form.control}
name="logoUrl"
render={({ field: { value, onChange } }) => {
const showRemoveLogoButton = value !== null;
return (
<>
<div className="flex items-center gap-4">
<Avatar
data-testid="profile-upload-logo"
alt={form.getValues("name")}
imageSrc={getPlaceholderAvatar(value, form.getValues("name"))}
imageSrc={getPlaceholderAvatar(
value,
form.getValues("name")
)}
size="lg"
/>
<div className="ms-4">
<div>
<div className="flex gap-2">
<ImageUploader
target="logo"
id="avatar-upload"
buttonMsg={t("upload_logo")}
handleAvatarChange={onChange}
imageSrc={getPlaceholderAvatar(value, form.getValues("name"))}
triggerButtonColor={showRemoveLogoButton ? "secondary" : "primary"}
imageSrc={getPlaceholderAvatar(
value,
form.getValues("name")
)}
triggerButtonColor="secondary"
/>
{showRemoveLogoButton && (
<Button color="secondary" onClick={() => onChange(null)}>
<Button color="minimal" onClick={() => onChange(null)}>
{t("remove")}
</Button>
)}
</div>
</div>
</>
</div>
);
}}
/>
<Controller
control={form.control}
name="calVideoLogo"
render={({ field: { value, onChange } }) => {
const showRemoveLogoButton = !!value;
return (
<div className="flex items-center gap-4">
<Avatar
alt="calVideoLogo"
imageSrc={value}
fallback={
<Icon name="plus" className="text-subtle h-6 w-6" />
}
size="lg"
/>
<div>
<div className="flex gap-2">
<ImageUploader
target="avatar"
id="cal-video-logo-upload"
buttonMsg={t("upload_cal_video_logo")}
handleAvatarChange={onChange}
imageSrc={value || undefined}
uploadInstruction={t(
"cal_video_logo_upload_instruction"
)}
triggerButtonColor="secondary"
testId="cal-video-logo"
/>
{showRemoveLogoButton && (
<Button color="minimal" onClick={() => onChange(null)}>
{t("remove")}
</Button>
)}
</div>
</div>
</div>
);
}}
/>
@@ -291,67 +356,72 @@ const OrgProfileForm = ({ defaultValues }: { defaultValues: FormValues }) => {
data-testid="profile-upload-banner"
alt={`${defaultValues.name} Banner` || ""}
className="grid min-h-[150px] w-full place-items-center rounded-md sm:min-h-[200px]"
fallback={t("no_target", { target: "banner" })}
fallback={
!value ? (
<div className="flex flex-col items-center gap-3">
<p className="text-muted text-sm">
{t("no_target", { target: "banner" })}
</p>
<div className="hidden">
<BannerUploader
height={500}
width={1500}
target="banner"
uploadInstruction={t("org_banner_instructions", {
height: 500,
width: 1500,
})}
id="banner-upload-inline"
buttonMsg={t("upload_banner")}
handleAvatarChange={onChange}
imageSrc={value || undefined}
triggerButtonColor="secondary"
/>
</div>
<Button
color="secondary"
StartIcon="upload"
onClick={() => {
// Trigger the BannerUploader dialog
const triggerButton = document.querySelector(
'[data-testid="open-upload-banner-dialog"]'
) as HTMLButtonElement;
triggerButton?.click();
}}>
{t("upload_banner")}
</Button>
</div>
) : (
t("no_target", { target: "banner" })
)
}
imageSrc={value}
/>
<div className="ms-4">
<div className="flex gap-2">
<BannerUploader
height={500}
width={1500}
target="banner"
uploadInstruction={t("org_banner_instructions", { height: 500, width: 1500 })}
id="banner-upload"
buttonMsg={t("upload_banner")}
handleAvatarChange={onChange}
imageSrc={value || undefined}
triggerButtonColor={showRemoveBannerButton ? "secondary" : "primary"}
/>
{showRemoveBannerButton && (
<Button color="destructive" onClick={() => onChange(null)}>
{t("remove")}
</Button>
)}
{value && (
<div className="mt-2">
<div className="flex gap-2">
<BannerUploader
height={500}
width={1500}
target="banner"
uploadInstruction={t("org_banner_instructions", {
height: 500,
width: 1500,
})}
id="banner-upload"
buttonMsg={t("upload_banner")}
handleAvatarChange={onChange}
imageSrc={value || undefined}
triggerButtonColor="secondary"
/>
{showRemoveBannerButton && (
<Button color="minimal" onClick={() => onChange(null)}>
{t("remove")}
</Button>
)}
</div>
</div>
</div>
</>
);
}}
/>
</div>
<div className="mt-2 flex items-center">
<Controller
control={form.control}
name="calVideoLogo"
render={({ field: { value, onChange } }) => {
const showRemoveLogoButton = !!value;
return (
<>
<Avatar
alt="calVideoLogo"
imageSrc={value}
fallback={<Icon name="plus" className="text-subtle h-6 w-6" />}
size="lg"
/>
<div className="ms-4">
<div className="flex gap-2">
<ImageUploader
target="avatar"
id="cal-video-logo-upload"
buttonMsg={t("upload_cal_video_logo")}
handleAvatarChange={onChange}
imageSrc={value || undefined}
uploadInstruction={t("cal_video_logo_upload_instruction")}
triggerButtonColor={showRemoveLogoButton ? "secondary" : "primary"}
testId="cal-video-logo"
/>
{showRemoveLogoButton && (
<Button color="secondary" onClick={() => onChange(null)}>
{t("remove")}
</Button>
)}
</div>
</div>
)}
</>
);
}}
@@ -406,7 +476,8 @@ const OrgProfileForm = ({ defaultValues }: { defaultValues: FormValues }) => {
size="sm"
type="button"
aria-label="copy organization id"
onClick={() => handleCopy(value.toString())}>
onClick={() => handleCopy(value.toString())}
>
<Icon name="copy" className="ml-1 h-4 w-4" />
</Button>
</Tooltip>
@@ -419,7 +490,9 @@ const OrgProfileForm = ({ defaultValues }: { defaultValues: FormValues }) => {
<Label>{t("about")}</Label>
<Editor
getText={() => md.render(form.getValues("bio") || "")}
setText={(value: string) => form.setValue("bio", turndown(value), { shouldDirty: true })}
setText={(value: string) =>
form.setValue("bio", turndown(value), { shouldDirty: true })
}
excludedToolbarItems={["blockType"]}
disableLists
firstRender={firstRender}
@@ -435,7 +508,8 @@ const OrgProfileForm = ({ defaultValues }: { defaultValues: FormValues }) => {
color="primary"
type="submit"
loading={mutation.isPending}
disabled={isDisabled}>
disabled={isDisabled}
>
{t("update")}
</Button>
</SectionBottomActions>
@@ -12,7 +12,13 @@ import { Avatar } from "@calcom/ui/components/avatar";
import { Form } from "@calcom/ui/components/form";
import { ToggleGroup, Select } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetBody } from "@calcom/ui/components/sheet";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetBody,
} from "@calcom/ui/components/sheet";
import { Skeleton, Loader } from "@calcom/ui/components/skeleton";
import { showToast } from "@calcom/ui/components/toast";
import { Tooltip } from "@calcom/ui/components/tooltip";
@@ -50,7 +56,9 @@ export function EditMemberSheet({
(state) => [state.editMode, state.setEditMode, state.setMutationLoading],
shallow
);
const [role, setRole] = useState<string>(selectedUser.customRoleId || selectedUser.role);
const [role, setRole] = useState<string>(
selectedUser.customRoleId || selectedUser.role
);
const name =
selectedUser.name ||
(() => {
@@ -61,16 +69,19 @@ export function EditMemberSheet({
const bookerUrl = selectedUser.bookerUrl;
const utils = trpc.useUtils();
const bookerUrlWithoutProtocol = bookerUrl.replace(/^https?:\/\//, "");
const bookingLink = selectedUser.username ? `${bookerUrlWithoutProtocol}/${selectedUser.username}` : "";
const bookingLink = selectedUser.username
? `${bookerUrlWithoutProtocol}/${selectedUser.username}`
: "";
// Load custom roles for the team
const { data: customRoles, isPending: isLoadingRoles } = trpc.viewer.pbac.getTeamRoles.useQuery(
{ teamId },
{
enabled: !!teamId,
retry: false, // Don't retry if PBAC is not enabled
}
);
const { data: customRoles, isPending: isLoadingRoles } =
trpc.viewer.pbac.getTeamRoles.useQuery(
{ teamId },
{
enabled: !!teamId,
retry: false, // Don't retry if PBAC is not enabled
}
);
const options = useMemo(() => {
// If we have custom roles, only show custom roles
@@ -95,7 +106,10 @@ export function EditMemberSheet({
label: t("owner"),
value: MembershipRole.OWNER,
},
].filter(({ value }) => value !== MembershipRole.OWNER || currentMember === MembershipRole.OWNER);
].filter(
({ value }) =>
value !== MembershipRole.OWNER || currentMember === MembershipRole.OWNER
);
}, [t, currentMember, customRoles]);
// Determine if we should use Select (when custom roles exist) or ToggleGroup (traditional only)
@@ -109,10 +123,11 @@ export function EditMemberSheet({
},
});
const { data: getUserConnectedApps, isPending } = trpc.viewer.teams.getUserConnectedApps.useQuery({
userIds: [selectedUser.id],
teamId,
});
const { data: getUserConnectedApps, isPending } =
trpc.viewer.teams.getUserConnectedApps.useQuery({
userIds: [selectedUser.id],
teamId,
});
const connectedApps = getUserConnectedApps?.[selectedUser.id];
@@ -191,21 +206,32 @@ export function EditMemberSheet({
onOpenChange={() => {
setEditMode(false);
dispatch({ type: "CLOSE_MODAL" });
}}>
}}
>
<SheetContent className="bg-cal-muted">
{!isPending && !isLoadingRoles ? (
<Form form={form} handleSubmit={changeRole} className="flex h-full flex-col">
<Form
form={form}
handleSubmit={changeRole}
className="flex h-full flex-col"
>
<SheetHeader showCloseButton={false} className="w-full">
<div className="border-sublte bg-default w-full rounded-xl border p-4">
<div className="border-subtle bg-default w-full rounded-xl border p-4">
<div
className="block w-full rounded-lg ring-1 ring-[#0000000F]"
style={{
background: "linear-gradient(to top right, var(--cal-bg-emphasis), var(--cal-bg))",
background:
"linear-gradient(to top right, var(--cal-bg-emphasis), var(--cal-bg))",
height: "110px",
}}
/>
<div className="bg-default ml-3 w-fit translate-y-[-50%] rounded-full p-1 ring-1 ring-[#0000000F]">
<Avatar asChild size="lg" alt={`${name} avatar`} imageSrc={selectedUser.avatarUrl} />
<Avatar
asChild
size="lg"
alt={`${name} avatar`}
imageSrc={selectedUser.avatarUrl}
/>
</div>
<Skeleton as="p" waitForTranslation={false}>
<h2 className="text-emphasis font-sans text-2xl font-semibold">
@@ -214,16 +240,28 @@ export function EditMemberSheet({
</Skeleton>
<Skeleton as="p" waitForTranslation={false}>
<p className="text-subtle max-h-[3em] overflow-hidden text-ellipsis text-sm font-normal">
{selectedUser.bio ? selectedUser?.bio : t("user_has_no_bio")}
{selectedUser.bio
? selectedUser?.bio
: t("user_has_no_bio")}
</p>
</Skeleton>
</div>
</SheetHeader>
<SheetBody className="stack-y-4 flex flex-col p-4">
<div className="stack-y-4 mb-4 flex flex-col">
<h3 className="text-emphasis mb-1 text-base font-semibold">{t("profile")}</h3>
<DisplayInfo label="Cal" value={bookingLink} icon="external-link" />
<DisplayInfo label={t("email")} value={selectedUser.email} icon="at-sign" />
<h3 className="text-emphasis mb-1 text-base font-semibold">
{t("profile")}
</h3>
<DisplayInfo
label="Cal"
value={bookingLink}
icon="external-link"
/>
<DisplayInfo
label={t("email")}
value={selectedUser.email}
icon="at-sign"
/>
{!editMode ? (
<DisplayInfo
label={t("role")}
@@ -239,7 +277,9 @@ export function EditMemberSheet({
<div className="flex flex-1">
{shouldUseSelect ? (
<Select
value={options.find((option) => option.value === form.watch("role"))}
value={options.find(
(option) => option.value === form.watch("role")
)}
onChange={(selectedOption: any) => {
if (selectedOption) {
form.setValue("role", selectedOption.value);
@@ -247,7 +287,9 @@ export function EditMemberSheet({
}}
options={options}
isDisabled={isLoadingRoles}
placeholder={isLoadingRoles ? t("loading") : t("select_role")}
placeholder={
isLoadingRoles ? t("loading") : t("select_role")
}
className="flex-1"
/>
) : (
@@ -267,7 +309,9 @@ export function EditMemberSheet({
<div className="flex items-center gap-6">
<div className="flex w-[110px] items-center gap-2">
<Icon className="text-subtle h-4 w-4" name="grid-3x3" />
<label className="text-subtle text-sm font-medium">{t("apps")}</label>
<label className="text-subtle text-sm font-medium">
{t("apps")}
</label>
</div>
<div className="flex flex-1">
{!connectedApps ? (
@@ -62,12 +62,22 @@ export function CreateButton(props: CreateBtnProps) {
const hasTeams = !!options.find((option) => option.teamId);
const platform = !!options.find((option) => option.platform);
const hasMultipleOptions = options.length > 1;
const isFabVariant = !disableMobileButton;
// On FAB variant, EndIcon shows as "plus" on mobile, so we remove StartIcon to avoid duplicate
// On button variant, both icons work correctly
const startIcon = isFabVariant && hasMultipleOptions ? undefined : "plus";
const endIcon = hasMultipleOptions ? "chevron-down" : undefined;
// inject selection data into url for correct router history
const openModal = (option: Option) => {
const _searchParams = new URLSearchParams(searchParams.toString());
function setParamsIfDefined(key: string, value: string | number | boolean | null | undefined) {
if (value !== undefined && value !== null) _searchParams.set(key, value.toString());
function setParamsIfDefined(
key: string,
value: string | number | boolean | null | undefined
) {
if (value !== undefined && value !== null)
_searchParams.set(key, value.toString());
}
setParamsIfDefined("dialog", "new");
setParamsIfDefined("eventPage", option.slug);
@@ -82,7 +92,6 @@ export function CreateButton(props: CreateBtnProps) {
<>
{!hasTeams && !platform ? (
<Button
size="sm"
onClick={() =>
CreateDialog
? openModal(options[0])
@@ -91,11 +100,17 @@ export function CreateButton(props: CreateBtnProps) {
: null
}
data-testid="create-button"
StartIcon="plus"
StartIcon={startIcon}
EndIcon={endIcon}
size="sm"
loading={isPending}
variant={disableMobileButton ? "button" : "fab"}
className={classNames(disableMobileButton && "md:min-h-min md:min-w-min", className)}
{...restProps}>
className={classNames(
disableMobileButton && "md:min-h-min md:min-w-min",
className
)}
{...restProps}
>
{buttonText ? buttonText : t("new")}
</Button>
) : (
@@ -103,16 +118,25 @@ export function CreateButton(props: CreateBtnProps) {
<DropdownMenuTrigger asChild>
<Button
variant={disableMobileButton ? "button" : "fab"}
StartIcon="plus"
StartIcon={startIcon}
EndIcon={endIcon}
size="sm"
data-testid="create-button-dropdown"
loading={isPending}
className={classNames(disableMobileButton && "md:min-h-min md:min-w-min", className)}
{...restProps}>
className={classNames(
disableMobileButton && "md:min-h-min md:min-w-min",
className
)}
{...restProps}
>
{buttonText ? buttonText : t("new")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={14} align="end" className="scroll-bar max-h-60 overflow-y-auto">
<DropdownMenuContent
sideOffset={14}
align="end"
className="scroll-bar max-h-60 overflow-y-auto"
>
<DropdownMenuLabel>
<div className="w-48 text-left text-xs">{subtitle}</div>
</DropdownMenuLabel>
@@ -121,14 +145,24 @@ export function CreateButton(props: CreateBtnProps) {
<DropdownItem
type="button"
data-testid={`option${option.teamId ? "-team" : ""}-${idx}`}
CustomStartIcon={<Avatar alt={option.label || ""} imageSrc={option.image} size="sm" />}
CustomStartIcon={
<Avatar
alt={option.label || ""}
imageSrc={option.image}
size="sm"
/>
}
onClick={() =>
CreateDialog
? openModal(option)
: createFunction
? createFunction(option.teamId || undefined, option.platform)
? createFunction(
option.teamId || undefined,
option.platform
)
: null
}>
}
>
{" "}
{/*improve this code */}
<span>{option.label}</span>
@@ -13,7 +13,11 @@ import { ALLOWED_FORM_WORKFLOW_ACTIONS } from "@calcom/features/ee/workflows/lib
import emailReminderTemplate from "@calcom/features/ee/workflows/lib/reminders/templates/emailReminderTemplate";
import type { FormValues } from "@calcom/features/ee/workflows/lib/types";
import type { WorkflowPermissions } from "@calcom/features/workflows/repositories/WorkflowPermissionsRepository";
import { SENDER_ID, SENDER_NAME, SCANNING_WORKFLOW_STEPS } from "@calcom/lib/constants";
import {
SENDER_ID,
SENDER_NAME,
SCANNING_WORKFLOW_STEPS,
} from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import { WorkflowActions } from "@calcom/prisma/enums";
@@ -23,7 +27,10 @@ import { Button } from "@calcom/ui/components/button";
import { FormCard, FormCardBody } from "@calcom/ui/components/card";
import type { MultiSelectCheckboxesOptionType as Option } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { useHasPaidPlan, useHasActiveTeamPlan } from "@calcom/web/modules/billing/hooks/useHasPaidPlan";
import {
useHasPaidPlan,
useHasActiveTeamPlan,
} from "@calcom/web/modules/billing/hooks/useHasPaidPlan";
import { AddActionDialog } from "./AddActionDialog";
import WorkflowStepContainer from "./WorkflowStepContainer";
@@ -70,20 +77,28 @@ export default function WorkflowDetailsPage(props: Props) {
const eventTypeId = searchParams?.get("eventTypeId");
// Get base action options and transform them for form triggers
const { data: baseActionOptions } = trpc.viewer.workflows.getWorkflowActionOptions.useQuery();
const { data: baseActionOptions } =
trpc.viewer.workflows.getWorkflowActionOptions.useQuery();
const transformedActionOptions = baseActionOptions
? baseActionOptions
.filter((option) => {
const isFormWorkflowWithInvalidSteps =
isFormTrigger(form.getValues("trigger")) &&
!ALLOWED_FORM_WORKFLOW_ACTIONS.some((action) => action === option.value);
!ALLOWED_FORM_WORKFLOW_ACTIONS.some(
(action) => action === option.value
);
const isSelectAllCalAiAction = isCalAIAction(option.value) && form.watch("selectAll");
const isSelectAllCalAiAction =
isCalAIAction(option.value) && form.watch("selectAll");
const isOrgCalAiAction = isCalAIAction(option.value) && isOrg;
if (isFormWorkflowWithInvalidSteps || isSelectAllCalAiAction || isOrgCalAiAction) {
if (
isFormWorkflowWithInvalidSteps ||
isSelectAllCalAiAction ||
isOrgCalAiAction
) {
return false;
}
return true;
@@ -100,7 +115,8 @@ export default function WorkflowDetailsPage(props: Props) {
}
}
const needsTeamsUpgrade = isFormTrigger(form.getValues("trigger")) && !hasActiveTeamPlan;
const needsTeamsUpgrade =
isFormTrigger(form.getValues("trigger")) && !hasActiveTeamPlan;
return {
...option,
@@ -117,8 +133,13 @@ export default function WorkflowDetailsPage(props: Props) {
: [];
useEffect(() => {
const matchingOption = allOptions.find((option) => option.value === eventTypeId);
if (matchingOption && !selectedOptions.find((option) => option.value === eventTypeId)) {
const matchingOption = allOptions.find(
(option) => option.value === eventTypeId
);
if (
matchingOption &&
!selectedOptions.find((option) => option.value === eventTypeId)
) {
const newOptions = [...selectedOptions, matchingOption];
setSelectedOptions(newOptions);
form.setValue("activeOn", newOptions);
@@ -141,7 +162,9 @@ export default function WorkflowDetailsPage(props: Props) {
})[0].id - 1
: 0;
const timeFormat = getTimeFormatStringFromUserTimeFormat(props.user.timeFormat);
const timeFormat = getTimeFormatStringFromUserTimeFormat(
props.user.timeFormat
);
const template = isFormTrigger(form.getValues("trigger"))
? WorkflowTemplates.CUSTOM
@@ -174,7 +197,9 @@ export default function WorkflowDetailsPage(props: Props) {
template,
numberRequired: numberRequired || false,
sender: isSMSAction(action) ? sender || SENDER_ID : SENDER_ID,
senderName: !isSMSAction(action) ? senderName || SENDER_NAME : SENDER_NAME,
senderName: !isSMSAction(action)
? senderName || SENDER_NAME
: SENDER_NAME,
numberVerificationPending: false,
includeCalendarEvent: false,
verifiedAt: SCANNING_WORKFLOW_STEPS ? null : new Date(),
@@ -185,8 +210,10 @@ export default function WorkflowDetailsPage(props: Props) {
form.setValue("steps", steps);
};
const { outboundAgentQueries: agentQueriesTrpc, inboundAgentQueries: inboundAgentQueriesTrpc } =
useAgentsData(form);
const {
outboundAgentQueries: agentQueriesTrpc,
inboundAgentQueries: inboundAgentQueriesTrpc,
} = useAgentsData(form);
return (
<>
@@ -199,9 +226,12 @@ export default function WorkflowDetailsPage(props: Props) {
<div className="border-subtle text-subtle ml-1 rounded-lg border p-1">
<Icon name="zap" size="16" />
</div>
<div className="text-sm font-medium leading-none">{t("trigger")}</div>
<div className="text-sm font-medium leading-none">
{t("trigger")}
</div>
</div>
}>
}
>
<FormCardBody className="border-muted">
<WorkflowStepContainer
form={form}
@@ -228,7 +258,8 @@ export default function WorkflowDetailsPage(props: Props) {
const agentData = agentQueriesTrpc[index]?.data;
const isAgentLoading = agentQueriesTrpc[index]?.isPending;
const inboundAgentData = inboundAgentQueriesTrpc[index]?.data;
const isInboundAgentLoading = inboundAgentQueriesTrpc[index]?.isPending;
const isInboundAgentLoading =
inboundAgentQueriesTrpc[index]?.isPending;
return (
<div key={index}>
@@ -241,13 +272,15 @@ export default function WorkflowDetailsPage(props: Props) {
<div className="border-subtle text-subtle rounded-lg border p-1">
<Icon name="arrow-right" size="16" />
</div>
<div className="text-sm font-medium leading-none">{t("action")}</div>
<div className="text-sm font-medium leading-none">
{t("action")}
</div>
</div>
}
deleteField={
!permissions.readOnly
? {
color: "destructive",
color: "minimal",
check: () => true,
disabled: !permissions.canUpdate,
fn: () => {
@@ -260,11 +293,16 @@ export default function WorkflowDetailsPage(props: Props) {
} else {
const steps = form.getValues("steps");
const updatedSteps = steps
?.filter((currStep) => currStep.id !== step.id)
?.filter(
(currStep) => currStep.id !== step.id
)
.map((s) => {
const updatedStep = s;
if (step.stepNumber < updatedStep.stepNumber) {
updatedStep.stepNumber = updatedStep.stepNumber - 1;
if (
step.stepNumber < updatedStep.stepNumber
) {
updatedStep.stepNumber =
updatedStep.stepNumber - 1;
}
return updatedStep;
});
@@ -276,7 +314,8 @@ export default function WorkflowDetailsPage(props: Props) {
},
}
: null
}>
}
>
<FormCardBody className="border-muted">
<WorkflowStepContainer
form={form}
@@ -316,7 +355,8 @@ export default function WorkflowDetailsPage(props: Props) {
type="button"
onClick={() => setIsAddActionDialogOpen(true)}
color="secondary"
className="bg-default">
className="bg-default"
>
{t("add_action")}
</Button>
</>
File diff suppressed because it is too large Load Diff
@@ -462,7 +462,7 @@ function WorkflowPage({
<div className="group flex min-w-0 items-center gap-1">
<span
className="text-default hover:bg-cal-muted min-w-0 cursor-pointer truncate whitespace-nowrap rounded p-1 text-sm font-semibold leading-none sm:min-w-[100px]"
className="text-default hover:bg-cal-muted min-w-0 cursor-pointer truncate whitespace-nowrap rounded p-1 text-sm font-semibold leading-none"
onClick={() => setIsEditingName(true)}>
{watchedName ? watchedName : isPending ? t("loading") : t("untitled")}
</span>
+335 -135
View File
@@ -18,14 +18,21 @@ import {
useBookerStoreContext,
} from "@calcom/features/bookings/Booker/BookerStoreProvider";
import { useInitializeBookerStore } from "@calcom/features/bookings/Booker/store";
import { useEvent, useScheduleForEvent } from "@calcom/features/bookings/Booker/utils/event";
import {
useEvent,
useScheduleForEvent,
} from "@calcom/features/bookings/Booker/utils/event";
import DatePicker from "@calcom/features/calendars/DatePicker";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import { TimezoneSelect } from "@calcom/features/components/timezone-select";
import type { Slot } from "@calcom/features/schedules/lib/use-schedule/types";
import { useNonEmptyScheduleDays } from "@calcom/features/schedules/lib/use-schedule/useNonEmptyScheduleDays";
import { useSlotsForDate } from "@calcom/features/schedules/lib/use-schedule/useSlotsForDate";
import { APP_NAME, DEFAULT_LIGHT_BRAND_COLOR, DEFAULT_DARK_BRAND_COLOR } from "@calcom/lib/constants";
import {
APP_NAME,
DEFAULT_LIGHT_BRAND_COLOR,
DEFAULT_DARK_BRAND_COLOR,
} from "@calcom/lib/constants";
import { weekdayToWeekIndex } from "@calcom/lib/dayjs";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -33,7 +40,11 @@ import { BookerLayouts } from "@calcom/prisma/zod-utils";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog";
import {
DialogContent,
DialogFooter,
DialogClose,
} from "@calcom/ui/components/dialog";
import { Select, ColorPicker } from "@calcom/ui/components/form";
import { Label } from "@calcom/ui/components/form";
import { TextField } from "@calcom/ui/components/form";
@@ -49,14 +60,25 @@ import { EmbedTheme } from "@calcom/features/embed/lib/constants";
import { getDimension } from "@calcom/features/embed/lib/getDimension";
import { useEmbedDialogCtx } from "@calcom/features/embed/lib/hooks/useEmbedDialogCtx";
import { useEmbedParams } from "@calcom/features/embed/lib/hooks/useEmbedParams";
import type { EmbedTabs, EmbedType, EmbedTypes, PreviewState, EmbedConfig } from "@calcom/features/embed/types";
import type {
EmbedTabs,
EmbedType,
EmbedTypes,
PreviewState,
EmbedConfig,
} from "@calcom/features/embed/types";
type EventType = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"] | undefined;
type EventType =
| RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"]
| undefined;
type EmbedDialogProps = {
types: EmbedTypes;
tabs: EmbedTabs;
eventTypeHideOptionDisabled: boolean;
defaultBrandColor: { brandColor: string | null; darkBrandColor: string | null } | null;
defaultBrandColor: {
brandColor: string | null;
darkBrandColor: string | null;
} | null;
noQueryParamMode?: boolean;
};
@@ -91,7 +113,11 @@ function chooseTimezone({
userSettingsTimezone: string | undefined;
}) {
// We prefer user's timezone configured in settings at the moment - Might be a better idea to prefer timezoneFromTimePreferences over user settings as the user might be in different timezone
return timezoneFromBookerStore ?? userSettingsTimezone ?? timezoneFromTimePreferences;
return (
timezoneFromBookerStore ??
userSettingsTimezone ??
timezoneFromTimePreferences
);
}
function useRouterHelpers() {
@@ -141,7 +167,10 @@ function useEmbedGoto(noQueryParamMode = false) {
}));
} else {
const validQueryParams = Object.fromEntries(
Object.entries(props).filter(([_, value]) => value !== null) as [string, string][]
Object.entries(props).filter(([_, value]) => value !== null) as [
string,
string
][]
);
goto(validQueryParams);
}
@@ -203,7 +232,9 @@ const ChooseEmbedTypesDialogContent = ({
{t("how_you_want_add_cal_site", { appName: APP_NAME })}
</h3>
<div>
<p className="text-subtle text-sm">{t("choose_ways_put_cal_site", { appName: APP_NAME })}</p>
<p className="text-subtle text-sm">
{t("choose_ways_put_cal_site", { appName: APP_NAME })}
</p>
</div>
</div>
<div className="items-start stack-y-2 md:flex md:stack-y-0">
@@ -214,17 +245,23 @@ const ChooseEmbedTypesDialogContent = ({
data-testid={embed.type}
onClick={() => {
if (embed.type === "headless") {
window.open("https://cal.com/help/routing/headless-routing", "_blank");
window.open(
"https://cal.com/help/routing/headless-routing",
"_blank"
);
} else {
gotoState({
embedType: embed.type as EmbedType,
});
}
}}>
}}
>
<div className="bg-default order-0 box-border flex-none rounded-md border border-solid transition dark:bg-transparent dark:invert">
{embed.illustration}
</div>
<div className="text-emphasis mt-4 font-semibold">{embed.title}</div>
<div className="text-emphasis mt-4 font-semibold">
{embed.title}
</div>
<p className="text-subtle mt-2 text-sm">{embed.subtitle}</p>
</button>
))}
@@ -251,7 +288,8 @@ const EmailEmbed = ({
userSettingsTimezone?: string;
}) => {
const { t, i18n } = useLocale();
const { timezoneFromBookerStore, timezoneFromTimePreferences } = useBookerTime();
const { timezoneFromBookerStore, timezoneFromTimePreferences } =
useBookerTime();
const timezone = chooseTimezone({
timezoneFromBookerStore,
timezoneFromTimePreferences,
@@ -279,17 +317,22 @@ const EmailEmbed = ({
(state) => [state.month, state.selectedDate, state.selectedDatesAndTimes],
shallow
);
const [setSelectedDate, setMonth, setSelectedDatesAndTimes, setSelectedTimeslot, setTimezone] =
useBookerStoreContext(
(state) => [
state.setSelectedDate,
state.setMonth,
state.setSelectedDatesAndTimes,
state.setSelectedTimeslot,
state.setTimezone,
],
shallow
);
const [
setSelectedDate,
setMonth,
setSelectedDatesAndTimes,
setSelectedTimeslot,
setTimezone,
] = useBookerStoreContext(
(state) => [
state.setSelectedDate,
state.setMonth,
state.setSelectedDatesAndTimes,
state.setSelectedTimeslot,
state.setTimezone,
],
shallow
);
const event = useEvent();
const schedule = useScheduleForEvent({
orgSlug,
@@ -306,8 +349,10 @@ const EmailEmbed = ({
return null;
}
if (selectedDatesAndTimes && selectedDatesAndTimes[eventType.slug]) {
const selectedDatesAndTimesForEvent = selectedDatesAndTimes[eventType.slug];
const selectedSlots = selectedDatesAndTimesForEvent[selectedDate as string] ?? [];
const selectedDatesAndTimesForEvent =
selectedDatesAndTimes[eventType.slug];
const selectedSlots =
selectedDatesAndTimesForEvent[selectedDate as string] ?? [];
if (selectedSlots?.includes(time)) {
// Checks whether a user has removed all their timeSlots and thus removes it from the selectedDatesAndTimesForEvent state
if (selectedSlots?.length > 1) {
@@ -315,13 +360,17 @@ const EmailEmbed = ({
...selectedDatesAndTimes,
[eventType.slug]: {
...selectedDatesAndTimesForEvent,
[selectedDate as string]: selectedSlots?.filter((slot: string) => slot !== time),
[selectedDate as string]: selectedSlots?.filter(
(slot: string) => slot !== time
),
},
};
setSelectedDatesAndTimes(updatedDatesAndTimes);
} else {
const updatedDatesAndTimesForEvent = { ...selectedDatesAndTimesForEvent };
const updatedDatesAndTimesForEvent = {
...selectedDatesAndTimesForEvent,
};
delete updatedDatesAndTimesForEvent[selectedDate as string];
setSelectedTimeslot(null);
setSelectedDatesAndTimes({
@@ -342,7 +391,9 @@ const EmailEmbed = ({
setSelectedDatesAndTimes(updatedDatesAndTimes);
} else if (!selectedDatesAndTimes) {
setSelectedDatesAndTimes({ [eventType.slug]: { [selectedDate as string]: [time] } });
setSelectedDatesAndTimes({
[eventType.slug]: { [selectedDate as string]: [time] },
});
} else {
setSelectedDatesAndTimes({
...selectedDatesAndTimes,
@@ -377,7 +428,9 @@ const EmailEmbed = ({
<DatePicker
isLoading={schedule.isPending}
onChange={(date: Dayjs | null) => {
setSelectedDate({ date: date === null ? date : date.format("YYYY-MM-DD") });
setSelectedDate({
date: date === null ? date : date.format("YYYY-MM-DD"),
});
}}
onMonthChange={(date: Dayjs) => {
setMonth(date.format("YYYY-MM"));
@@ -387,7 +440,9 @@ const EmailEmbed = ({
locale={i18n.language}
browsingDate={month ? dayjs(month) : undefined}
selected={dayjs(selectedDate)}
weekStart={weekdayToWeekIndex(event?.data?.subsetOfUsers?.[0]?.weekStart)}
weekStart={weekdayToWeekIndex(
event?.data?.subsetOfUsers?.[0]?.weekStart
)}
eventSlug={eventType?.slug}
/>
</CollapsibleContent>
@@ -405,7 +460,9 @@ const EmailEmbed = ({
selectedDatesAndTimes &&
selectedDatesAndTimes[eventType.slug] &&
selectedDatesAndTimes[eventType.slug][selectedDate as string]
? selectedDatesAndTimes[eventType.slug][selectedDate as string]
? selectedDatesAndTimes[eventType.slug][
selectedDate as string
]
: undefined
}
handleSlotClick={handleSlotClick}
@@ -423,7 +480,9 @@ const EmailEmbed = ({
<div className="text-default mb-[9px] text-sm">{t("duration")}</div>
{durationsOptions.length > 0 ? (
<Select<{ label: string; value: number }>
value={durationsOptions.find((option) => option.value === selectedDuration)}
value={durationsOptions.find(
(option) => option.value === selectedDuration
)}
options={durationsOptions}
onChange={(option) => {
setSelectedDuration(option?.value);
@@ -445,7 +504,11 @@ const EmailEmbed = ({
<Collapsible open>
<CollapsibleContent>
<div className="text-default mb-[9px] text-sm">{t("timezone")}</div>
<TimezoneSelect id="timezone" value={timezone} onChange={({ value }) => setTimezone(value)} />
<TimezoneSelect
id="timezone"
value={timezone}
onChange={({ value }) => setTimezone(value)}
/>
</CollapsibleContent>
</Collapsible>
</div>
@@ -474,7 +537,8 @@ const EmailEmbedPreview = ({
userSettingsTimezone?: string;
}) => {
const { t } = useLocale();
const { timeFormat, timezoneFromBookerStore, timezoneFromTimePreferences } = useBookerTime();
const { timeFormat, timezoneFromBookerStore, timezoneFromTimePreferences } =
useBookerTime();
const timezone = chooseTimezone({
timezoneFromBookerStore,
timezoneFromTimePreferences,
@@ -498,7 +562,8 @@ const EmailEmbedPreview = ({
overflowY: "auto",
backgroundColor: "white",
}}
ref={emailContentRef}>
ref={emailContentRef}
>
<div
style={{
fontStyle: "normal",
@@ -507,7 +572,8 @@ const EmailEmbedPreview = ({
lineHeight: "19px",
marginTop: "15px",
marginBottom: "15px",
}}>
}}
>
<b style={{ color: "black" }}> {eventType.title}</b>
</div>
<div
@@ -517,8 +583,10 @@ const EmailEmbedPreview = ({
fontSize: "14px",
lineHeight: "17px",
color: "#333333",
}}>
{t("duration")}: <b style={{ color: "black" }}>{selectedDuration} mins</b>
}}
>
{t("duration")}:{" "}
<b style={{ color: "black" }}>{selectedDuration} mins</b>
</div>
<div>
<b style={{ color: "black" }}>
@@ -529,7 +597,8 @@ const EmailEmbedPreview = ({
fontSize: "14px",
lineHeight: "17px",
color: "#333333",
}}>
}}
>
{t("timezone")}: <b style={{ color: "black" }}>{timezone}</b>
</span>
</b>
@@ -553,10 +622,13 @@ const EmailEmbedPreview = ({
textAlign: "left",
borderCollapse: "collapse",
borderSpacing: "0px",
}}>
}}
>
<tbody>
<tr>
<td style={{ textAlign: "left", marginTop: "16px" }}>
<td
style={{ textAlign: "left", marginTop: "16px" }}
>
<span
style={{
fontSize: "14px",
@@ -564,7 +636,8 @@ const EmailEmbedPreview = ({
paddingBottom: "8px",
color: "rgb(26, 26, 26)",
fontWeight: "bold",
}}>
}}
>
{selectedDate}
&nbsp;
</span>
@@ -572,15 +645,24 @@ const EmailEmbedPreview = ({
</tr>
<tr>
<td>
<table style={{ borderCollapse: "separate", borderSpacing: "0px 4px" }}>
<table
style={{
borderCollapse: "separate",
borderSpacing: "0px 4px",
}}
>
<tbody>
<tr style={{ height: "25px" }}>
{sortedTimes?.length > 0 &&
sortedTimes.map((time) => {
// If teamId is present on eventType and is not null, it means it is a team event.
// So we add 'team/' to the url.
const bookingURL = `${eventType.bookerUrl}/${
eventType.teamId !== null ? "team/" : ""
const bookingURL = `${
eventType.bookerUrl
}/${
eventType.teamId !== null
? "team/"
: ""
}${username}/${
eventType.slug
}?duration=${selectedDuration}&date=${key}&month=${month}&slot=${time}&cal.tz=${timezone}`;
@@ -596,34 +678,45 @@ const EmailEmbedPreview = ({
height: "24px",
border: "1px solid #111827",
borderRadius: "3px",
}}>
}}
>
<table style={{ height: "21px" }}>
<tbody>
<tr style={{ height: "21px" }}>
<td style={{ width: "7px" }} />
<td
style={{ width: "7px" }}
/>
<td
style={{
width: "50px",
textAlign: "center",
marginRight: "1px",
}}>
}}
>
<a
href={bookingURL}
className="spot"
style={{
fontFamily: '"Proxima Nova", sans-serif',
fontFamily:
'"Proxima Nova", sans-serif',
textDecoration: "none",
textAlign: "center",
color: "#111827",
fontSize: "12px",
lineHeight: "16px",
}}>
}}
>
<b
style={{
fontWeight: "normal",
textDecoration: "none",
}}>
{dayjs.utc(time).tz(timezone).format(timeFormat)}
textDecoration:
"none",
}}
>
{dayjs
.utc(time)
.tz(timezone)
.format(timeFormat)}
&nbsp;
</b>
</a>
@@ -652,7 +745,8 @@ const EmailEmbedPreview = ({
textDecoration: "none",
cursor: "pointer",
color: "black",
}}>
}}
>
{t("see_all_available_times")}
</a>
</div>
@@ -664,7 +758,8 @@ const EmailEmbedPreview = ({
borderTop: "1px solid #CCCCCC",
marginTop: "8px",
paddingTop: "8px",
}}>
}}
>
<span>{t("powered_by")}</span>{" "}
<b style={{ color: "black" }}>
<span> Cal.com</span>
@@ -695,7 +790,8 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
const { t } = useLocale();
const searchParams = useCompatSearchParams();
const pathname = usePathname();
const { resetState, gotoState, gotoEmbedTypeSelectionState } = useEmbedGoto(noQueryParamMode);
const { resetState, gotoState, gotoEmbedTypeSelectionState } =
useEmbedGoto(noQueryParamMode);
const iframeRef = useRef<HTMLIFrameElement>(null);
const dialogContentRef = useRef<HTMLDivElement>(null);
const emailContentRef = useRef<HTMLDivElement>(null);
@@ -712,7 +808,10 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
const calLink = decodeURIComponent(embedUrl);
const { data: eventTypeData } = trpc.viewer.eventTypes.get.useQuery(
{ id: parsedEventId },
{ enabled: !Number.isNaN(parsedEventId) && embedType === "email", refetchOnWindowFocus: false }
{
enabled: !Number.isNaN(parsedEventId) && embedType === "email",
refetchOnWindowFocus: false,
}
);
const { data: userSettings } = trpc.viewer.me.get.useQuery();
@@ -743,7 +842,10 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
}),
};
});
const embedCodeRefs: Record<(typeof tabs)[0]["name"], RefObject<HTMLTextAreaElement>> = {};
const embedCodeRefs: Record<
(typeof tabs)[0]["name"],
RefObject<HTMLTextAreaElement>
> = {};
tabs
.filter((tab) => tab.type === "code")
.forEach((codeTab) => {
@@ -752,10 +854,14 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
const refOfEmbedCodesRefs = useRef(embedCodeRefs);
const embed = types.find((embed) => embed.type === embedType);
const [selectedDuration, setSelectedDuration] = useState(eventTypeData?.eventType.length);
const [selectedDuration, setSelectedDuration] = useState(
eventTypeData?.eventType.length
);
const [isEmbedCustomizationOpen, setIsEmbedCustomizationOpen] = useState(true);
const [isBookingCustomizationOpen, setIsBookingCustomizationOpen] = useState(true);
const [isEmbedCustomizationOpen, setIsEmbedCustomizationOpen] =
useState(true);
const [isBookingCustomizationOpen, setIsBookingCustomizationOpen] =
useState(true);
const defaultConfig = {
layout: BookerLayouts.MONTH_VIEW,
};
@@ -832,7 +938,13 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
);
};
const inlineEmbedDimensionUpdate = ({ width, height }: { width: string; height: string }) => {
const inlineEmbedDimensionUpdate = ({
width,
height,
}: {
width: string;
height: string;
}) => {
iframeRef.current?.contentWindow?.postMessage(
{
mode: "cal:preview",
@@ -924,18 +1036,22 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
enableOverflow
ref={dialogContentRef}
className="rounded-lg p-0.5 sm:max-w-7xl!"
type="creation">
type="creation"
>
<div className="flex">
<div className="bg-cal-muted flex h-[95vh] w-1/3 flex-col overflow-y-auto p-8">
<h3
className="text-emphasis mb-2.5 flex items-center text-xl font-semibold leading-5"
id="modal-title">
id="modal-title"
>
<button className="h-6 w-6" onClick={gotoEmbedTypeSelectionState}>
<Icon name="arrow-left" className="mr-4 w-4" />
</button>
{embed.title}
</h3>
<h4 className="text-subtle mb-6 text-sm font-normal">{embed.subtitle}</h4>
<h4 className="text-subtle mb-6 text-sm font-normal">
{embed.subtitle}
</h4>
{eventTypeData?.eventType && embedType === "email" ? (
<EmailEmbed
eventType={eventTypeData?.eventType}
@@ -948,68 +1064,82 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
/>
) : (
<div className="flex flex-col">
<div className={classNames("font-medium", embedType === "element-click" ? "hidden" : "")}>
<div
className={classNames(
"font-medium",
embedType === "element-click" ? "hidden" : ""
)}
>
<Collapsible
open={isEmbedCustomizationOpen}
onOpenChange={() => setIsEmbedCustomizationOpen((val) => !val)}>
onOpenChange={() =>
setIsEmbedCustomizationOpen((val) => !val)
}
>
<CollapsibleContent className="text-sm">
{/* Conditionally render Window Sizing only if inline embed AND NOT React Atom */}
{embedType === "inline" && embedParams.embedTabName !== EmbedTabName.ATOM_REACT && (
<div>
{/*TODO: Add Auto/Fixed toggle from Figma */}
<div className="text-default mb-[9px] text-sm">Window sizing</div>
<div className="justify-left mb-6 flex items-center font-normal! ">
<div className="mr-[9px]">
{embedType === "inline" &&
embedParams.embedTabName !== EmbedTabName.ATOM_REACT && (
<div>
{/*TODO: Add Auto/Fixed toggle from Figma */}
<div className="text-default mb-[9px] text-sm">
Window sizing
</div>
<div className="justify-left mb-6 flex items-center font-normal! ">
<div className="mr-[9px]">
<TextField
labelProps={{ className: "hidden" }}
className="focus:ring-offset-0"
required
value={previewState.inline.width}
onChange={(e) => {
setPreviewState((previewState) => {
const width = e.target.value || "100%";
return {
...previewState,
inline: {
...previewState.inline,
width,
},
};
});
}}
addOnLeading={<>W</>}
/>
</div>
<TextField
labelProps={{ className: "hidden" }}
className="focus:ring-offset-0"
value={previewState.inline.height}
required
value={previewState.inline.width}
onChange={(e) => {
setPreviewState((previewState) => {
const width = e.target.value || "100%";
const height = e.target.value || "100%";
setPreviewState((previewState) => {
return {
...previewState,
inline: {
...previewState.inline,
width,
height,
},
};
});
}}
addOnLeading={<>W</>}
addOnLeading={<>H</>}
/>
</div>
<TextField
labelProps={{ className: "hidden" }}
className="focus:ring-offset-0"
value={previewState.inline.height}
required
onChange={(e) => {
const height = e.target.value || "100%";
setPreviewState((previewState) => {
return {
...previewState,
inline: {
...previewState.inline,
height,
},
};
});
}}
addOnLeading={<>H</>}
/>
</div>
</div>
)}
)}
<div
className={classNames(
"items-center justify-between",
embedType === "floating-popup" ? "text-emphasis" : "hidden"
)}>
embedType === "floating-popup"
? "text-emphasis"
: "hidden"
)}
>
<div className="mb-2 text-sm">Button text</div>
{/* Default Values should come from preview iframe */}
<TextField
@@ -1035,7 +1165,8 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
embedType === "floating-popup"
? "text-emphasis space-x-2 rtl:space-x-reverse"
: "hidden"
)}>
)}
>
<Switch
defaultChecked={true}
onCheckedChange={(checked) => {
@@ -1050,13 +1181,18 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
});
}}
/>
<div className="text-default my-2 text-sm">Display calendar icon</div>
<div className="text-default my-2 text-sm">
Display calendar icon
</div>
</div>
<div
className={classNames(
"mt-4 items-center justify-between",
embedType === "floating-popup" ? "text-emphasis" : "hidden"
)}>
embedType === "floating-popup"
? "text-emphasis"
: "hidden"
)}
>
<div className="mb-2">Position of button</div>
<Select
onChange={(position) => {
@@ -1075,7 +1211,12 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
/>
</div>
<div className="mt-3 flex flex-col xl:flex-row xl:justify-between">
<div className={classNames("mt-4", embedType === "floating-popup" ? "" : "hidden")}>
<div
className={classNames(
"mt-4",
embedType === "floating-popup" ? "" : "hidden"
)}
>
<div className="whitespace-nowrap">Button color</div>
<div className="mt-2 w-40 xl:mt-0 xl:w-full">
<ColorPicker
@@ -1097,7 +1238,12 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
/>
</div>
</div>
<div className={classNames("mt-4", embedType === "floating-popup" ? "" : "hidden")}>
<div
className={classNames(
"mt-4",
embedType === "floating-popup" ? "" : "hidden"
)}
>
<div className="whitespace-nowrap">Text color</div>
<div className="mb-6 mt-2 w-40 xl:mt-0 xl:w-full">
<ColorPicker
@@ -1126,13 +1272,16 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
<div className="font-medium">
<Collapsible
open={isBookingCustomizationOpen}
onOpenChange={() => setIsBookingCustomizationOpen((val) => !val)}>
onOpenChange={() =>
setIsBookingCustomizationOpen((val) => !val)
}
>
<CollapsibleContent>
<div className="text-sm">
{/* Conditionally render EmbedTheme only if NOT React Atom */}
{embedParams.embedTabName !== EmbedTabName.ATOM_REACT && (
<Label className="mb-6">
<div className="mb-2">EmbedTheme</div>
<div className="mb-2">Embed theme</div>
<Select
className="w-full"
defaultValue={ThemeOptions[0]}
@@ -1146,7 +1295,9 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
}
setPreviewState((previewState) => {
// Ensure theme is updated in config for all embed types
const newConfig = (currentConfig?: EmbedConfig) => ({
const newConfig = (
currentConfig?: EmbedConfig
) => ({
...(currentConfig ?? {}),
theme: option.value,
});
@@ -1154,15 +1305,21 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
...previewState,
inline: {
...previewState.inline,
config: newConfig(previewState.inline.config),
config: newConfig(
previewState.inline.config
),
},
floatingPopup: {
...previewState.floatingPopup,
config: newConfig(previewState.floatingPopup.config),
config: newConfig(
previewState.floatingPopup.config
),
},
elementClick: {
...previewState.elementClick,
config: newConfig(previewState.elementClick.config),
config: newConfig(
previewState.elementClick.config
),
},
// Keep updating top-level theme for preview iframe
theme: option.value,
@@ -1188,7 +1345,9 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
});
}}
/>
<div className="text-default text-sm">{t("hide_eventtype_details")}</div>
<div className="text-default text-sm">
{t("hide_eventtype_details")}
</div>
</div>
) : null}
{/* Conditionally render Brand Colors only if NOT React Atom */}
@@ -1207,11 +1366,14 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
<div className="w-full">
<ColorPicker
popoverAlign="start"
container={dialogContentRef?.current ?? undefined}
container={
dialogContentRef?.current ?? undefined
}
defaultValue={paletteDefaultValue(palette.name)}
onChange={(color) => {
addToPalette({
[palette.name as keyof (typeof previewState)["palette"]]: color,
[palette.name as keyof (typeof previewState)["palette"]]:
color,
});
}}
/>
@@ -1229,7 +1391,9 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
}
setPreviewState((previewState) => {
// Ensure layout is updated in config for all embed types
const newConfig = (currentConfig?: EmbedConfig) => ({
const newConfig = (
currentConfig?: EmbedConfig
) => ({
...(currentConfig ?? {}),
layout: option.value,
});
@@ -1241,11 +1405,15 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
},
floatingPopup: {
...previewState.floatingPopup,
config: newConfig(previewState.floatingPopup.config),
config: newConfig(
previewState.floatingPopup.config
),
},
elementClick: {
...previewState.elementClick,
config: newConfig(previewState.elementClick.config),
config: newConfig(
previewState.elementClick.config
),
},
// Keep updating top-level layout for preview iframe
layout: option.value,
@@ -1281,8 +1449,11 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
<div
key={tab.href}
className={classNames(
embedParams.embedTabName === tab.href.split("=")[1] ? "flex-1" : "hidden"
)}>
embedParams.embedTabName === tab.href.split("=")[1]
? "flex-1"
: "hidden"
)}
>
{tab.type === "code" && (
<tab.Component
namespace={namespace}
@@ -1293,16 +1464,27 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
/>
)}
<div
className={embedParams.embedTabName === "embed-preview" ? "mt-2 block" : "hidden"}
className={
embedParams.embedTabName === "embed-preview"
? "mt-2 block"
: "hidden"
}
/>
</div>
);
}
if (embedType === "email" && (tab.name !== "Preview" || !eventTypeData?.eventType)) return;
if (
embedType === "email" &&
(tab.name !== "Preview" || !eventTypeData?.eventType)
)
return;
return (
<div key={tab.href} className={classNames("flex grow flex-col")}>
<div
key={tab.href}
className={classNames("flex grow flex-col")}
>
<div className="flex h-[55vh] grow flex-col">
<EmailEmbedPreview
selectedDuration={selectedDuration}
@@ -1314,12 +1496,20 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
month={month as string}
selectedDateAndTime={
selectedDatesAndTimes
? selectedDatesAndTimes[eventTypeData?.eventType.slug as string]
? selectedDatesAndTimes[
eventTypeData?.eventType.slug as string
]
: {}
}
/>
</div>
<div className={embedParams.embedTabName === "embed-preview" ? "mt-2 block" : "hidden"} />
<div
className={
embedParams.embedTabName === "embed-preview"
? "mt-2 block"
: "hidden"
}
/>
</div>
);
})}
@@ -1336,7 +1526,10 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
</div>
)}
</div>
<DialogFooter className="mt-10 flex-row-reverse gap-x-2" showDivider>
<DialogFooter
className="mt-10 flex-row-reverse gap-x-2"
showDivider
>
<DialogClose />
<Button
type="submit"
@@ -1349,14 +1542,16 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
(tab) => tab.href === `embedTabName=${currentTabHref}`
)?.name;
if (!currentTabName) return;
const currentTabCodeEl = refOfEmbedCodesRefs.current[currentTabName].current;
const currentTabCodeEl =
refOfEmbedCodesRefs.current[currentTabName].current;
if (!currentTabCodeEl) {
return;
}
navigator.clipboard.writeText(currentTabCodeEl.value);
showToast(t("code_copied"), "success");
}
}}>
}}
>
{embedType === "email" ? t("copy") : t("copy_code")}
</Button>
</DialogFooter>
@@ -1395,9 +1590,13 @@ export const EmbedDialog = ({
// Must not set name when noQueryParam mode as required by Dialog component
name: "embed",
clearQueryParamsOnClose: queryParamsForDialog,
})}>
})}
>
{!embedParams.embedType ? (
<ChooseEmbedTypesDialogContent types={types} noQueryParamMode={noQueryParamMode} />
<ChooseEmbedTypesDialogContent
types={types}
noQueryParamMode={noQueryParamMode}
/>
) : (
<EmbedTypeCodeAndPreviewDialogContent
embedType={embedParams.embedType as EmbedType}
@@ -1455,7 +1654,8 @@ export const EmbedButton = <T extends React.ElementType = typeof Button>({
data-test-embed-url={embedUrl}
data-testid="embed"
type="button"
onClick={openEmbedModal}>
onClick={openEmbedModal}
>
{children}
</Component>
);
File diff suppressed because it is too large Load Diff
@@ -107,16 +107,22 @@ const GeneralView = ({ user, travelSchedules }: GeneralViewProps) => {
defaultValues: {
locale: {
value: localeProp || "",
label: localeOptions.find((option) => option.value === localeProp)?.label || "",
label:
localeOptions.find((option) => option.value === localeProp)?.label ||
"",
},
timeZone: user.timeZone || "",
timeFormat: {
value: user.timeFormat || 12,
label: timeFormatOptions.find((option) => option.value === user.timeFormat)?.label || 12,
label:
timeFormatOptions.find((option) => option.value === user.timeFormat)
?.label || 12,
},
weekStart: {
value: user.weekStart,
label: weekStartOptions.find((option) => option.value === user.weekStart)?.label || "",
label:
weekStartOptions.find((option) => option.value === user.weekStart)
?.label || "",
},
travelSchedules:
travelSchedules.map((schedule) => {
@@ -136,25 +142,30 @@ const GeneralView = ({ user, travelSchedules }: GeneralViewProps) => {
} = formMethods;
const isDisabled = isSubmitting || !isDirty;
const [isAllowDynamicBookingChecked, setIsAllowDynamicBookingChecked] = useState(
!!user.allowDynamicBooking
);
const [isAllowDynamicBookingChecked, setIsAllowDynamicBookingChecked] =
useState(!!user.allowDynamicBooking);
const [isAllowSEOIndexingChecked, setIsAllowSEOIndexingChecked] = useState(
user.organizationSettings?.allowSEOIndexing === false
? !!user.organizationSettings?.allowSEOIndexing
: !!user.allowSEOIndexing
);
const [isReceiveMonthlyDigestEmailChecked, setIsReceiveMonthlyDigestEmailChecked] = useState(
!!user.receiveMonthlyDigestEmail
);
const [isRequireBookerEmailVerificationChecked, setIsRequireBookerEmailVerificationChecked] = useState(
!!user.requiresBookerEmailVerification
);
const [
isReceiveMonthlyDigestEmailChecked,
setIsReceiveMonthlyDigestEmailChecked,
] = useState(!!user.receiveMonthlyDigestEmail);
const [
isRequireBookerEmailVerificationChecked,
setIsRequireBookerEmailVerificationChecked,
] = useState(!!user.requiresBookerEmailVerification);
const watchedTzSchedules = formMethods.watch("travelSchedules");
return (
<SettingsHeader title={t("general")} description={t("general_description")} borderInShellHeader={true}>
<SettingsHeader
title={t("general")}
description={t("general_description")}
borderInShellHeader={true}
>
<div>
<Form
form={formMethods}
@@ -166,7 +177,8 @@ const GeneralView = ({ user, travelSchedules }: GeneralViewProps) => {
timeFormat: values.timeFormat.value,
weekStart: values.weekStart.value,
});
}}>
}}
>
<div className="border-subtle border-x border-y-0 px-4 py-8 sm:px-6">
<Controller
name="locale"
@@ -192,25 +204,33 @@ const GeneralView = ({ user, travelSchedules }: GeneralViewProps) => {
<Label className="text-emphasis mt-6">
<>{t("timezone")}</>
</Label>
<TimezoneSelect
id="timezone"
value={value}
onChange={(event) => {
if (event) formMethods.setValue("timeZone", event.value, { shouldDirty: true });
}}
/>
<div className="flex items-end gap-2">
<div className="flex-1">
<TimezoneSelect
id="timezone"
value={value}
onChange={(event) => {
if (event)
formMethods.setValue("timeZone", event.value, {
shouldDirty: true,
});
}}
/>
</div>
{!watchedTzSchedules.length && (
<Button
color="secondary"
StartIcon="calendar"
onClick={() => setIsTZScheduleOpen(true)}
>
{t("schedule_timezone_change")}
</Button>
)}
</div>
</>
)}
/>
{!watchedTzSchedules.length ? (
<Button
color="secondary"
className="mt-2"
StartIcon="calendar"
onClick={() => setIsTZScheduleOpen(true)}>
{t("schedule_timezone_change")}
</Button>
) : (
{watchedTzSchedules.length > 0 && (
<div className="bg-cal-muted border-subtle mt-2 rounded-md border p-4">
<Label>{t("travel_schedule")}</Label>
<div className="border-subtle bg-default mt-4 rounded-md border text-sm">
@@ -221,7 +241,8 @@ const GeneralView = ({ user, travelSchedules }: GeneralViewProps) => {
"flex items-center p-4",
index !== 0 ? "border-subtle border-t" : ""
)}
key={index}>
key={index}
>
<div>
<div className="text-emphasis font-semibold">{`${formatLocalizedDateTime(
schedule.startDate,
@@ -236,7 +257,9 @@ const GeneralView = ({ user, travelSchedules }: GeneralViewProps) => {
)}`
: ``
}`}</div>
<div className="text-subtle">{schedule.timeZone.replace(/_/g, " ")}</div>
<div className="text-subtle">
{schedule.timeZone.replace(/_/g, " ")}
</div>
</div>
<Button
color="destructive"
@@ -247,7 +270,11 @@ const GeneralView = ({ user, travelSchedules }: GeneralViewProps) => {
const updatedSchedules = watchedTzSchedules.filter(
(s, filterIndex) => filterIndex !== index
);
formMethods.setValue("travelSchedules", updatedSchedules, { shouldDirty: true });
formMethods.setValue(
"travelSchedules",
updatedSchedules,
{ shouldDirty: true }
);
}}
/>
</div>
@@ -258,7 +285,8 @@ const GeneralView = ({ user, travelSchedules }: GeneralViewProps) => {
StartIcon="plus"
color="secondary"
className="mt-4"
onClick={() => setIsTZScheduleOpen(true)}>
onClick={() => setIsTZScheduleOpen(true)}
>
{t("add")}
</Button>
</div>
@@ -276,7 +304,12 @@ const GeneralView = ({ user, travelSchedules }: GeneralViewProps) => {
value={value}
options={timeFormatOptions}
onChange={(event) => {
if (event) formMethods.setValue("timeFormat", { ...event }, { shouldDirty: true });
if (event)
formMethods.setValue(
"timeFormat",
{ ...event },
{ shouldDirty: true }
);
}}
/>
</>
@@ -297,7 +330,12 @@ const GeneralView = ({ user, travelSchedules }: GeneralViewProps) => {
value={value}
options={weekStartOptions}
onChange={(event) => {
if (event) formMethods.setValue("weekStart", { ...event }, { shouldDirty: true });
if (event)
formMethods.setValue(
"weekStart",
{ ...event },
{ shouldDirty: true }
);
}}
/>
</>
@@ -311,7 +349,8 @@ const GeneralView = ({ user, travelSchedules }: GeneralViewProps) => {
disabled={isDisabled}
color="primary"
type="submit"
data-testid="general-submit-button">
data-testid="general-submit-button"
>
<>{t("update")}</>
</Button>
</SectionBottomActions>
@@ -335,7 +374,10 @@ const GeneralView = ({ user, travelSchedules }: GeneralViewProps) => {
toggleSwitchAtTheEnd={true}
title={t("seo_indexing")}
description={t("allow_seo_indexing")}
disabled={mutation.isPending || user.organizationSettings?.allowSEOIndexing === false}
disabled={
mutation.isPending ||
user.organizationSettings?.allowSEOIndexing === false
}
checked={isAllowSEOIndexingChecked}
onCheckedChange={(checked) => {
setIsAllowSEOIndexingChecked(checked);
@@ -28,7 +28,12 @@ import type { AppRouter } from "@calcom/trpc/types/server/routers/_app";
import { Alert } from "@calcom/ui/components/alert";
import { UserAvatar } from "@calcom/ui/components/avatar";
import { Button } from "@calcom/ui/components/button";
import { DialogContent, DialogFooter, DialogTrigger, DialogClose } from "@calcom/ui/components/dialog";
import {
DialogContent,
DialogFooter,
DialogTrigger,
DialogClose,
} from "@calcom/ui/components/dialog";
import { Editor } from "@calcom/ui/components/editor";
import { Form } from "@calcom/ui/components/form";
import { PasswordField } from "@calcom/ui/components/form";
@@ -85,7 +90,10 @@ const ProfileView = ({ user }: Props) => {
revalidateSettingsProfile();
if (res.hasEmailBeenChanged && res.sendEmailVerification) {
showToast(t("change_of_email_toast", { email: tempFormValues?.email }), "success");
showToast(
t("change_of_email_toast", { email: tempFormValues?.email }),
"success"
);
} else {
showToast(t("settings_updated_successfully"), "success");
}
@@ -105,44 +113,56 @@ const ProfileView = ({ user }: Props) => {
}
},
});
const unlinkConnectedAccountMutation = trpc.viewer.loggedInViewerRouter.unlinkConnectedAccount.useMutation({
onSuccess: async (res) => {
showToast(t(res.message), "success");
utils.viewer.me.invalidate();
revalidateSettingsProfile();
},
onError: (e) => {
showToast(t(e.message), "error");
},
});
const unlinkConnectedAccountMutation =
trpc.viewer.loggedInViewerRouter.unlinkConnectedAccount.useMutation({
onSuccess: async (res) => {
showToast(t(res.message), "success");
utils.viewer.me.invalidate();
revalidateSettingsProfile();
},
onError: (e) => {
showToast(t(e.message), "error");
},
});
const addSecondaryEmailMutation = trpc.viewer.loggedInViewerRouter.addSecondaryEmail.useMutation({
onSuccess: (res) => {
setShowSecondaryEmailModalOpen(false);
setNewlyAddedSecondaryEmail(res?.data?.email);
utils.viewer.me.invalidate();
revalidateSettingsProfile();
},
onError: (error) => {
setSecondaryEmailAddErrorMessage(error?.message || "");
},
});
const addSecondaryEmailMutation =
trpc.viewer.loggedInViewerRouter.addSecondaryEmail.useMutation({
onSuccess: (res) => {
setShowSecondaryEmailModalOpen(false);
setNewlyAddedSecondaryEmail(res?.data?.email);
utils.viewer.me.invalidate();
revalidateSettingsProfile();
},
onError: (error) => {
setSecondaryEmailAddErrorMessage(error?.message || "");
},
});
const resendVerifyEmailMutation = trpc.viewer.auth.resendVerifyEmail.useMutation();
const resendVerifyEmailMutation =
trpc.viewer.auth.resendVerifyEmail.useMutation();
const [confirmPasswordOpen, setConfirmPasswordOpen] = useState(false);
const [tempFormValues, setTempFormValues] = useState<ExtendedFormValues | null>(null);
const [confirmPasswordErrorMessage, setConfirmPasswordDeleteErrorMessage] = useState("");
const [showCreateAccountPasswordDialog, setShowCreateAccountPasswordDialog] = useState(false);
const [showAccountDisconnectWarning, setShowAccountDisconnectWarning] = useState(false);
const [showSecondaryEmailModalOpen, setShowSecondaryEmailModalOpen] = useState(false);
const [secondaryEmailAddErrorMessage, setSecondaryEmailAddErrorMessage] = useState("");
const [newlyAddedSecondaryEmail, setNewlyAddedSecondaryEmail] = useState<undefined | string>(undefined);
const [tempFormValues, setTempFormValues] =
useState<ExtendedFormValues | null>(null);
const [confirmPasswordErrorMessage, setConfirmPasswordDeleteErrorMessage] =
useState("");
const [showCreateAccountPasswordDialog, setShowCreateAccountPasswordDialog] =
useState(false);
const [showAccountDisconnectWarning, setShowAccountDisconnectWarning] =
useState(false);
const [showSecondaryEmailModalOpen, setShowSecondaryEmailModalOpen] =
useState(false);
const [secondaryEmailAddErrorMessage, setSecondaryEmailAddErrorMessage] =
useState("");
const [newlyAddedSecondaryEmail, setNewlyAddedSecondaryEmail] = useState<
undefined | string
>(undefined);
const [deleteAccountOpen, setDeleteAccountOpen] = useState(false);
const [hasDeleteErrors, setHasDeleteErrors] = useState(false);
const [deleteErrorMessage, setDeleteErrorMessage] = useState("");
const [isCompanyEmailAlertDismissed, setIsCompanyEmailAlertDismissed] = useState(false);
const [isCompanyEmailAlertDismissed, setIsCompanyEmailAlertDismissed] =
useState(false);
const form = useForm<DeleteAccountValues>();
const onDeleteMeSuccessMutation = async () => {
@@ -181,25 +201,30 @@ const ProfileView = ({ user }: Props) => {
revalidateSettingsProfile();
},
});
const deleteMeWithoutPasswordMutation = trpc.viewer.me.deleteMeWithoutPassword.useMutation({
onSuccess: onDeleteMeSuccessMutation,
onError: onDeleteMeErrorMutation,
async onSettled() {
await utils.viewer.me.invalidate();
revalidateSettingsProfile();
},
});
const deleteMeWithoutPasswordMutation =
trpc.viewer.me.deleteMeWithoutPassword.useMutation({
onSuccess: onDeleteMeSuccessMutation,
onError: onDeleteMeErrorMutation,
async onSettled() {
await utils.viewer.me.invalidate();
revalidateSettingsProfile();
},
});
const isCALIdentityProvider = user?.identityProvider === IdentityProvider.CAL;
const onConfirmPassword = (e: Event | React.MouseEvent<HTMLElement, MouseEvent>) => {
const onConfirmPassword = (
e: Event | React.MouseEvent<HTMLElement, MouseEvent>
) => {
e.preventDefault();
const password = passwordRef.current.value;
confirmPasswordMutation.mutate({ passwordInput: password });
};
const onConfirmButton = (e: Event | React.MouseEvent<HTMLElement, MouseEvent>) => {
const onConfirmButton = (
e: Event | React.MouseEvent<HTMLElement, MouseEvent>
) => {
e.preventDefault();
if (isCALIdentityProvider) {
const totpCode = form.getValues("totpCode");
@@ -210,7 +235,10 @@ const ProfileView = ({ user }: Props) => {
}
};
const onConfirm = ({ totpCode }: DeleteAccountValues, e: BaseSyntheticEvent | undefined) => {
const onConfirm = (
{ totpCode }: DeleteAccountValues,
e: BaseSyntheticEvent | undefined
) => {
e?.preventDefault();
if (isCALIdentityProvider) {
const password = passwordRef.current.value;
@@ -225,11 +253,19 @@ const ProfileView = ({ user }: Props) => {
const errorMessages: { [key: string]: string } = {
[ErrorCode.SecondFactorRequired]: t("2fa_enabled_instructions"),
[ErrorCode.IncorrectPassword]: `${t("incorrect_password")} ${t("please_try_again")}`,
[ErrorCode.IncorrectPassword]: `${t("incorrect_password")} ${t(
"please_try_again"
)}`,
[ErrorCode.UserNotFound]: t("no_account_exists"),
[ErrorCode.IncorrectTwoFactorCode]: `${t("incorrect_2fa_code")} ${t("please_try_again")}`,
[ErrorCode.InternalServerError]: `${t("something_went_wrong")} ${t("please_try_again_and_contact_us")}`,
[ErrorCode.ThirdPartyIdentityProviderEnabled]: t("account_created_with_identity_provider"),
[ErrorCode.IncorrectTwoFactorCode]: `${t("incorrect_2fa_code")} ${t(
"please_try_again"
)}`,
[ErrorCode.InternalServerError]: `${t("something_went_wrong")} ${t(
"please_try_again_and_contact_us"
)}`,
[ErrorCode.ThirdPartyIdentityProviderEnabled]: t(
"account_created_with_identity_provider"
),
};
const userEmail = user.email || "";
@@ -267,7 +303,8 @@ const ProfileView = ({ user }: Props) => {
<SettingsHeader
title={t("profile")}
description={t("profile_description", { appName: APP_NAME })}
borderInShellHeader={true}>
borderInShellHeader={true}
>
<ProfileForm
key={JSON.stringify(defaultValues)}
defaultValues={defaultValues}
@@ -316,19 +353,30 @@ const ProfileView = ({ user }: Props) => {
{shouldShowCompanyEmailAlert && (
<div className="mt-6">
<CompanyEmailOrganizationBanner onDismissAction={() => setIsCompanyEmailAlertDismissed(true)} />
<CompanyEmailOrganizationBanner
onDismissAction={() => setIsCompanyEmailAlertDismissed(true)}
/>
</div>
)}
<div className="border-subtle mt-6 rounded-lg rounded-b-none border border-b-0 p-6">
<Label className="mb-0 text-base font-semibold text-red-700">{t("danger_zone")}</Label>
<p className="text-subtle text-sm">{t("account_deletion_cannot_be_undone")}</p>
<Label className="mb-0 text-base font-semibold text-red-700">
{t("danger_zone")}
</Label>
<p className="text-subtle text-sm">
{t("account_deletion_cannot_be_undone")}
</p>
</div>
{/* Delete account Dialog */}
<Dialog open={deleteAccountOpen} onOpenChange={setDeleteAccountOpen}>
<SectionBottomActions align="end">
<DialogTrigger asChild>
<Button data-testid="delete-account" color="destructive" className="mt-1" StartIcon="trash-2">
<Button
data-testid="delete-account"
color="destructive"
className="mt-1"
StartIcon="trash-2"
>
{t("delete_account")}
</Button>
</DialogTrigger>
@@ -337,10 +385,13 @@ const ProfileView = ({ user }: Props) => {
title={t("delete_account_modal_title")}
description={t("confirm_delete_account_modal", { appName: APP_NAME })}
type="creation"
Icon="triangle-alert">
Icon="triangle-alert"
>
<>
<div className="mb-10">
<p className="text-subtle mb-4 text-sm">{t("delete_account_confirmation_message")}</p>
<p className="text-subtle mb-4 text-sm">
{t("delete_account_confirmation_message")}
</p>
{isCALIdentityProvider && (
<PasswordField
data-testid="password"
@@ -359,7 +410,9 @@ const ProfileView = ({ user }: Props) => {
</Form>
)}
{hasDeleteErrors && <Alert severity="error" title={deleteErrorMessage} />}
{hasDeleteErrors && (
<Alert severity="error" title={deleteErrorMessage} />
)}
</div>
<DialogFooter showDivider>
<DialogClose />
@@ -367,7 +420,8 @@ const ProfileView = ({ user }: Props) => {
color="destructive"
data-testid="delete-account-confirm"
onClick={(e) => onConfirmButton(e)}
loading={deleteMeMutation.isPending}>
loading={deleteMeMutation.isPending}
>
{t("delete_my_account")}
</Button>
</DialogFooter>
@@ -381,7 +435,8 @@ const ProfileView = ({ user }: Props) => {
title={t("confirm_password")}
description={t("confirm_password_change_email")}
type="creation"
Icon="triangle-alert">
Icon="triangle-alert"
>
<div className="mb-10">
<div className="mb-4 grid gap-2 md:grid-cols-2">
<div>
@@ -394,7 +449,9 @@ const ProfileView = ({ user }: Props) => {
<span className="text-emphasis mb-2 block text-sm font-medium leading-none">
{t("new_email_address")}
</span>
<p className="text-subtle leading-none">{tempFormValues?.email}</p>
<p className="text-subtle leading-none">
{tempFormValues?.email}
</p>
</div>
</div>
<PasswordField
@@ -407,14 +464,17 @@ const ProfileView = ({ user }: Props) => {
ref={passwordRef}
/>
{confirmPasswordErrorMessage && <Alert severity="error" title={confirmPasswordErrorMessage} />}
{confirmPasswordErrorMessage && (
<Alert severity="error" title={confirmPasswordErrorMessage} />
)}
</div>
<DialogFooter showDivider>
<Button
data-testid="profile-update-email-submit-button"
color="primary"
loading={confirmPasswordMutation.isPending}
onClick={(e) => onConfirmPassword(e)}>
onClick={(e) => onConfirmPassword(e)}
>
{t("confirm")}
</Button>
<DialogClose />
@@ -422,31 +482,40 @@ const ProfileView = ({ user }: Props) => {
</DialogContent>
</Dialog>
<Dialog open={showCreateAccountPasswordDialog} onOpenChange={setShowCreateAccountPasswordDialog}>
<Dialog
open={showCreateAccountPasswordDialog}
onOpenChange={setShowCreateAccountPasswordDialog}
>
<DialogContent
title={t("create_account_password")}
description={t("create_account_password_hint")}
type="creation"
Icon="triangle-alert">
Icon="triangle-alert"
>
<DialogFooter>
<DialogClose />
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showAccountDisconnectWarning} onOpenChange={setShowAccountDisconnectWarning}>
<Dialog
open={showAccountDisconnectWarning}
onOpenChange={setShowAccountDisconnectWarning}
>
<DialogContent
title={t("disconnect_account")}
description={t("disconnect_account_hint")}
type="creation"
Icon="triangle-alert">
Icon="triangle-alert"
>
<DialogFooter>
<Button
color="primary"
onClick={() => {
unlinkConnectedAccountMutation.mutate();
setShowAccountDisconnectWarning(false);
}}>
}}
>
{t("confirm")}
</Button>
<DialogClose />
@@ -528,7 +597,9 @@ const ProfileForm = ({
.trim()
.min(1, t("you_need_to_add_a_name"))
.max(FULL_NAME_LENGTH_MAX_LIMIT, {
message: t("max_limit_allowed_hint", { limit: FULL_NAME_LENGTH_MAX_LIMIT }),
message: t("max_limit_allowed_hint", {
limit: FULL_NAME_LENGTH_MAX_LIMIT,
}),
}),
email: emailSchema.toLowerCase(),
bio: z.string(),
@@ -558,7 +629,8 @@ const ProfileForm = ({
});
const getUpdatedFormValues = (values: FormValues) => {
const changedFields = formMethods.formState.dirtyFields?.secondaryEmails || [];
const changedFields =
formMethods.formState.dirtyFields?.secondaryEmails || [];
const updatedValues: FormValues = {
...values,
};
@@ -569,7 +641,8 @@ const ProfileForm = ({
);
if (primaryEmailIndex >= 0) {
// Add the new updated value as primary email
updatedValues.email = updatedValues.secondaryEmails[primaryEmailIndex].email;
updatedValues.email =
updatedValues.secondaryEmails[primaryEmailIndex].email;
}
// We will only send the emails which have already changed
@@ -583,12 +656,17 @@ const ProfileForm = ({
});
const deletedEmails = (user?.secondaryEmails || []).filter(
(secondaryEmail) => !updatedValues.secondaryEmails.find((val) => val.id && val.id === secondaryEmail.id)
(secondaryEmail) =>
!updatedValues.secondaryEmails.find(
(val) => val.id && val.id === secondaryEmail.id
)
);
const secondaryEmails = [
...updatedEmails.map((email) => ({ ...email, isDeleted: false })),
...deletedEmails.map((email) => ({ ...email, isDeleted: true })),
].map((secondaryEmail) => pick(secondaryEmail, ["id", "email", "isDeleted"]));
].map((secondaryEmail) =>
pick(secondaryEmail, ["id", "email", "isDeleted"])
);
return {
...updatedValues,
@@ -625,9 +703,16 @@ const ProfileForm = ({
const showRemoveAvatarButton = value !== null;
return (
<>
<UserAvatar data-testid="profile-upload-avatar" previewSrc={value} size="lg" user={user} />
<UserAvatar
data-testid="profile-upload-avatar"
previewSrc={value}
size="lg"
user={user}
/>
<div className="ms-4">
<h2 className="mb-2 text-sm font-medium">{t("profile_picture")}</h2>
<h2 className="mb-2 text-sm font-medium">
{t("profile_picture")}
</h2>
<div className="flex gap-2">
<ImageUploader
target="avatar"
@@ -637,15 +722,18 @@ const ProfileForm = ({
onChange(newAvatar);
}}
imageSrc={getUserAvatarUrl({ avatarUrl: value })}
triggerButtonColor={showRemoveAvatarButton ? "secondary" : "secondary"}
triggerButtonColor={
showRemoveAvatarButton ? "secondary" : "secondary"
}
/>
{showRemoveAvatarButton && (
<Button
color="destructive"
color="minimal"
onClick={() => {
onChange(null);
}}>
}}
>
{t("remove")}
</Button>
)}
@@ -669,22 +757,32 @@ const ProfileForm = ({
<div className="-mt-2 flex flex-wrap items-start gap-2">
<div
className={
secondaryEmailFields.length > 1 ? "grid w-full grid-cols-1 gap-2 sm:grid-cols-2" : "flex-1"
}>
secondaryEmailFields.length > 1
? "grid w-full grid-cols-1 gap-2 sm:grid-cols-2"
: "flex-1"
}
>
{secondaryEmailFields.map((field, index) => (
<CustomEmailTextField
key={field.itemId}
formMethods={formMethods}
formMethodFieldName={`secondaryEmails.${index}.email` as keyof FormValues}
errorMessage={get(formMethods.formState.errors, `secondaryEmails.${index}.email.message`)}
formMethodFieldName={
`secondaryEmails.${index}.email` as keyof FormValues
}
errorMessage={get(
formMethods.formState.errors,
`secondaryEmails.${index}.email.message`
)}
emailVerified={Boolean(field.emailVerified)}
emailPrimary={field.emailPrimary}
dataTestId={`profile-form-email-${index}`}
handleChangePrimary={() => {
const fields = secondaryEmailFields.map((secondaryField, cIndex) => ({
...secondaryField,
emailPrimary: cIndex === index,
}));
const fields = secondaryEmailFields.map(
(secondaryField, cIndex) => ({
...secondaryField,
emailPrimary: cIndex === index,
})
);
updateAllSecondaryEmailFields(fields);
}}
handleVerifyEmail={() => handleResendVerifyEmail(field.email)}
@@ -697,7 +795,8 @@ const ProfileForm = ({
StartIcon="plus"
className="mt-2"
onClick={() => handleAddSecondaryEmail()}
data-testid="add-secondary-email">
data-testid="add-secondary-email"
>
{t("add_email")}
</Button>
</div>
@@ -707,7 +806,9 @@ const ProfileForm = ({
<Editor
getText={() => md.render(formMethods.getValues("bio") || "")}
setText={(value: string) => {
formMethods.setValue("bio", turndown(value), { shouldDirty: true });
formMethods.setValue("bio", turndown(value), {
shouldDirty: true,
});
}}
excludedToolbarItems={["blockType"]}
disableLists
@@ -728,7 +829,9 @@ const ProfileForm = ({
labelClassname="font-normal text-sm text-subtle"
valueClassname="text-emphasis inline-flex items-center gap-1 font-normal text-sm leading-5"
value={
["TEXT", "NUMBER", "SINGLE_SELECT"].includes(attribute.type)
["TEXT", "NUMBER", "SINGLE_SELECT"].includes(
attribute.type
)
? attribute.options[0].value
: attribute.options.map((option) => option.value)
}
@@ -740,22 +843,27 @@ const ProfileForm = ({
)}
{/* // For Non-Cal identities, we merge the values from DB and the user logging in,
so essentially there's no point in allowing them to disconnect, since when they log in they will get logged into the same account */}
{!isCALIdentityProvider && user.email !== user.identityProviderEmail && (
<div className="mt-6">
<Label>Connected accounts</Label>
<div className="flex items-center">
<span className="text-default text-sm capitalize">{user.identityProvider.toLowerCase()}</span>
{user.identityProviderEmail && (
<span className="text-default ml-2 text-sm">{user.identityProviderEmail}</span>
)}
<div className="flex flex-1 justify-end">
<Button color="destructive" onClick={onDisconnect}>
{t("disconnect")}
</Button>
{!isCALIdentityProvider &&
user.email !== user.identityProviderEmail && (
<div className="mt-6">
<Label>Connected accounts</Label>
<div className="flex items-center">
<span className="text-default text-sm capitalize">
{user.identityProvider.toLowerCase()}
</span>
{user.identityProviderEmail && (
<span className="text-default ml-2 text-sm">
{user.identityProviderEmail}
</span>
)}
<div className="flex flex-1 justify-end">
<Button color="destructive" onClick={onDisconnect}>
{t("disconnect")}
</Button>
</div>
</div>
</div>
</div>
)}
)}
</div>
<SectionBottomActions align="end">
<Button
@@ -763,7 +871,8 @@ const ProfileForm = ({
disabled={isDisabled}
color="primary"
type="submit"
data-testid="profile-submit-button">
data-testid="profile-submit-button"
>
{t("update")}
</Button>
</SectionBottomActions>
@@ -27,7 +27,10 @@ const usePersistedExpansionState = (itemName: string) => {
const setPersistedExpansion = (expanded: boolean) => {
setIsExpanded(expanded);
sessionStorage.setItem(`nav-expansion-${itemName}`, JSON.stringify(expanded));
sessionStorage.setItem(
`nav-expansion-${itemName}`,
JSON.stringify(expanded)
);
};
return [isExpanded, setPersistedExpansion] as const;
@@ -64,8 +67,16 @@ export type NavigationItemType = {
}) => boolean;
};
const defaultIsCurrent: NavigationItemType["isCurrent"] = ({ isChild, item, pathname }) => {
return isChild ? item.href === pathname : item.href ? pathname?.startsWith(item.href) ?? false : false;
const defaultIsCurrent: NavigationItemType["isCurrent"] = ({
isChild,
item,
pathname,
}) => {
return isChild
? item.href === pathname
: item.href
? pathname?.startsWith(item.href) ?? false
: false;
};
export const NavigationItem: React.FC<{
@@ -76,9 +87,12 @@ export const NavigationItem: React.FC<{
const { item, isChild } = props;
const { t, isLocaleReady } = useLocale();
const pathname = usePathname();
const isCurrent: NavigationItemType["isCurrent"] = item.isCurrent || defaultIsCurrent;
const isCurrent: NavigationItemType["isCurrent"] =
item.isCurrent || defaultIsCurrent;
const current = isCurrent({ isChild: !!isChild, item, pathname });
const shouldDisplayNavigationItem = useShouldDisplayNavigationItem(props.item);
const shouldDisplayNavigationItem = useShouldDisplayNavigationItem(
props.item
);
const [isExpanded, setIsExpanded] = usePersistedExpansionState(item.name);
const isTablet = useMediaQuery("(max-width: 1024px)");
@@ -88,8 +102,12 @@ export const NavigationItem: React.FC<{
const hasChildren = item.child && item.child.length > 0;
const hasActiveChild =
hasChildren && item.child?.some((child) => isCurrent({ isChild: true, item: child, pathname }));
const shouldShowChildren = isExpanded || hasActiveChild || isCurrent({ pathname, isChild, item });
hasChildren &&
item.child?.some((child) =>
isCurrent({ isChild: true, item: child, pathname })
);
const shouldShowChildren =
isExpanded || hasActiveChild || isCurrent({ pathname, isChild, item });
const shouldShowChevron = hasChildren && !hasActiveChild;
const isParentNavigationItem = hasChildren && !isChild;
@@ -109,8 +127,16 @@ export const NavigationItem: React.FC<{
{item.child?.map((childItem) => {
const childIsCurrent =
typeof childItem.isCurrent === "function"
? childItem.isCurrent({ isChild: true, item: childItem, pathname })
: defaultIsCurrent({ isChild: true, item: childItem, pathname });
? childItem.isCurrent({
isChild: true,
item: childItem,
pathname,
})
: defaultIsCurrent({
isChild: true,
item: childItem,
pathname,
});
return (
<Link
key={childItem.name}
@@ -125,7 +151,8 @@ export const NavigationItem: React.FC<{
childIsCurrent
? "bg-emphasis text-white"
: "hover:bg-emphasis text-mute hover:text-emphasis"
)}>
)}
>
{t(childItem.name)}
</Link>
);
@@ -136,7 +163,8 @@ export const NavigationItem: React.FC<{
t(item.name)
)
}
className="lg:hidden">
className="lg:hidden"
>
<button
data-test-id={item.name}
aria-label={t(item.name)}
@@ -155,7 +183,8 @@ export const NavigationItem: React.FC<{
isLocaleReady
? "hover:bg-subtle todesktop:[&[aria-current='page']]:bg-emphasis todesktop:hover:bg-transparent hover:text-emphasis"
: ""
)}>
)}
>
{item.icon && (
<Icon
name={item.isLoading ? "rotate-cw" : item.icon}
@@ -169,7 +198,8 @@ export const NavigationItem: React.FC<{
{isLocaleReady ? (
<span
className="hidden w-full justify-between truncate text-ellipsis lg:flex"
data-testid={`${item.name}-test`}>
data-testid={`${item.name}-test`}
>
{t(item.name)}
{item.badge && item.badge}
</span>
@@ -177,7 +207,10 @@ export const NavigationItem: React.FC<{
<SkeletonText className="h-[20px] w-full" />
)}
{shouldShowChevron && (
<Icon name={isExpanded ? "chevron-up" : "chevron-down"} className="ml-auto h-4 w-4" />
<Icon
name={isExpanded ? "chevron-up" : "chevron-down"}
className="ml-auto h-4 w-4"
/>
)}
</button>
</Tooltip>
@@ -196,14 +229,17 @@ export const NavigationItem: React.FC<{
: `[&[aria-current='page']]:bg-emphasis`,
isChild
? `[&[aria-current='page']]:text-emphasis [&[aria-current='page']]:bg-emphasis hidden h-8 pl-16 lg:flex lg:pl-11 ${
props.index === 0 ? "mt-0" : "mt-1 hover:mt-1 [&[aria-current='page']]:mt-1"
props.index === 0
? "mt-0"
: "mt-1 hover:mt-1 [&[aria-current='page']]:mt-1"
}`
: "[&[aria-current='page']]:text-emphasis mt-0.5 text-sm",
isLocaleReady
? "hover:bg-subtle todesktop:[&[aria-current='page']]:bg-emphasis todesktop:hover:bg-transparent hover:text-emphasis"
: ""
)}
aria-current={current ? "page" : undefined}>
aria-current={current ? "page" : undefined}
>
{item.icon && (
<Icon
name={item.isLoading ? "rotate-cw" : item.icon}
@@ -218,24 +254,22 @@ export const NavigationItem: React.FC<{
{isLocaleReady ? (
<span
className="hidden w-full justify-between truncate text-ellipsis lg:flex"
data-testid={`${item.name}-test`}>
data-testid={`${item.name}-test`}
>
{t(item.name)}
{item.badge && item.badge}
</span>
) : (
<SkeletonText className="h-[20px] w-full" />
)}
{item.name === "workflows" && (
<Badge startIcon="sparkles" variant="purple">
Cal.ai
</Badge>
)}
</Link>
</Tooltip>
)}
{item.child &&
shouldShowChildren &&
item.child.map((item, index) => <NavigationItem index={index} key={item.name} item={item} isChild />)}
item.child.map((item, index) => (
<NavigationItem index={index} key={item.name} item={item} isChild />
))}
</Fragment>
);
};
@@ -247,9 +281,12 @@ export const MobileNavigationItem: React.FC<{
const { item, isChild } = props;
const pathname = usePathname();
const { t, isLocaleReady } = useLocale();
const isCurrent: NavigationItemType["isCurrent"] = item.isCurrent || defaultIsCurrent;
const isCurrent: NavigationItemType["isCurrent"] =
item.isCurrent || defaultIsCurrent;
const current = isCurrent({ isChild: !!isChild, item, pathname });
const shouldDisplayNavigationItem = useShouldDisplayNavigationItem(props.item);
const shouldDisplayNavigationItem = useShouldDisplayNavigationItem(
props.item
);
if (!shouldDisplayNavigationItem) return null;
return (
@@ -258,7 +295,8 @@ export const MobileNavigationItem: React.FC<{
href={item.href}
target={item.target}
className="[&[aria-current='page']]:text-emphasis hover:text-default text-muted bg-transparent! relative my-2 min-w-0 flex-1 overflow-hidden rounded-md p-1 text-center text-xs font-medium focus:z-10 sm:text-sm"
aria-current={current ? "page" : undefined}>
aria-current={current ? "page" : undefined}
>
{item.badge && <div className="absolute right-1 top-1">{item.badge}</div>}
{item.icon && (
<Icon
@@ -268,7 +306,11 @@ export const MobileNavigationItem: React.FC<{
aria-current={current ? "page" : undefined}
/>
)}
{isLocaleReady ? <span className="block truncate">{t(item.name)}</span> : <SkeletonText />}
{isLocaleReady ? (
<span className="block truncate">{t(item.name)}</span>
) : (
<SkeletonText />
)}
</Link>
);
};
@@ -279,7 +321,9 @@ export const MobileNavigationMoreItem: React.FC<{
}> = (props) => {
const { item } = props;
const { t, isLocaleReady } = useLocale();
const shouldDisplayNavigationItem = useShouldDisplayNavigationItem(props.item);
const shouldDisplayNavigationItem = useShouldDisplayNavigationItem(
props.item
);
const [isExpanded, setIsExpanded] = usePersistedExpansionState(item.name);
if (!shouldDisplayNavigationItem) return null;
@@ -292,14 +336,22 @@ export const MobileNavigationMoreItem: React.FC<{
<>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="hover:bg-subtle flex w-full items-center justify-between p-5 text-left transition">
className="hover:bg-subtle flex w-full items-center justify-between p-5 text-left transition"
>
<span className="text-default flex items-center font-semibold">
{item.icon && (
<Icon name={item.icon} className="h-5 w-5 shrink-0 ltr:mr-3 rtl:ml-3" aria-hidden="true" />
<Icon
name={item.icon}
className="h-5 w-5 shrink-0 ltr:mr-3 rtl:ml-3"
aria-hidden="true"
/>
)}
{isLocaleReady ? t(item.name) : <SkeletonText />}
</span>
<Icon name={isExpanded ? "chevron-up" : "chevron-down"} className="text-subtle h-5 w-5" />
<Icon
name={isExpanded ? "chevron-up" : "chevron-down"}
className="text-subtle h-5 w-5"
/>
</button>
{isExpanded && item.child && (
<ul className="bg-subtle">
@@ -307,7 +359,8 @@ export const MobileNavigationMoreItem: React.FC<{
<li key={childItem.name} className="border-subtle border-t">
<Link
href={childItem.href}
className="hover:bg-cal-muted flex items-center p-4 pl-12 transition">
className="hover:bg-cal-muted flex items-center p-4 pl-12 transition"
>
<span className="text-default font-medium">
{isLocaleReady ? t(childItem.name) : <SkeletonText />}
</span>
@@ -318,10 +371,17 @@ export const MobileNavigationMoreItem: React.FC<{
)}
</>
) : (
<Link href={item.href} className="hover:bg-subtle flex items-center justify-between p-5 transition">
<Link
href={item.href}
className="hover:bg-subtle flex items-center justify-between p-5 transition"
>
<span className="text-default flex items-center font-semibold ">
{item.icon && (
<Icon name={item.icon} className="h-5 w-5 shrink-0 ltr:mr-3 rtl:ml-3" aria-hidden="true" />
<Icon
name={item.icon}
className="h-5 w-5 shrink-0 ltr:mr-3 rtl:ml-3"
aria-hidden="true"
/>
)}
{isLocaleReady ? t(item.name) : <SkeletonText />}
</span>
@@ -6,7 +6,13 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Avatar } from "@calcom/ui/components/avatar";
import { Sheet, SheetContent, SheetBody, SheetHeader, SheetFooter } from "@calcom/ui/components/sheet";
import {
Sheet,
SheetContent,
SheetBody,
SheetHeader,
SheetFooter,
} from "@calcom/ui/components/sheet";
import { Loader } from "@calcom/ui/components/skeleton";
import type { UserTableAction, UserTableState } from "../types";
@@ -36,15 +42,19 @@ export function EditUserSheet({
const { t } = useLocale();
const { user: selectedUser } = state.editSheet;
const orgBranding = useOrgBranding();
const [editMode, setEditMode] = useEditMode((state) => [state.editMode, state.setEditMode], shallow);
const { data: loadedUser, isPending } = trpc.viewer.organizations.getUser.useQuery(
{
userId: selectedUser?.id,
},
{
enabled: !!selectedUser?.id,
}
const [editMode, setEditMode] = useEditMode(
(state) => [state.editMode, state.setEditMode],
shallow
);
const { data: loadedUser, isPending } =
trpc.viewer.organizations.getUser.useQuery(
{
userId: selectedUser?.id,
},
{
enabled: !!selectedUser?.id,
}
);
const { data: usersAttributes, isPending: usersAttributesPending } =
trpc.viewer.attributes.getByUserId.useQuery(
@@ -57,11 +67,15 @@ export function EditUserSheet({
}
);
const avatarURL = `${orgBranding?.fullDomain ?? WEBAPP_URL}/${loadedUser?.username}/avatar.png`;
const avatarURL = `${orgBranding?.fullDomain ?? WEBAPP_URL}/${
loadedUser?.username
}/avatar.png`;
const schedulesNames = loadedUser?.schedules && loadedUser?.schedules.map((s) => s.name);
const schedulesNames =
loadedUser?.schedules && loadedUser?.schedules.map((s) => s.name);
const teamNames =
loadedUser?.teams && loadedUser?.teams.map((t) => `${t.name} ${!t.accepted ? "(pending)" : ""}`);
loadedUser?.teams &&
loadedUser?.teams.map((t) => `${t.name} ${!t.accepted ? "(pending)" : ""}`);
return (
<Sheet
@@ -69,19 +83,25 @@ export function EditUserSheet({
onOpenChange={() => {
setEditMode(false);
dispatch({ type: "CLOSE_MODAL" });
}}>
}}
>
<SheetContent className="bg-default">
{!isPending && loadedUser ? (
<>
{!editMode ? (
<>
<SheetHeader showCloseButton={false} className="w-full">
<div className="border-sublte bg-default w-full rounded-xl border p-4">
<div className="border-subtle bg-default w-full rounded-xl border p-4">
<OrganizationBanner />
<div className="bg-default ml-3 w-fit translate-y-[-50%] rounded-full p-1 ring-1 ring-[#0000000F]">
<Avatar asChild size="lg" alt={`${loadedUser?.name} avatar`} imageSrc={avatarURL} />
<Avatar
asChild
size="lg"
alt={`${loadedUser?.name} avatar`}
imageSrc={avatarURL}
/>
</div>
<h2 className="text-emphasis font-sans text-2xl font-semibold">
<h2 className="text-emphasis font-sans text-2xl font-semibold -mt-8">
{loadedUser?.name || "Nameless User"}
</h2>
<p className="text-subtle max-h-[3em] overflow-hidden text-ellipsis text-sm font-normal">
@@ -91,49 +111,79 @@ export function EditUserSheet({
</SheetHeader>
<SheetBody className="stack-y-4 flex flex-col p-4">
<div className="stack-y-4 mb-4 flex flex-col">
<h3 className="text-emphasis mb-1 text-base font-semibold">{t("profile")}</h3>
<h3 className="text-emphasis mb-1 text-base font-semibold">
{t("profile")}
</h3>
<DisplayInfo
label="Cal"
value={removeProtocol(
`${orgBranding?.fullDomain ?? WEBAPP_URL}/${loadedUser?.username}`
`${orgBranding?.fullDomain ?? WEBAPP_URL}/${
loadedUser?.username
}`
)}
icon="external-link"
/>
<DisplayInfo label={t("email")} value={loadedUser?.email ?? ""} icon="at-sign" />
<DisplayInfo label={t("role")} value={[loadedUser?.role ?? ""]} icon="fingerprint" />
<DisplayInfo label={t("timezone")} value={loadedUser?.timeZone ?? ""} icon="clock" />
<DisplayInfo
label={t("email")}
value={loadedUser?.email ?? ""}
icon="at-sign"
/>
<DisplayInfo
label={t("role")}
value={[loadedUser?.role ?? ""]}
icon="fingerprint"
/>
<DisplayInfo
label={t("timezone")}
value={loadedUser?.timeZone ?? ""}
icon="clock"
/>
<DisplayInfo
label={t("teams")}
value={!teamNames || teamNames.length === 0 ? "" : teamNames}
value={
!teamNames || teamNames.length === 0 ? "" : teamNames
}
icon="users"
coloredBadges
/>
<DisplayInfo
label={t("availability")}
value={!schedulesNames || schedulesNames.length === 0 ? "" : schedulesNames}
value={
!schedulesNames || schedulesNames.length === 0
? ""
: schedulesNames
}
icon="calendar"
/>
</div>
{canViewAttributes && usersAttributes && usersAttributes?.length > 0 && (
<div className="mt-4 flex flex-col">
<h3 className="text-emphasis mb-5 text-base font-semibold">{t("attributes")}</h3>
<div className="stack-y-4 flex flex-col">
{usersAttributes.map((attribute, index) => (
<>
<DisplayInfo
key={index}
label={attribute.name}
value={
["TEXT", "NUMBER", "SINGLE_SELECT"].includes(attribute.type)
? attribute.options[0].value
: attribute.options.map((option) => option.value)
}
/>
</>
))}
{canViewAttributes &&
usersAttributes &&
usersAttributes?.length > 0 && (
<div className="mt-4 flex flex-col">
<h3 className="text-emphasis mb-5 text-base font-semibold">
{t("attributes")}
</h3>
<div className="stack-y-4 flex flex-col">
{usersAttributes.map((attribute, index) => (
<>
<DisplayInfo
key={index}
label={attribute.name}
value={
["TEXT", "NUMBER", "SINGLE_SELECT"].includes(
attribute.type
)
? attribute.options[0].value
: attribute.options.map(
(option) => option.value
)
}
/>
</>
))}
</div>
</div>
</div>
)}
)}
</SheetBody>
<SheetFooter>
<SheetFooterControls
@@ -95,7 +95,10 @@ const initalColumnVisibility = {
actions: true,
};
function reducer(state: UserTableState, action: UserTableAction): UserTableState {
function reducer(
state: UserTableState,
action: UserTableAction
): UserTableState {
switch (action.type) {
case "SET_CHANGE_MEMBER_ROLE_ID":
return { ...state, changeMemberRole: action.payload };
@@ -143,7 +146,11 @@ function UserListTable(props: UserListTableProps): JSX.Element | null {
const pathname = usePathname();
if (!pathname) return null;
return (
<DataTableProvider tableIdentifier={pathname} useSegments={useSegments} defaultPageSize={25}>
<DataTableProvider
tableIdentifier={pathname}
useSegments={useSegments}
defaultPageSize={25}
>
<UserListTableContent {...props} />
</DataTableProvider>
);
@@ -305,12 +312,13 @@ function UserListTableContent({
size: 200,
header: t("members"),
cell: ({ row }: CellContext<UserTableUser, unknown>) => {
const { username, email, avatarUrl } = row.original;
const { username, name, email, avatarUrl } = row.original;
const displayName = name || username || "No username";
return (
<div className="flex items-center gap-2">
<Avatar
size="sm"
alt={username || email}
alt={displayName}
imageSrc={getUserAvatarUrl({
avatarUrl,
})}
@@ -318,12 +326,14 @@ function UserListTableContent({
<div className="">
<div
data-testid={`member-${username}-username`}
className="text-emphasis text-sm font-medium leading-none">
{username || "No username"}
className="text-emphasis text-sm font-medium leading-none"
>
{displayName}
</div>
<div
data-testid={`member-${username}-email`}
className="text-subtle mt-1 text-sm leading-none">
className="text-subtle mt-1 text-sm leading-none"
>
{email}
</div>
</div>
@@ -352,7 +362,8 @@ function UserListTableContent({
variant={roleVariant}
onClick={() => {
table.getColumn("role")?.setFilterValue([role]);
}}>
}}
>
{roleName}
</Badge>
);
@@ -379,7 +390,8 @@ function UserListTableContent({
data-testid={`email-${email.replace("@", "")}-pending`}
onClick={() => {
table.getColumn("role")?.setFilterValue(["PENDING"]);
}}>
}}
>
{t("pending")}
</Badge>
)}
@@ -490,7 +502,8 @@ function UserListTableContent({
const permissionsForUser = {
canEdit:
((permissionsRaw.canEdit ?? false) || (permissions?.canEditAttributesForUser ?? false)) &&
((permissionsRaw.canEdit ?? false) ||
(permissions?.canEditAttributesForUser ?? false)) &&
user.accepted &&
!isSelf,
canRemove: (permissionsRaw.canRemove ?? false) && !isSelf,
@@ -501,7 +514,8 @@ function UserListTableContent({
!!org?.canAdminImpersonate &&
(permissionsRaw.canImpersonate ?? false),
canLeave: user.accepted && isSelf,
canResendInvitation: (permissionsRaw.canResendInvitation ?? false) && !user.accepted,
canResendInvitation:
(permissionsRaw.canResendInvitation ?? false) && !user.accepted,
};
return (
@@ -559,7 +573,9 @@ function UserListTableContent({
}))
);
default: {
const attribute = facetedTeamValues.attributes.find((attr) => attr.id === columnId);
const attribute = facetedTeamValues.attributes.find(
(attr) => attr.id === columnId
);
if (attribute) {
return convertFacetedValuesToMap(
attribute?.options.map(({ value }) => ({
@@ -619,12 +635,19 @@ function UserListTableContent({
}
const ATTRIBUTE_IDS = attributes?.map((attr) => attr.id) ?? [];
const csvRaw = generateCsvRawForMembersTable(headers, allRows, ATTRIBUTE_IDS, domain);
const csvRaw = generateCsvRawForMembersTable(
headers,
allRows,
ATTRIBUTE_IDS,
domain
);
if (!csvRaw) {
throw new Error("Generating CSV file failed.");
}
const filename = `${org.name}_${new Date().toISOString().split("T")[0]}.csv`;
const filename = `${org.name}_${
new Date().toISOString().split("T")[0]
}.csv`;
downloadAsCsv(csvRaw, filename);
} catch (error) {
showToast(`Error: ${error}`, "error");
@@ -654,7 +677,8 @@ function UserListTableContent({
<DataTableSegment.SaveButton />
<DataTableSegment.Select />
</>
}>
}
>
{numberOfSelectedRows >= 2 && dynamicLinkVisible && (
<DataTableSelectionBar.Root className="bottom-[7.3rem]! md:bottom-32!">
<DynamicLink table={table} domain={domain} />
@@ -667,17 +691,23 @@ function UserListTableContent({
</p>
{!isPlatformUser && (
<>
{permissions?.canChangeMemberRole && <TeamListBulkAction table={table} />}
{permissions?.canChangeMemberRole && (
<TeamListBulkAction table={table} />
)}
{numberOfSelectedRows >= 2 && (
<DataTableSelectionBar.Button
color="secondary"
onClick={() => setDynamicLinkVisible(!dynamicLinkVisible)}
icon="handshake">
icon="handshake"
>
{t("group_meeting")}
</DataTableSelectionBar.Button>
)}
{(permissions?.canEditAttributesForUser ?? adminOrOwner) && (
<MassAssignAttributesBulkAction table={table} filters={columnFilters} />
<MassAssignAttributesBulkAction
table={table}
filters={columnFilters}
/>
)}
{(permissions?.canChangeMemberRole ?? adminOrOwner) && (
<EventTypesList table={table} orgTeams={teams} />
@@ -686,7 +716,9 @@ function UserListTableContent({
)}
{(permissions?.canRemove ?? adminOrOwner) && (
<DeleteBulkUsers
users={table.getSelectedRowModel().flatRows.map((row) => row.original)}
users={table
.getSelectedRowModel()
.flatRows.map((row) => row.original)}
onRemove={() => table.toggleAllPageRowsSelected(false)}
/>
)}
@@ -694,10 +726,18 @@ function UserListTableContent({
)}
</DataTableWrapper>
{state.deleteMember.showModal && <DeleteMemberModal state={state} dispatch={dispatch} />}
{state.inviteMember.showModal && <InviteMemberModal dispatch={dispatch} />}
{state.impersonateMember.showModal && <ImpersonationMemberModal dispatch={dispatch} state={state} />}
{state.changeMemberRole.showModal && <ChangeUserRoleModal dispatch={dispatch} state={state} />}
{state.deleteMember.showModal && (
<DeleteMemberModal state={state} dispatch={dispatch} />
)}
{state.inviteMember.showModal && (
<InviteMemberModal dispatch={dispatch} />
)}
{state.impersonateMember.showModal && (
<ImpersonationMemberModal dispatch={dispatch} state={state} />
)}
{state.changeMemberRole.showModal && (
<ChangeUserRoleModal dispatch={dispatch} state={state} />
)}
{state.editSheet.showModal && (
<EditUserSheet
dispatch={dispatch}
@@ -717,7 +757,8 @@ function UserListTableContent({
StartIcon="file-down"
loading={isDownloading}
onClick={() => handleDownload()}
data-testid="export-members-button">
data-testid="export-members-button"
>
{t("download")}
</DataTableToolbar.CTA>
{(permissions?.canInvite ?? adminOrOwner) && (
@@ -734,7 +775,8 @@ function UserListTableContent({
});
posthog.capture("add_organization_member_clicked");
}}
data-testid="new-organization-member-button">
data-testid="new-organization-member-button"
>
{t("add")}
</DataTableToolbar.CTA>
)}
@@ -61,6 +61,7 @@ describe("generate Csv for Org Users Table", () => {
const mockUser: UserTableUser = {
id: 1,
username: "testuser",
name: "Test User",
email: "test@example.com",
timeZone: "UTC",
role: MembershipRole.MEMBER,
+4 -5
View File
@@ -223,7 +223,7 @@ test.describe("Event Types tests", () => {
await gotoFirstEventType(page);
await page.getByTestId("location-select").click();
await page.locator(`text="Organizer Phone Number"`).click();
await page.getByTestId("location-select-item-userPhone").click();
const locationInputName = "locations[0].hostPhoneNumber";
await page.locator(`input[name="${locationInputName}"]`).waitFor();
await page.locator(`input[name="${locationInputName}"]`).clear();
@@ -367,7 +367,7 @@ test.describe("Event Types tests", () => {
// Add Multiple Organizer Phone Number options
await page.getByTestId("location-select").last().click();
await page.locator(`text="Organizer Phone Number"`).click();
await page.getByTestId("location-select-item-userPhone").click();
const organizerPhoneNumberInputName = (idx: number) => `locations[${idx}].hostPhoneNumber`;
@@ -380,7 +380,7 @@ test.describe("Event Types tests", () => {
await page.locator("[data-testid=add-location]").click();
const testPhoneInputValue2 = "9188888888";
await page.locator(`text="Organizer Phone Number"`).last().click();
await page.getByTestId("location-select-item-userPhone").last().click();
await page.locator(`input[name="${organizerPhoneNumberInputName(1)}"]`).waitFor();
await page.locator(`input[name="${organizerPhoneNumberInputName(1)}"]`).fill(testPhoneInputValue2);
await checkDisplayLocation(page);
@@ -629,9 +629,8 @@ test.describe("Event Types tests", () => {
});
const selectAttendeePhoneNumber = async (page: Page) => {
const locationOptionText = "Attendee Phone Number";
await page.getByTestId("location-select").click();
await page.locator(`text=${locationOptionText}`).click();
await page.getByTestId("location-select-item-phone").click();
};
/**
+14 -14
View File
@@ -254,20 +254,20 @@ test.describe("Insights", async () => {
const expectedChartTitles = [
"Events",
"Performance",
"Event Trends",
"Bookings by Hour",
"Average Event Duration",
"Most Bookings Scheduled",
"Least Bookings Scheduled",
"Most Bookings Completed",
"Least Bookings Completed",
"Most Cancelled",
"Most No-Show",
"Recent No-Show Guests",
"Highest Rated",
"Lowest Rated",
"Recent Ratings",
"Popular Events",
"Event trends",
"Bookings by hour",
"Average event duration",
"Most bookings scheduled",
"Least bookings scheduled",
"Most bookings completed",
"Least bookings completed",
"Most cancelled",
"Most no-show",
"Recent no-show guests",
"Highest rated",
"Lowest rated",
"Recent ratings",
"Popular events",
];
for (const title of expectedChartTitles) {
@@ -759,7 +759,7 @@ test.describe("Text area min and max characters text", () => {
await element.click();
const locatorForSelect = page.locator("[id=test-field-type]").nth(0);
await locatorForSelect.click();
await locatorForSelect.locator(`text="Long Text"`).click();
await page.getByTestId("select-option-textarea").click();
await page.fill('[name="name"]', questionName);
await page.fill('[name="label"]', questionName);
File diff suppressed because it is too large Load Diff
@@ -23,7 +23,7 @@ export const EmptyState = ({
buttonDataTestId,
}: EmptyStateProps) => {
return (
<div className="border-sublte bg-cal-muted flex flex-col items-center gap-6 rounded-xl border p-11">
<div className="border-subtle bg-cal-muted flex flex-col items-center gap-6 rounded-xl border p-11">
<div className="mb-3 grid">
{/* Icon card - Top */}
<div className="bg-default border-subtle z-30 col-start-1 col-end-1 row-start-1 row-end-1 h-10 w-10 transform rounded-md border shadow-sm">
@@ -47,7 +47,9 @@ export const EmptyState = ({
/>
</div>
<div className="w-full">
<h1 className="text-emphasis line-clamp-1 text-center text-lg font-semibold">{header}</h1>
<h1 className="text-emphasis line-clamp-1 text-center text-lg font-semibold">
{header}
</h1>
<p className="mt-2 line-clamp-1 text-center text-sm leading-normal transition-all duration-200 ease-in-out hover:line-clamp-none">
{text}
</p>
@@ -56,7 +58,8 @@ export const EmptyState = ({
data-testid={buttonDataTestId}
StartIcon={buttonStartIcon}
onClick={buttonOnClick}
className={buttonClassName}>
className={buttonClassName}
>
{buttonText}
</Button>
</div>
@@ -22,7 +22,7 @@ export const isValidRoutingFormFieldType = (type: string): type is RoutingFormFi
export const FieldTypes = [
{
label: "Short Text",
label: "Short text",
value: RoutingFormFieldType.TEXT,
},
{
@@ -30,15 +30,15 @@ export const FieldTypes = [
value: RoutingFormFieldType.NUMBER,
},
{
label: "Long Text",
label: "Long text",
value: RoutingFormFieldType.TEXTAREA,
},
{
label: "Single Selection",
label: "Single-choice selection",
value: RoutingFormFieldType.SINGLE_SELECT,
},
{
label: "Multiple Selection",
label: "Multiple choice selection",
value: RoutingFormFieldType.MULTI_SELECT,
},
{
@@ -3,15 +3,15 @@ import { RouteActionType } from "../zod";
export const RoutingPages: { label: string; value: NonNullable<LocalRoute["action"]>["type"] }[] = [
{
label: "Custom Page",
label: "Custom page",
value: RouteActionType.CustomPageMessage,
},
{
label: "External Redirect",
label: "External redirect",
value: RouteActionType.ExternalRedirectUrl,
},
{
label: "Event Redirect",
label: "Event redirect",
value: RouteActionType.EventTypeRedirectUrl,
},
];
@@ -271,11 +271,11 @@ test.describe("Routing Forms", () => {
label: "Test Field",
});
const queryString =
"firstField=456&Test-Field-Number=456&Test-Field-Single-Selection=456&Test-Field-Multiple-Selection=456&Test-Field-Multiple-Selection=789&Test-Field-Phone=456&Test-Field-Email=456@example.com";
"firstField=456&Test-Field-Number=456&Test-Field-Single-choice-selection=456&Test-Field-Multiple-choice-selection=456&Test-Field-Multiple-choice-selection=789&Test-Field-Phone=456&Test-Field-Email=456@example.com";
await gotoRoutingLink({ page, queryString });
await page.fill('[data-testid="form-field-Test-Field-Long-Text"]', "manual-fill");
await page.fill('[data-testid="form-field-Test-Field-Long-text"]', "manual-fill");
await expect(page.locator('[data-testid="form-field-firstField"]')).toHaveValue("456");
await expect(page.locator('[data-testid="form-field-Test-Field-Number"]')).toHaveValue("456");
@@ -301,9 +301,9 @@ test.describe("Routing Forms", () => {
// All other params come from prefill URL
expect(url.searchParams.get("Test-Field-Number")).toBe("456");
expect(url.searchParams.get("Test-Field-Long-Text")).toBe("manual-fill");
expect(url.searchParams.get("Test-Field-Multiple-Selection")).toBe("456");
expect(url.searchParams.getAll("Test-Field-Multiple-Selection")).toMatchObject(["456", "789"]);
expect(url.searchParams.get("Test-Field-Long-text")).toBe("manual-fill");
expect(url.searchParams.get("Test-Field-Multiple-choice-selection")).toBe("456");
expect(url.searchParams.getAll("Test-Field-Multiple-choice-selection")).toMatchObject(["456", "789"]);
expect(url.searchParams.get("Test-Field-Phone")).toBe("456");
expect(url.searchParams.get("Test-Field-Email")).toBe("456@example.com");
});
@@ -1060,7 +1060,7 @@ async function addAllTypesOfFieldsAndSaveForm(
const { optionsInUi: fieldTypesList } = await verifySelectOptions(
{ selector: ".data-testid-field-type", nth: 0 },
["Email", "Long Text", "Multiple Selection", "Number", "Phone", "Single Selection", "Short Text"],
["Email", "Long text", "Multiple choice selection", "Number", "Phone", "Single-choice selection", "Short text"],
page
);
@@ -1083,7 +1083,7 @@ async function addAllTypesOfFieldsAndSaveForm(
identifier = "firstField";
}
if (fieldTypeLabel === "Multiple Selection" || fieldTypeLabel === "Single Selection") {
if (fieldTypeLabel === "Multiple choice selection" || fieldTypeLabel === "Single-choice selection") {
await page.fill(`[data-testid="fields.${nth}.options.0-input"]`, "123");
await page.fill(`[data-testid="fields.${nth}.options.1-input"]`, "456");
await page.fill(`[data-testid="fields.${nth}.options.2-input"]`, "789");
@@ -46,7 +46,7 @@ export async function addOneFieldAndDescriptionAndSaveForm(
// Verify all Options of SelectBox
const { optionsInUi: types } = await verifySelectOptions(
{ selector: ".data-testid-field-type", nth: 0 },
["Email", "Long Text", "Multiple Selection", "Number", "Phone", "Single Selection", "Short Text"],
["Email", "Long text", "Multiple choice selection", "Number", "Phone", "Single-choice selection", "Short text"],
page
);
@@ -243,6 +243,7 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
select: {
id: true,
username: true,
name: true,
profiles: {
select: {
organizationId: true,
@@ -313,6 +314,7 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
return {
id: user.id,
username: user.profiles[0]?.username || user.username,
name: user.name,
email: user.email,
timeZone: user.timeZone,
role: membership.role,
@@ -20,7 +20,9 @@ export type ConfirmationDialogContentProps = {
variety?: "danger" | "warning" | "success";
} & ConfirmBtnType;
export function ConfirmationDialogContent(props: PropsWithChildren<ConfirmationDialogContentProps>) {
export function ConfirmationDialogContent(
props: PropsWithChildren<ConfirmationDialogContentProps>
) {
return (
<DialogContent type="creation">
<ConfirmationContent {...props} />
@@ -28,7 +30,9 @@ export function ConfirmationDialogContent(props: PropsWithChildren<ConfirmationD
);
}
export const ConfirmationContent = (props: PropsWithChildren<ConfirmationDialogContentProps>) => {
export const ConfirmationContent = (
props: PropsWithChildren<ConfirmationDialogContentProps>
) => {
const { t } = useLocale();
const {
title,
@@ -49,7 +53,10 @@ export const ConfirmationContent = (props: PropsWithChildren<ConfirmationDialogC
<div className="mt-0.5 ltr:mr-3">
{variety === "danger" && (
<div className="bg-error mx-auto rounded-full p-2 text-center">
<Icon name="circle-alert" className="h-5 w-5 text-red-600 dark:text-red-100" />
<Icon
name="circle-alert"
className="h-5 w-5 text-red-600 dark:text-red-100"
/>
</div>
)}
{variety === "warning" && (
@@ -65,7 +72,7 @@ export const ConfirmationContent = (props: PropsWithChildren<ConfirmationDialogC
</div>
)}
<div className="w-full">
<DialogPrimitive.Title className="font-cal text-emphasis mt-2 text-xl">
<DialogPrimitive.Title className="font-cal text-emphasis mt-1 text-xl">
{title}
</DialogPrimitive.Title>
<DialogPrimitive.Description asChild>
@@ -81,7 +88,8 @@ export const ConfirmationContent = (props: PropsWithChildren<ConfirmationDialogC
color="primary"
loading={isPending}
onClick={(e) => onConfirm && onConfirm(e)}
data-testid="dialog-confirmation">
data-testid="dialog-confirmation"
>
{isPending ? loadingText : confirmBtnText}
</DialogClose>
)}
+164 -132
View File
@@ -61,7 +61,11 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
<input
{...props}
ref={ref}
className={classNames(inputStyles({ size }), isFullWidth && "w-full", className)}
className={classNames(
inputStyles({ size }),
isFullWidth && "w-full",
className
)}
/>
);
});
@@ -87,161 +91,189 @@ const Addon = ({
onClick={onClickAddon && onClickAddon}
className={classNames(
"flex shrink-0 items-center justify-center whitespace-nowrap",
onClickAddon && "pointer-events-auto cursor-pointer disabled:hover:cursor-not-allowed",
onClickAddon &&
"pointer-events-auto cursor-pointer disabled:hover:cursor-not-allowed",
className
)}>
)}
>
<span
className={classNames(
"text-sm font-medium leading-none",
error ? "text-error" : "text-muted peer-disabled:opacity-50"
)}>
)}
>
{children}
</span>
</div>
);
export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function InputField(props, ref) {
const id = useId();
const { t: _t, isLocaleReady, i18n } = useLocale();
const t = props.t || _t;
const name = props.name || "";
const {
label = t(name),
labelProps,
labelClassName,
disabled,
LockedIcon,
placeholder = isLocaleReady && i18n.exists(`${name}_placeholder`) ? t(`${name}_placeholder`) : "",
className,
addOnLeading,
addOnSuffix,
addOnClassname,
inputIsFullWidth,
hint,
type,
hintErrors,
labelSrOnly,
noLabel,
containerClassName,
readOnly,
showAsteriskIndicator,
onClickAddon,
export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(
function InputField(props, ref) {
const id = useId();
const { t: _t, isLocaleReady, i18n } = useLocale();
const t = props.t || _t;
const name = props.name || "";
const {
label = t(name),
labelProps,
labelClassName,
disabled,
LockedIcon,
placeholder = isLocaleReady && i18n.exists(`${name}_placeholder`)
? t(`${name}_placeholder`)
: "",
className,
addOnLeading,
addOnSuffix,
addOnClassname,
inputIsFullWidth,
hint,
type,
hintErrors,
labelSrOnly,
noLabel,
containerClassName,
readOnly,
showAsteriskIndicator,
onClickAddon,
t: __t,
dataTestid,
size,
...passThrough
} = props;
t: __t,
dataTestid,
size,
...passThrough
} = props;
const [inputValue, setInputValue] = useState<string>("");
const [inputValue, setInputValue] = useState<string>("");
const handleFocusInput = (e: React.MouseEvent<HTMLDivElement>) => {
e.currentTarget.parentElement?.querySelector("input")?.focus();
};
const handleFocusInput = (e: React.MouseEvent<HTMLDivElement>) => {
e.currentTarget.parentElement?.querySelector("input")?.focus();
};
return (
<div className={classNames(containerClassName)}>
{!!name && !noLabel && (
<Label
htmlFor={id}
{...labelProps}
className={classNames(labelClassName, labelSrOnly && "sr-only", props.error && "text-error")}>
{label}
{showAsteriskIndicator && !readOnly && passThrough.required ? (
<span className="text-default ml-1 font-medium">*</span>
) : null}
{LockedIcon}
</Label>
)}
{addOnLeading || addOnSuffix ? (
<div
dir="ltr"
className={classNames(
inputStyles({ size }),
"group relative mb-1 flex min-w-0 items-center gap-1",
"focus-within:shadow-outline-gray-focused focus-within:border-emphasis",
"[&:has(:disabled)]:bg-subtle [&:has(:disabled)]:hover:border-default [&:has(:disabled)]:cursor-not-allowed",
inputIsFullWidth && "w-full"
)}>
{addOnLeading && (
<Addon
size={size ?? "md"}
position="start"
className={classNames(addOnClassname)}
onClickAddon={handleFocusInput}>
{addOnLeading}
</Addon>
)}
<input
data-testid={dataTestid ? `${dataTestid}-input` : "input-field"}
return (
<div className={classNames(containerClassName)}>
{!!name && !noLabel && (
<Label
htmlFor={id}
{...labelProps}
className={classNames(
labelClassName,
labelSrOnly && "sr-only",
props.error && "text-error"
)}
>
{label}
{showAsteriskIndicator && !readOnly && passThrough.required ? (
<span className="text-default ml-1 font-medium">*</span>
) : null}
{LockedIcon}
</Label>
)}
{addOnLeading || addOnSuffix ? (
<div
dir="ltr"
className={classNames(
inputStyles({ size }),
"group relative mb-1 flex min-w-0 items-center gap-1",
"focus-within:shadow-outline-gray-focused focus-within:border-emphasis",
"[&:has(:disabled)]:bg-subtle [&:has(:disabled)]:hover:border-default [&:has(:disabled)]:cursor-not-allowed",
inputIsFullWidth && "w-full"
)}
>
{addOnLeading && (
<Addon
size={size ?? "md"}
position="start"
className={classNames(addOnClassname)}
onClickAddon={handleFocusInput}
>
{addOnLeading}
</Addon>
)}
<input
data-testid={dataTestid ? `${dataTestid}-input` : "input-field"}
id={id}
type={type}
placeholder={placeholder}
className={classNames(
"w-full min-w-0 truncate border-0 bg-transparent focus:outline-none focus:ring-0",
"text-default rounded-lg text-sm font-medium leading-none",
"placeholder:text-muted disabled:cursor-not-allowed disabled:bg-transparent",
addOnLeading && "rounded-none pl-0.5 pr-0",
addOnSuffix && "pl-0",
className
)}
{...passThrough}
{...(type == "search" && {
onChange: (e) => {
setInputValue(e.target.value);
props.onChange?.(e);
},
value: inputValue,
})}
disabled={readOnly || disabled}
ref={ref}
/>
{addOnSuffix && (
<Addon
size={size ?? "md"}
position="end"
onClickAddon={(e) => {
handleFocusInput(e);
onClickAddon?.(e);
}}
className={classNames(addOnClassname)}
>
{addOnSuffix}
</Addon>
)}
{type === "search" && inputValue?.toString().length > 0 && (
<Icon
name="x"
className="text-subtle absolute top-2.5 h-4 w-4 cursor-pointer ltr:right-2 rtl:left-2"
onClick={(e) => {
setInputValue("");
props.onChange?.(
e as unknown as React.ChangeEvent<HTMLInputElement>
);
}}
/>
)}
</div>
) : (
<Input
id={id}
type={type}
placeholder={placeholder}
size={size}
className={classNames(
"w-full min-w-0 truncate border-0 bg-transparent focus:outline-none focus:ring-0",
"w-full min-w-0 truncate focus:outline-none focus:ring-0",
"text-default rounded-lg text-sm font-medium leading-none",
"placeholder:text-muted disabled:cursor-not-allowed disabled:bg-transparent",
"placeholder:text-muted disabled:cursor-not-allowed",
addOnLeading && "rounded-none pl-0.5 pr-0",
addOnSuffix && !addOnLeading && "pl-0.5",
className
)}
{...passThrough}
{...(type == "search" && {
onChange: (e) => {
setInputValue(e.target.value);
props.onChange?.(e);
},
value: inputValue,
})}
disabled={readOnly || disabled}
readOnly={readOnly}
ref={ref}
isFullWidth={inputIsFullWidth}
disabled={readOnly || disabled}
/>
{addOnSuffix && (
<Addon
size={size ?? "md"}
position="end"
onClickAddon={(e) => {
handleFocusInput(e);
onClickAddon?.(e);
}}
className={classNames(addOnClassname)}>
{addOnSuffix}
</Addon>
)}
{type === "search" && inputValue?.toString().length > 0 && (
<Icon
name="x"
className="text-subtle absolute top-2.5 h-4 w-4 cursor-pointer ltr:right-2 rtl:left-2"
onClick={(e) => {
setInputValue("");
props.onChange?.(e as unknown as React.ChangeEvent<HTMLInputElement>);
}}
/>
)}
</div>
) : (
<Input
id={id}
type={type}
placeholder={placeholder}
size={size}
className={classNames(
className,
"disabled:bg-subtle disabled:hover:border-subtle disabled:cursor-not-allowed"
)}
{...passThrough}
readOnly={readOnly}
ref={ref}
isFullWidth={inputIsFullWidth}
disabled={readOnly || disabled}
/>
)}
<HintsOrErrors hintErrors={hintErrors} fieldName={name} t={t} />
{hint && <div className="text-default mt-2 flex items-center text-sm">{hint}</div>}
</div>
);
});
)}
<HintsOrErrors hintErrors={hintErrors} fieldName={name} t={t} />
{hint && (
<div className="text-muted mt-2 flex items-center text-sm">
<Icon name="info" className="text-muted h-4 w-4 mr-1" />
{hint}
</div>
)}
</div>
);
}
);
export const TextField = forwardRef<HTMLInputElement, InputFieldProps>(function TextField(props, ref) {
return <InputField ref={ref} {...props} />;
});
export const TextField = forwardRef<HTMLInputElement, InputFieldProps>(
function TextField(props, ref) {
return <InputField ref={ref} {...props} />;
}
);