From b1350d07643281a545d9f3e393858703f75955f6 Mon Sep 17 00:00:00 2001 From: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Date: Thu, 2 Jan 2025 11:32:50 +0200 Subject: [PATCH 1/4] fix: atoms globals.css invalid class format (#18295) --- packages/platform/atoms/globals.css | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/platform/atoms/globals.css b/packages/platform/atoms/globals.css index 654b310a901325..2f9bcdaf45c888 100644 --- a/packages/platform/atoms/globals.css +++ b/packages/platform/atoms/globals.css @@ -13219,23 +13219,23 @@ select { scrollbar-width: none } -.\[--booker-main-width\: 420px\] { +.\[--booker-main-width\:420px\] { --booker-main-width:420px } -.\[--booker-main-width\: 480px\] { +.\[--booker-main-width\:480px\] { --booker-main-width:480px; } -.\[--booker-meta-width\: 240px\] { +.\[--booker-meta-width\:240px\] { --booker-meta-width:240px } -.\[--booker-meta-width\: 340px\] { +.\[--booker-meta-width\:340px\] { --booker-meta-width:340px } -.\[--booker-timeslots-width\: 240px\] { +.\[--booker-timeslots-width\:240px\] { --booker-timeslots-width:240px } @@ -13259,7 +13259,7 @@ select { --cal-brand:#111827 } -.\[--calendar-dates-sticky-offset\: 66px\] { +.\[--calendar-dates-sticky-offset\:66px\] { --calendar-dates-sticky-offset:66px } @@ -13279,11 +13279,11 @@ select { --disabled-gradient-foreground:#e6e7eb } -.\[--troublehooster-meta-width\: 0px\] { +.\[--troublehooster-meta-width\:0px\] { --troublehooster-meta-width:0px } -.\[--troublehooster-meta-width\: 250px\] { +.\[--troublehooster-meta-width\:250px\] { --troublehooster-meta-width:250px } From 85f96f492dc53f2b0b97d65a601a751208836643 Mon Sep 17 00:00:00 2001 From: Benny Joo Date: Thu, 2 Jan 2025 04:48:19 -0500 Subject: [PATCH 2/4] chore: migrate /icons page to App Router (#18436) * migrate icons page * migrate main index page * remove /icons page * use i18n string * fix imports * add icons_showcase i18n string * add /app dir to tailwind preset config * use classname * do not migrate main index page --- apps/web/app/403/page.tsx | 4 +- apps/web/app/500/page.tsx | 4 +- apps/web/app/icons/IconGrid.tsx | 25 ++++++ apps/web/app/icons/page.tsx | 46 +++++++++++ apps/web/pages/icons.tsx | 77 ------------------- apps/web/public/static/locales/en/common.json | 20 +++-- packages/config/tailwind-preset.js | 1 + 7 files changed, 83 insertions(+), 94 deletions(-) create mode 100644 apps/web/app/icons/IconGrid.tsx create mode 100644 apps/web/app/icons/page.tsx delete mode 100644 apps/web/pages/icons.tsx diff --git a/apps/web/app/403/page.tsx b/apps/web/app/403/page.tsx index 3547f67c382715..51f3b2c2818f61 100644 --- a/apps/web/app/403/page.tsx +++ b/apps/web/app/403/page.tsx @@ -16,9 +16,7 @@ async function Error403() { return (
-

- 403 -

+

403

{t("dont_have_access_this_page")}

{t("you_need_admin_or_owner_privileges_to_access")} diff --git a/apps/web/app/500/page.tsx b/apps/web/app/500/page.tsx index 3decc4a8183904..867b60d51c41a8 100644 --- a/apps/web/app/500/page.tsx +++ b/apps/web/app/500/page.tsx @@ -18,9 +18,7 @@ async function Error500({ searchParams }: { searchParams: { error?: string } }) return (

-

- 500 -

+

500

{t("500_error_message")}

{t("something_went_wrong_on_our_end")}

{searchParams?.error && ( diff --git a/apps/web/app/icons/IconGrid.tsx b/apps/web/app/icons/IconGrid.tsx new file mode 100644 index 00000000000000..da6ea588058f6e --- /dev/null +++ b/apps/web/app/icons/IconGrid.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { Icon } from "@calcom/ui"; +import type { IconName } from "@calcom/ui"; + +export const IconGrid = (props: { + title: string; + icons: IconName[]; + rootClassName?: string; + iconClassName?: string; +}) => ( +
+

{props.title}

+
+ {props.icons.map((icon) => { + return ( +
+ +
{icon}
+
+ ); + })} +
+
+); diff --git a/apps/web/app/icons/page.tsx b/apps/web/app/icons/page.tsx new file mode 100644 index 00000000000000..a2ebf86a70478a --- /dev/null +++ b/apps/web/app/icons/page.tsx @@ -0,0 +1,46 @@ +import { _generateMetadata, getTranslate } from "app/_utils"; +import { Inter } from "next/font/google"; +import localFont from "next/font/local"; + +import { type IconName, IconSprites } from "@calcom/ui"; + +import { lucideIconList } from "../../../../packages/ui/components/icon/icon-list.mjs"; +import { IconGrid } from "./IconGrid"; + +const interFont = Inter({ subsets: ["latin"], variable: "--font-inter", preload: true, display: "swap" }); +const calFont = localFont({ + src: "../../fonts/CalSans-SemiBold.woff2", + variable: "--font-cal", + preload: true, + display: "swap", + weight: "600", +}); +export const generateMetadata = async () => { + return await _generateMetadata( + (t) => t("icon_showcase"), + () => "" + ); +}; +export default async function IconsPage() { + const icons = Array.from(lucideIconList).sort() as IconName[]; + const t = await getTranslate(); + + return ( +
+
+ +
+

{t("icons_showcase")}

+ + +
+
+
+ ); +} +export const dynamic = "force-static"; diff --git a/apps/web/pages/icons.tsx b/apps/web/pages/icons.tsx deleted file mode 100644 index d15a292bcaee1d..00000000000000 --- a/apps/web/pages/icons.tsx +++ /dev/null @@ -1,77 +0,0 @@ -"use client"; - -import type { InferGetStaticPropsType } from "next"; -import { Inter } from "next/font/google"; -import localFont from "next/font/local"; -import Head from "next/head"; - -import { APP_NAME } from "@calcom/lib/constants"; -import type { IconName } from "@calcom/ui"; -import { Icon, IconSprites } from "@calcom/ui"; - -import { lucideIconList } from "../../../packages/ui/components/icon/icon-list.mjs"; - -const interFont = Inter({ subsets: ["latin"], variable: "--font-inter", preload: true, display: "swap" }); -const calFont = localFont({ - src: "../fonts/CalSans-SemiBold.woff2", - variable: "--font-cal", - preload: true, - display: "swap", - weight: "600", -}); - -export const getStaticProps = async () => { - return { - props: { - icons: Array.from(lucideIconList).sort() as IconName[], - }, - }; -}; - -const IconGrid = (props: { - title: string; - icons: IconName[]; - rootClassName?: string; - iconClassName?: string; -}) => ( -
-

{props.title}

-
- {props.icons.map((icon) => { - return ( -
- -
{icon}
-
- ); - })} -
-
-); - -export default function IconsPage(props: InferGetStaticPropsType) { - return ( -
- - Icon showcase | {APP_NAME} - - - -
-

Icons showcase

- - -
-
- ); -} diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 4ea9d6c6591a88..ec7885d63aca4c 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2528,26 +2528,22 @@ "schedule_timezone_change": "Schedule timezone change", "date": "Date", "overlaps_with_existing_schedule": "This overlaps with an existing schedule. Please select a different date.", - "org_admin_no_slots|subject": "No availability found for {{name}}", "org_admin_no_slots|heading": "No availability found for {{name}}", "org_admin_no_slots|content": "Hello Organization Admins,

Please note: It has been brought to our attention that {{username}} has not had any availability when a user has visited {{username}}/{{slug}}

There’s a few reasons why this could be happening
The user does not have any calendars connected
Their schedules attached to this event are not enabled

We recommend checking their availability to resolve this.", "org_admin_no_slots|cta": "Open users availability", "organization_no_slots_notification_switch_title": "Get notifications when your team has no availability", "organization_no_slots_notification_switch_description": "Admins will get email notifications when a user tries to book a team member and is faced with 'No availability'. We trigger this email after two occurrences and remind you every 7 days per user. ", - "email_team_invite|subject|added_to_org": "{{user}} added you to the organization {{team}} on {{appName}}", "email_team_invite|subject|invited_to_org": "{{user}} invited you to join the organization {{team}} on {{appName}}", "email_team_invite|subject|added_to_subteam": "{{user}} added you to the team {{team}} of organization {{parentTeamName}} on {{appName}}", "email_team_invite|subject|invited_to_subteam": "{{user}} invited you to join the team {{team}} of organization {{parentTeamName}} on {{appName}}", "email_team_invite|subject|invited_to_regular_team": "{{user}} invited you to join the team {{team}} on {{appName}}", - "email_team_invite|heading|added_to_org": "You’ve been added to a {{appName}} organization", "email_team_invite|heading|invited_to_org": "You’ve been invited to a {{appName}} organization", "email_team_invite|heading|added_to_subteam": "You’ve been added to a team of {{parentTeamName}} organization", "email_team_invite|heading|invited_to_subteam": "You’ve been invited to a team of {{parentTeamName}} organization", "email_team_invite|heading|invited_to_regular_team": "You’ve been invited to join a {{appName}} team", - "email_team_invite|content|added_to_org": "{{invitedBy}} has added you to the {{teamName}} organization.", "email_team_invite|content|invited_to_org": "{{invitedBy}} has invited you to join the {{teamName}} organization.", "email_team_invite|content|added_to_subteam": "{{invitedBy}} has added you to the team {{teamName}} in their organization {{parentTeamName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.", @@ -2840,12 +2836,12 @@ "routing_form_insights_booking_status": "Booking Status", "routing_form_insights_booking_at": "Booking At", "routing_form_insights_submitted_at": "Submitted At", - "total_bookings_per_period":"Total Bookings per period", - "routed_to_per_period":"Routed to per period", + "total_bookings_per_period": "Total Bookings per period", + "routed_to_per_period": "Routed to per period", "per_day": "Per day", "per_week": "Per week", "per_month": "Per month", - "routing_form_insights_assignment_reason":"Assignment Reason", + "routing_form_insights_assignment_reason": "Assignment Reason", "access_denied": "Access Denied", "salesforce_route_to_owner": "Contact owner will be the Round Robin host if available", "salesforce_do_not_route_to_owner": "Contact owner will not be forced (can still be host if it matches the attributes and Round Robin criteria)", @@ -2868,7 +2864,7 @@ "rr_distribution_method_availability_description": "Allows bookers to book meetings whenever a host is available. Use this to maximize the number of potential meetings booked and when the even distribution of meetings across hosts is less important.", "rr_distribution_method_balanced_title": "Load balancing", "rr_distribution_method_balanced_description": "We will monitor how many bookings have been made with each host and compare this with others, disabling some hosts that are too far ahead so bookings are evenly distributed.", - "require_confirmation_for_free_email": "Only require confirmation for free email providers (Ex. @gmail.com, @outlook.com)", + "require_confirmation_for_free_email": "Only require confirmation for free email providers (Ex. @gmail.com, @outlook.com)", "exclude_emails_that_contain": "Exclude emails that contain ...", "require_emails_that_contain": "Require emails that contain ...", "exclude_emails_match_found_error_message": "Please enter a valid work email address", @@ -2890,8 +2886,8 @@ "lock_attribute_for_assignment_description": "Locking would only allow assignments from Directory Sync", "attribute_edited_successfully": "Attribute edited successfully", "new_group_option": "New group option", - "attribute_weight_enabled":"Weights enabled", - "attribute_weight_enabled_description":"By enabling weights, it would be possible to assign higher priority to certain attributes per user. The higher the weight, the higher the priority.", + "attribute_weight_enabled": "Weights enabled", + "attribute_weight_enabled_description": "By enabling weights, it would be possible to assign higher priority to certain attributes per user. The higher the weight, the higher the priority.", "routed": "Routed", "reassigned": "Reassigned", "rerouted": "Rerouted", @@ -2906,5 +2902,7 @@ "something_unexpected_occurred": "Something unexpected occurred", "500_error_message": "It's not you, it's us.", "dry_run_mode_active": "You are doing a test booking.", + "icon_showcase": "Icon showcase", + "icons_showcase": "Icons showcase", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" -} +} \ No newline at end of file diff --git a/packages/config/tailwind-preset.js b/packages/config/tailwind-preset.js index c93ef3b4855e4b..6f21acbe0ff211 100644 --- a/packages/config/tailwind-preset.js +++ b/packages/config/tailwind-preset.js @@ -6,6 +6,7 @@ const subtleColor = "#E5E7EB"; module.exports = { content: [ "./pages/**/*.{js,ts,jsx,tsx}", + "./app/**/*.{js,ts,jsx,tsx}", "./modules/**/*.{js,ts,jsx,tsx}", "./components/**/*.{js,ts,jsx,tsx}", "../../packages/app-store/**/*{components,pages}/**/*.{js,ts,jsx,tsx}", From c10c87c98ec294581bf8ad98b07345ab124fcd3f Mon Sep 17 00:00:00 2001 From: Lauris Skraucis Date: Thu, 2 Jan 2025 12:31:43 +0100 Subject: [PATCH 3/4] fix: booker atom booking fields (#18441) --- .../atom-api-transformers/transformApiEventTypeForAtom.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/platform/atoms/event-types/atom-api-transformers/transformApiEventTypeForAtom.ts b/packages/platform/atoms/event-types/atom-api-transformers/transformApiEventTypeForAtom.ts index c282882b018936..1c29f1d50352aa 100644 --- a/packages/platform/atoms/event-types/atom-api-transformers/transformApiEventTypeForAtom.ts +++ b/packages/platform/atoms/event-types/atom-api-transformers/transformApiEventTypeForAtom.ts @@ -352,18 +352,21 @@ function getBookingFields( if (!hasRescheduleReasonField) { systemAfterFields.push(systemAfterFieldRescheduleReason); } + + const fieldsWithSystem = [...systemBeforeFields, ...transformedBookingFields, ...systemAfterFields]; + // note(Lauris): in web app booking form values can be passed as url query params, but booker atom does not accept booking field values via url, // so defaultFormValues act as a way to prefill booking form fields, and if the field in database has disableOnPrefill=true and value passed then its read only. const defaultFormValuesKeys = defaultFormValues ? Object.keys(defaultFormValues) : []; if (defaultFormValuesKeys.length) { - for (const field of transformedBookingFields) { + for (const field of fieldsWithSystem) { if (defaultFormValuesKeys.includes(field.name) && field.disableOnPrefill) { field.editable = "user-readonly"; } } } - return eventTypeBookingFields.brand<"HAS_SYSTEM_FIELDS">().parse(transformedBookingFields); + return eventTypeBookingFields.brand<"HAS_SYSTEM_FIELDS">().parse(fieldsWithSystem); } function isCustomField( From c415b2d7f529c5de7c28d0f91139ef6936b75497 Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Thu, 2 Jan 2025 15:32:39 +0100 Subject: [PATCH 4/4] feat: apply full width automatically for DataTable (#18357) * feat: apply full width automatically for DataTable * change implementation * load all columns of insights routing table at the same time * update team member list * sticky columns for >= sm * fix type error --------- Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> --- .../data-table/components/DataTable.tsx | 32 ++--- .../features/data-table/components/index.ts | 1 - packages/features/data-table/hooks/index.ts | 3 + .../data-table/hooks/useColumnSizingVars.ts | 26 ++++ .../data-table/hooks/useDebouncedWidth.ts | 31 ++++ .../useFetchMoreOnBottomReached.ts | 0 packages/features/data-table/index.ts | 2 +- packages/features/data-table/lib/resizing.ts | 133 ++++++++++++++++-- .../ee/teams/components/MemberList.tsx | 5 +- .../components/RoutingFormResponsesTable.tsx | 51 +++---- .../components/UserTable/UserListTable.tsx | 8 +- 11 files changed, 229 insertions(+), 63 deletions(-) create mode 100644 packages/features/data-table/hooks/index.ts create mode 100644 packages/features/data-table/hooks/useColumnSizingVars.ts create mode 100644 packages/features/data-table/hooks/useDebouncedWidth.ts rename packages/features/data-table/{components => hooks}/useFetchMoreOnBottomReached.ts (100%) diff --git a/packages/features/data-table/components/DataTable.tsx b/packages/features/data-table/components/DataTable.tsx index 9a2e8fec7dc320..cc6215208f4443 100644 --- a/packages/features/data-table/components/DataTable.tsx +++ b/packages/features/data-table/components/DataTable.tsx @@ -7,11 +7,12 @@ import { useVirtualizer, type Virtualizer } from "@tanstack/react-virtual"; // eslint-disable-next-line no-restricted-imports import kebabCase from "lodash/kebabCase"; import { usePathname } from "next/navigation"; -import { useMemo, useEffect, memo } from "react"; +import { useEffect, memo } from "react"; import classNames from "@calcom/lib/classNames"; import { Icon, TableNew, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@calcom/ui"; +import { useColumnSizingVars } from "../hooks"; import { usePersistentColumnResizing } from "../lib/resizing"; export type DataTableProps = { @@ -40,8 +41,8 @@ export function DataTable({ enableColumnResizing, ...rest }: DataTableProps & React.ComponentPropsWithoutRef<"div">) { - const pathname = usePathname(); - const identifier = _identifier ?? pathname; + const pathname = usePathname() as string | null; + const identifier = _identifier ?? pathname ?? undefined; const { rows } = table.getRowModel(); @@ -71,26 +72,13 @@ export function DataTable({ } }, [rowVirtualizer.getVirtualItems().length, rows.length, tableContainerRef.current]); - const columnSizeVars = useMemo(() => { - const headers = table.getFlatHeaders(); - const colSizes: { [key: string]: string } = {}; - for (let i = 0; i < headers.length; i++) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const header = headers[i]!; - const isAutoWidth = header.column.columnDef.meta?.autoWidth; - colSizes[`--header-${kebabCase(header.id)}-size`] = isAutoWidth ? "auto" : `${header.getSize()}px`; - colSizes[`--col-${kebabCase(header.column.id)}-size`] = isAutoWidth - ? "auto" - : `${header.column.getSize()}px`; - } - return colSizes; - }, [table.getFlatHeaders(), table.getState().columnSizingInfo, table.getState().columnSizing]); + const columnSizingVars = useColumnSizingVars({ table }); usePersistentColumnResizing({ enabled: Boolean(enableColumnResizing), table, - // TODO: Figure out why 'identifier' somehow types to string | null - identifier: identifier || undefined, + tableContainerRef, + identifier, }); return ( @@ -113,7 +101,7 @@ export function DataTable({ @@ -132,7 +120,7 @@ export function DataTable({ className={classNames( "bg-subtle hover:bg-muted relative flex shrink-0 items-center", header.column.getCanSort() ? "cursor-pointer select-none" : "", - meta?.sticky && "sticky top-0 z-20" + meta?.sticky && "top-0 z-20 sm:sticky" )}>
({ "flex shrink-0 items-center overflow-hidden", variant === "compact" && "p-1.5", meta?.sticky && - "bg-default group-hover:!bg-muted group-data-[state=selected]:bg-subtle sticky" + "bg-default group-hover:!bg-muted group-data-[state=selected]:bg-subtle sm:sticky" )}> {flexRender(cell.column.columnDef.cell, cell.getContext())} diff --git a/packages/features/data-table/components/index.ts b/packages/features/data-table/components/index.ts index 059d7cc6a4eef7..8467f5f68d5fc5 100644 --- a/packages/features/data-table/components/index.ts +++ b/packages/features/data-table/components/index.ts @@ -2,7 +2,6 @@ export { DataTableToolbar } from "./DataTableToolbar"; export { DataTableSelectionBar } from "./DataTableSelectionBar"; export { DataTablePagination } from "./DataTablePagination"; export { DataTableFilters } from "./filters"; -export { useFetchMoreOnBottomReached } from "./useFetchMoreOnBottomReached"; export { DataTable } from "./DataTable"; export { DataTableSkeleton } from "./DataTableSkeleton"; export type { DataTableProps } from "./DataTable"; diff --git a/packages/features/data-table/hooks/index.ts b/packages/features/data-table/hooks/index.ts new file mode 100644 index 00000000000000..b1350185c211fc --- /dev/null +++ b/packages/features/data-table/hooks/index.ts @@ -0,0 +1,3 @@ +export * from "./useDebouncedWidth"; +export * from "./useFetchMoreOnBottomReached"; +export * from "./useColumnSizingVars"; diff --git a/packages/features/data-table/hooks/useColumnSizingVars.ts b/packages/features/data-table/hooks/useColumnSizingVars.ts new file mode 100644 index 00000000000000..b966afc5110025 --- /dev/null +++ b/packages/features/data-table/hooks/useColumnSizingVars.ts @@ -0,0 +1,26 @@ +import type { Table } from "@tanstack/react-table"; +// eslint-disable-next-line no-restricted-imports +import kebabCase from "lodash/kebabCase"; +import { useMemo } from "react"; + +export const useColumnSizingVars = ({ table }: { table: Table }) => { + const headers = table.getFlatHeaders(); + const columnSizingInfo = table.getState().columnSizingInfo; + const columnSizing = table.getState().columnSizing; + + return useMemo(() => { + const headers = table.getFlatHeaders(); + const colSizes: { [key: string]: string } = {}; + headers.forEach((header) => { + const isAutoWidth = header.column.columnDef.meta?.autoWidth; + colSizes[`--header-${kebabCase(header.id)}-size`] = isAutoWidth ? "auto" : `${header.getSize()}px`; + colSizes[`--col-${kebabCase(header.column.id)}-size`] = isAutoWidth + ? "auto" + : `${header.column.getSize()}px`; + }); + return colSizes; + // `columnSizing` and `columnSizingInfo` are not used in the memo, + // but they're included in the deps to ensure the memo is re-evaluated when they change. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [headers, columnSizingInfo, columnSizing]); +}; diff --git a/packages/features/data-table/hooks/useDebouncedWidth.ts b/packages/features/data-table/hooks/useDebouncedWidth.ts new file mode 100644 index 00000000000000..11019e03c31d99 --- /dev/null +++ b/packages/features/data-table/hooks/useDebouncedWidth.ts @@ -0,0 +1,31 @@ +// eslint-disable-next-line no-restricted-imports +import debounce from "lodash/debounce"; +import { useState, useEffect } from "react"; + +export function useDebouncedWidth(ref: React.RefObject, debounceMs = 100) { + const [width, setWidth] = useState(0); + + useEffect(() => { + if (!ref.current) return; + + const element = ref.current; + setWidth(element.clientWidth); + + const debouncedSetWidth = debounce((width: number) => { + setWidth(width); + }, debounceMs); + + const resizeObserver = new ResizeObserver(([entry]) => { + debouncedSetWidth(entry.target.clientWidth); + }); + + resizeObserver.observe(element); + + return () => { + resizeObserver.disconnect(); + debouncedSetWidth.cancel(); + }; + }, [ref]); + + return width; +} diff --git a/packages/features/data-table/components/useFetchMoreOnBottomReached.ts b/packages/features/data-table/hooks/useFetchMoreOnBottomReached.ts similarity index 100% rename from packages/features/data-table/components/useFetchMoreOnBottomReached.ts rename to packages/features/data-table/hooks/useFetchMoreOnBottomReached.ts diff --git a/packages/features/data-table/index.ts b/packages/features/data-table/index.ts index 7ab099a4f8dba2..474b7f614f9477 100644 --- a/packages/features/data-table/index.ts +++ b/packages/features/data-table/index.ts @@ -2,4 +2,4 @@ export * from "./components"; export * from "./lib/types"; export * from "./lib/utils"; export * from "./lib/context"; -export * from "./lib/resizing"; +export * from "./hooks"; diff --git a/packages/features/data-table/lib/resizing.ts b/packages/features/data-table/lib/resizing.ts index 50906cdbbba609..3cf5f4dfe3709f 100644 --- a/packages/features/data-table/lib/resizing.ts +++ b/packages/features/data-table/lib/resizing.ts @@ -1,25 +1,27 @@ -import type { Table, ColumnSizingState } from "@tanstack/react-table"; +import type { Header, Table, ColumnSizingState } from "@tanstack/react-table"; // eslint-disable-next-line no-restricted-imports import debounce from "lodash/debounce"; -import { useState, useCallback, useEffect } from "react"; +import { useCallback, useEffect, useRef } from "react"; + +import { useDebouncedWidth } from "../hooks"; type UsePersistentColumnResizingProps = { enabled: boolean; table: Table; identifier?: string; + tableContainerRef: React.RefObject; }; function getLocalStorageKey(identifier: string) { return `data-table-column-sizing-${identifier}`; } -function loadColumnSizing(identifier: string) { +function loadColumnSizing(identifier: string): ColumnSizingState { try { return JSON.parse(localStorage.getItem(getLocalStorageKey(identifier)) || "{}"); } catch (error) { return {}; } - return {}; } function saveColumnSizing(identifier: string, columnSizing: ColumnSizingState) { @@ -28,12 +30,76 @@ function saveColumnSizing(identifier: string, columnSizing: ColumnSizingState) { const debouncedSaveColumnSizing = debounce(saveColumnSizing, 1000); +function getAdjustedColumnSizing({ + headers, + containerWidth, + initialColumnSizing, + currentColumnSizing, + resizedColumns, +}: { + headers: Header[]; + containerWidth: number; + initialColumnSizing: ColumnSizingState; + currentColumnSizing: ColumnSizingState; + resizedColumns: Set; +}) { + // Return early if any column has auto width + const hasAutoWidthColumn = headers.some((header) => header.column.columnDef.meta?.autoWidth); + if (hasAutoWidthColumn) { + return currentColumnSizing; + } + + const getColumnSize = (id: string) => + resizedColumns.has(id) ? currentColumnSizing[id] : initialColumnSizing[id]; + + const isAdjustable = (header: Header) => + header.column.columnDef.enableResizing !== false && !resizedColumns.has(header.id); + + // Calculate how many columns can be adjusted + const numberOfAdjustableColumns = headers.filter(isAdjustable).length; + + // Calculate widths and required adjustments + const totalColumnsWidth = headers.reduce((total, header) => total + getColumnSize(header.id), 0); + const widthToFill = Math.max(0, containerWidth - totalColumnsWidth); + const widthPerAdjustableColumn = + numberOfAdjustableColumns === 0 ? 0 : Math.floor(widthToFill / numberOfAdjustableColumns); + + // Handle any leftover width (due to rounding) + const leftoverWidth = widthToFill - widthPerAdjustableColumn * numberOfAdjustableColumns; + + // Build the new column sizing object + const newColumnSizing = headers.reduce((acc, header, index) => { + const baseWidth = getColumnSize(header.id); + const adjustmentWidth = isAdjustable(header) ? widthPerAdjustableColumn : 0; + const isLastColumn = index === headers.length - 1; + const extraWidth = isLastColumn ? leftoverWidth : 0; + + acc[header.id] = baseWidth + adjustmentWidth + extraWidth; + return acc; + }, {} as ColumnSizingState); + + return newColumnSizing; +} + +function getPartialColumnSizing(columnSizing: ColumnSizingState, columnsToExtract: Set) { + return Object.keys(columnSizing).reduce((acc, key) => { + if (columnsToExtract.has(key)) { + acc[key] = columnSizing[key]; + } + return acc; + }, {} as ColumnSizingState); +} + export function usePersistentColumnResizing({ enabled, table, identifier, + tableContainerRef, }: UsePersistentColumnResizingProps) { - const [_, setColumnSizing] = useState({}); + const initialized = useRef(false); + const columnSizing = useRef({}); + const initialColumnSizing = useRef({}); + const resizedColumns = useRef>(new Set()); const onColumnSizingChange = useCallback( (updater: ColumnSizingState | ((old: ColumnSizingState) => ColumnSizingState)) => { @@ -41,10 +107,24 @@ export function usePersistentColumnResizing({ // but TS doesn't know that, and this won't happen. if (!identifier) return; + const isResizingColumn = table.getState().columnSizingInfo.isResizingColumn; + if (isResizingColumn) { + resizedColumns.current.add(isResizingColumn); + } table.setState((oldTableState) => { - const newColumnSizing = typeof updater === "function" ? updater(oldTableState.columnSizing) : updater; - debouncedSaveColumnSizing(identifier, newColumnSizing); - setColumnSizing(newColumnSizing); + let newColumnSizing = typeof updater === "function" ? updater(oldTableState.columnSizing) : updater; + newColumnSizing = getAdjustedColumnSizing({ + headers: table.getFlatHeaders(), + containerWidth: tableContainerRef.current?.clientWidth || 0, + initialColumnSizing: initialColumnSizing.current, + currentColumnSizing: newColumnSizing, + resizedColumns: resizedColumns.current, + }); + debouncedSaveColumnSizing( + identifier, + getPartialColumnSizing(newColumnSizing, resizedColumns.current) + ); + columnSizing.current = newColumnSizing; return { ...oldTableState, @@ -55,19 +135,50 @@ export function usePersistentColumnResizing({ [identifier, table] ); + const debouncedContainerWidth = useDebouncedWidth(tableContainerRef); + useEffect(() => { - if (!enabled || !identifier) return; + if (!enabled || !identifier || !initialized.current) return; + const newColumnSizing = getAdjustedColumnSizing({ + headers: table.getFlatHeaders(), + containerWidth: debouncedContainerWidth, + initialColumnSizing: initialColumnSizing.current, + currentColumnSizing: columnSizing.current, + resizedColumns: resizedColumns.current, + }); - const newColumnSizing = loadColumnSizing(identifier); - setColumnSizing(newColumnSizing); + columnSizing.current = newColumnSizing; table.setState((old) => ({ ...old, columnSizing: newColumnSizing, })); + }, [debouncedContainerWidth]); + + useEffect(() => { + if (!enabled || !identifier) return; + + // loadedColumnSizing is a partial object of explicitly resized columns. + const loadedColumnSizing = loadColumnSizing(identifier); + + // combine loaded sizes and the default sizes from TanStack Table + initialColumnSizing.current = table.getFlatHeaders().reduce((acc, header) => { + acc[header.id] = loadedColumnSizing[header.id] || header.getSize(); + return acc; + }, {} as ColumnSizingState); + + columnSizing.current = loadedColumnSizing; + resizedColumns.current = new Set(Object.keys(loadedColumnSizing)); + + table.setState((old) => ({ + ...old, + columnSizing: loadedColumnSizing, + })); table.setOptions((prev) => ({ ...prev, columnResizeMode: "onChange", onColumnSizingChange, })); + + initialized.current = true; }, [enabled, identifier, table, onColumnSizingChange]); } diff --git a/packages/features/ee/teams/components/MemberList.tsx b/packages/features/ee/teams/components/MemberList.tsx index 510bb825b769b4..277702ef3a8a65 100644 --- a/packages/features/ee/teams/components/MemberList.tsx +++ b/packages/features/ee/teams/components/MemberList.tsx @@ -295,6 +295,7 @@ function MemberListContent(props: Props) { id: "select", enableHiding: false, enableSorting: false, + enableResizing: false, size: 30, header: ({ table }) => ( fetchMoreOnBottomReached(e.target as HTMLDivElement)}>
diff --git a/packages/features/insights/components/RoutingFormResponsesTable.tsx b/packages/features/insights/components/RoutingFormResponsesTable.tsx index 55c326274f71e6..06f9443dcce2e6 100644 --- a/packages/features/insights/components/RoutingFormResponsesTable.tsx +++ b/packages/features/insights/components/RoutingFormResponsesTable.tsx @@ -282,18 +282,21 @@ export function RoutingFormResponsesTableContent({ const columnFilters = useColumnFilters(); - const { data: headers, isLoading: isHeadersLoading } = - trpc.viewer.insights.routingFormResponsesHeaders.useQuery( - { - userId: selectedUserId ?? undefined, - teamId: selectedTeamId ?? undefined, - isAll: isAll ?? false, - routingFormId: selectedRoutingFormId ?? undefined, - }, - { - enabled: initialConfigIsReady, - } - ); + const { + data: headers, + isLoading: isHeadersLoading, + isSuccess: isHeadersSuccess, + } = trpc.viewer.insights.routingFormResponsesHeaders.useQuery( + { + userId: selectedUserId ?? undefined, + teamId: selectedTeamId ?? undefined, + isAll: isAll ?? false, + routingFormId: selectedRoutingFormId ?? undefined, + }, + { + enabled: initialConfigIsReady, + } + ); const { data, fetchNextPage, isFetching, hasNextPage, isLoading } = trpc.viewer.insights.routingFormResponses.useInfiniteQuery( @@ -323,7 +326,7 @@ export function RoutingFormResponsesTableContent({ const totalFetched = flatData.length; const processedData = useMemo(() => { - if (isHeadersLoading) return []; + if (!isHeadersSuccess) return []; return flatData.map((response) => { const row: RoutingFormTableRow = { id: response.id, @@ -363,7 +366,7 @@ export function RoutingFormResponsesTableContent({ return row; }); - }, [flatData, headers, isHeadersLoading]); + }, [flatData, headers, isHeadersSuccess]); const statusOrder: Record = { [BookingStatus.ACCEPTED]: 1, @@ -375,8 +378,12 @@ export function RoutingFormResponsesTableContent({ const columnHelper = createColumnHelper(); - const columns = useMemo( - () => [ + const columns = useMemo(() => { + if (!isHeadersSuccess) { + return []; + } + + return [ columnHelper.accessor("routedToBooking", { id: "bookedBy", header: t("routing_form_insights_booked_by"), @@ -412,7 +419,6 @@ export function RoutingFormResponsesTableContent({ return columnHelper.accessor(fieldHeader.id, { id: fieldHeader.id, header: convertToTitleCase(fieldHeader.label), - size: 200, cell: (info) => { const values = info.getValue(); if (isMultiSelect) { @@ -456,7 +462,6 @@ export function RoutingFormResponsesTableContent({ columnHelper.accessor("routedToBooking", { id: "bookingStatus", header: t("routing_form_insights_booking_status"), - size: 250, cell: (info) => (
@@ -480,7 +485,6 @@ export function RoutingFormResponsesTableContent({ columnHelper.accessor("routedToBooking", { id: "bookingAt", header: t("routing_form_insights_booking_at"), - size: 250, enableColumnFilter: false, cell: (info) => (
@@ -496,7 +500,6 @@ export function RoutingFormResponsesTableContent({ columnHelper.accessor("routedToBooking", { id: "assignmentReason", header: t("routing_form_insights_assignment_reason"), - size: 250, meta: { filter: { type: "text" }, }, @@ -515,7 +518,6 @@ export function RoutingFormResponsesTableContent({ columnHelper.accessor("createdAt", { id: "submittedAt", header: t("routing_form_insights_submitted_at"), - size: 250, enableColumnFilter: false, cell: (info) => (
@@ -523,9 +525,8 @@ export function RoutingFormResponsesTableContent({
), }), - ], - [headers, t, copyToClipboard] - ); + ]; + }, [isHeadersSuccess, headers, t, copyToClipboard]); const table = useReactTable({ data: processedData, @@ -534,7 +535,7 @@ export function RoutingFormResponsesTableContent({ getFilteredRowModel: getFilteredRowModel(), getSortedRowModel: getSortedRowModel(), defaultColumn: { - size: 200, + size: 150, }, state: { columnFilters, diff --git a/packages/features/users/components/UserTable/UserListTable.tsx b/packages/features/users/components/UserTable/UserListTable.tsx index 509e256608113f..42969fb5601ad7 100644 --- a/packages/features/users/components/UserTable/UserListTable.tsx +++ b/packages/features/users/components/UserTable/UserListTable.tsx @@ -118,7 +118,7 @@ function UserListTableContent() { const { data: session } = useSession(); const { isPlatformUser } = useGetUserAttributes(); const { data: org } = trpc.viewer.organizations.listCurrent.useQuery(); - const { data: attributes } = trpc.viewer.attributes.list.useQuery(); + const { data: attributes, isSuccess: isSuccessAttributes } = trpc.viewer.attributes.list.useQuery(); const { data: teams } = trpc.viewer.organizations.getTeams.useQuery(); const { data: facetedTeamValues } = trpc.viewer.organizations.getFacetedValues.useQuery(); @@ -163,7 +163,6 @@ function UserListTableContent() { //we must flatten the array of arrays from the useInfiniteQuery hook const flatData = useMemo(() => data?.pages?.flatMap((page) => page.rows) ?? [], [data]) as UserTableUser[]; - const totalFetched = flatData.length; const memorisedColumns = useMemo(() => { const permissions = { @@ -543,6 +542,11 @@ function UserListTableContent() { } }; + if (!isSuccessAttributes) { + // do not render the table until the attributes are fetched + return null; + } + return ( <>