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:
co-authored by
unknown <>
Pedro Castro
Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
32d97fbea4
commit
7c66f33de2
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
</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)}
|
||||
|
||||
</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,
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user