(
thead > tr:first-child > *:first-child {
+ border-top-left-radius: var(--slds-g-radius-border-1, 0.25rem);
+}
+.slds-table_bordered > thead > tr:first-child > *:last-child {
+ border-top-right-radius: var(--slds-g-radius-border-1, 0.25rem);
+}
+
.is-disabled {
background-color: var(--slds-g-color-disabled-container-1, #e5e5e5);
}
diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts
index 04937a954..bfba14a87 100644
--- a/libs/ui/src/index.ts
+++ b/libs/ui/src/index.ts
@@ -23,6 +23,7 @@ export * from './lib/data-table/DataTable';
export * from './lib/data-table/DataTableRenderers';
export * from './lib/data-table/DataTree';
export * from './lib/data-table/SalesforceRecordDataTable';
+export * from './lib/data-table/grid/rdg-compat';
export * from './lib/docked-composer/DockedComposer';
export * from './lib/expression-group/expression-utils';
export * from './lib/expression-group/ExpressionContainer';
diff --git a/libs/ui/src/lib/data-table/DataTable.tsx b/libs/ui/src/lib/data-table/DataTable.tsx
index 84aa77d02..dadf909d9 100644
--- a/libs/ui/src/lib/data-table/DataTable.tsx
+++ b/libs/ui/src/lib/data-table/DataTable.tsx
@@ -1,164 +1,115 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { ContextMenuItem, SalesforceOrgUi } from '@jetstream/types';
-import { forwardRef } from 'react';
-import { DataGrid, DataGridProps, SortColumn } from 'react-data-grid';
-import 'react-data-grid/lib/styles.css';
-import { ContextMenu } from '../form/context-menu/ContextMenu';
-import { DataTableFilterContext, DataTableGenericContext } from './data-table-context';
-import './data-table-styles.css';
-import { ColumnWithFilter, ContextMenuActionData, RowWithKey } from './data-table-types';
-import { useDataTable } from './useDataTable';
+import { ExpandedState, RowSelectionState } from '@tanstack/react-table';
+import { forwardRef, useMemo } from 'react';
+import { DataTableV2, DataTableV2Props } from './grid/DataTableV2';
+import {
+ ColumnWithFilter,
+ ContextMenuActionData,
+ ContextMenuItems,
+ DataTableRef,
+ DefaultColumnOptions,
+ RowWithKey,
+ SortColumn,
+} from './grid/grid-types';
-interface PropsWithServer {
- serverUrl: string;
- skipFrontdoorLogin: boolean;
-}
-
-interface PropsWithoutServer {
- serverUrl?: never;
- skipFrontdoorLogin?: never;
-}
-
-export type DataTableProps> = DataTablePropsBase &
- (PropsWithServer | PropsWithoutServer);
-
-interface DataTablePropsBase> extends Omit<
- DataGridProps,
- 'columns' | 'rows' | 'rowKeyGetter' | 'onColumnsReorder'
-> {
+/**
+ * Public flat data table. Thin wrapper over the new headless-TanStack grid (DataTableV2) that preserves
+ * the legacy prop surface (selectedRows Set, rowHeight number|fn, topSummaryRows, etc.) so call sites
+ * migrate with no code changes.
+ */
+export interface DataTableProps> {
data: T[];
columns: ColumnWithFilter[];
+ getRowKey: (row: T) => string;
org?: SalesforceOrgUi;
- // Both of these are required if one is present, since the server url is most likely used for frontdoor login
- // serverUrl
- // skipFrontdoorLogin
+ serverUrl?: string;
+ skipFrontdoorLogin?: boolean;
quickFilterText?: string | null;
includeQuickFilter?: boolean;
context?: TContext;
- /** Must be stable to avoid constant re-renders */
- contextMenuItems?: ContextMenuItem[];
+ contextMenuItems?: ContextMenuItems;
+ // `any` so call sites may type their handler against a narrower row type without variance errors.
+ contextMenuAction?: (item: ContextMenuItem, data: ContextMenuActionData) => void;
initialSortColumns?: SortColumn[];
- /** Must be stable to avoid constant re-renders */
- contextMenuAction?: (item: ContextMenuItem, data: ContextMenuActionData) => void;
- getRowKey: (row: T) => string;
rowAlwaysVisible?: (row: T) => boolean;
ignoreRowInSetFilter?: (row: T) => boolean;
+ /**
+ * For genuine parent→child hierarchy (tree): return a row's child rows, or undefined for leaves.
+ * Use this instead of `DataTree`'s `groupBy` when parent rows are real data rows (not synthetic
+ * group labels). Compose the chevron/indentation in a column's `renderCell` with `TreeExpander`.
+ */
+ getSubRows?: (row: T, index: number) => T[] | undefined;
+ /** Controlled expanded state (keyed by row id). Uncontrolled when omitted — see `defaultExpanded`. */
+ expanded?: ExpandedState;
+ onExpandedChange?: (expanded: ExpandedState) => void;
+ /** Initial expanded state when uncontrolled; `true` expands every expandable row. */
+ defaultExpanded?: ExpandedState | boolean;
onReorderColumns?: (columns: string[], columnOrder: number[]) => void;
onSortedAndFilteredRowsChange?: (rows: readonly T[]) => void;
+ onSortColumnsChange?: (sortColumns: SortColumn[]) => void;
+ onRowsChange?: (rows: T[], data: { indexes: number[]; column: ColumnWithFilter }) => void;
+ /** Fixed numeric row height, or a per-row callback `({ type: 'ROW' | 'GROUP', row }) => number`.
+ * Rows are pinned to the returned height and are not DOM-measured, so the value must reflect the
+ * actual rendered height. */
+ rowHeight?: number | ((args: { type: 'ROW' | 'GROUP'; row: T }) => number);
+ rowClass?: (row: T) => string | undefined;
+ selectedRows?: ReadonlySet;
+ onSelectedRowsChange?: (selectedRows: Set) => void;
+ /** Pinned summary rows rendered below the header (legacy `topSummaryRows`). */
+ topSummaryRows?: any[];
+ summaryRowHeight?: number;
+ defaultColumnOptions?: DefaultColumnOptions;
+ className?: string;
+ 'aria-label'?: string;
}
-export const DataTable = forwardRef>(
- (
- {
- data,
- columns: _columns,
- serverUrl,
- skipFrontdoorLogin,
- org,
- quickFilterText,
- includeQuickFilter,
- context,
- contextMenuItems,
- initialSortColumns,
- contextMenuAction,
- getRowKey,
- ignoreRowInSetFilter,
- rowAlwaysVisible,
- onReorderColumns,
- onSortedAndFilteredRowsChange,
- ...rest
- }: DataTableProps,
- ref,
- ) => {
- const {
- gridId,
- columns,
- sortColumns,
- renderers,
- filters,
- reorderedColumns,
- filterSetValues,
- filteredRows,
- contextMenuProps,
- setSortColumns,
- updateFilter,
- handleReorderColumns,
- handleCellKeydown,
- handleCellContextMenu,
- handleCloseContextMenu,
- handleRowChange,
- } = useDataTable({
- data,
- columns: _columns,
- serverUrl,
- skipFrontdoorLogin,
- org,
- quickFilterText,
- includeQuickFilter,
- contextMenuItems,
- initialSortColumns,
- ref,
- contextMenuAction,
- getRowKey,
- ignoreRowInSetFilter,
- rowAlwaysVisible,
- onReorderColumns,
- onSortedAndFilteredRowsChange,
- });
+/** Map the legacy DataTable/DataTree prop surface onto DataTableV2 props (selection Set↔Record, etc.). */
+export function useMappedV2Props(props: DataTableProps): DataTableV2Props {
+ const { selectedRows, onSelectedRowsChange, topSummaryRows, summaryRowHeight, defaultColumnOptions, ...rest } = props as any;
+
+ const rowSelection = useMemo(() => {
+ if (!selectedRows) {
+ return undefined;
+ }
+ const record: RowSelectionState = {};
+ selectedRows.forEach((key: string) => (record[key] = true));
+ return record;
+ }, [selectedRows]);
+
+ const handleRowSelectionChange = useMemo(() => {
+ if (!onSelectedRowsChange) {
+ return undefined;
+ }
+ return (record: RowSelectionState) => onSelectedRowsChange(new Set(Object.keys(record).filter((key) => record[key])));
+ }, [onSelectedRowsChange]);
+
+ // The legacy wrappers always injected these defaults before handing columns to react-data-grid;
+ // most call sites rely on them rather than flagging each column.
+ const mergedDefaultColumnOptions = useMemo>(
+ () => ({ resizable: true, sortable: true, ...defaultColumnOptions }),
+ [defaultColumnOptions],
+ );
+
+ return {
+ ...rest,
+ ariaLabel: props['aria-label'],
+ enableRowSelection: !!(selectedRows || onSelectedRowsChange),
+ rowSelection,
+ onRowSelectionChange: handleRowSelectionChange,
+ defaultColumnOptions: mergedDefaultColumnOptions,
+ // Legacy react-data-grid allowed adding secondary sorts with a modifier-click.
+ enableMultiSort: true,
+ summaryRows: topSummaryRows,
+ summaryRowHeight,
+ } as DataTableV2Props;
+}
+
+function DataTableInner(props: DataTableProps, ref: React.Ref>) {
+ const mapped = useMappedV2Props(props);
+ return {...mapped} ref={ref} role={props.getSubRows ? 'treegrid' : 'grid'} />;
+}
- return (
-
-
- {
- if (rest.onRowsChange) {
- handleRowChange(rows, data);
- rest.onRowsChange(rows as any, data as any);
- }
- }}
- />
- {contextMenuProps && contextMenuItems && contextMenuAction && (
- {
- contextMenuAction(item, {
- row: filteredRows[contextMenuProps.rowIdx] as T,
- rowIdx: contextMenuProps.rowIdx,
- rows: filteredRows as T[],
- column: contextMenuProps.column,
- columns,
- });
- handleCloseContextMenu();
- }}
- onClose={handleCloseContextMenu}
- />
- )}
-
-
- );
- },
-);
+export const DataTable = forwardRef(DataTableInner) as unknown as (
+ props: DataTableProps & { ref?: React.Ref> },
+) => React.ReactElement;
diff --git a/libs/ui/src/lib/data-table/DataTableEditors.tsx b/libs/ui/src/lib/data-table/DataTableEditors.tsx
index 44ab6f4b1..c71bc7ea1 100644
--- a/libs/ui/src/lib/data-table/DataTableEditors.tsx
+++ b/libs/ui/src/lib/data-table/DataTableEditors.tsx
@@ -1,322 +1,4 @@
-/* eslint-disable @typescript-eslint/no-explicit-any */
-import { logger } from '@jetstream/shared/client-logger';
-import { isEscapeKey, isTabKey } from '@jetstream/shared/ui-utils';
-import { ListItem, SalesforceOrgUi } from '@jetstream/types';
-import { formatISO } from 'date-fns/formatISO';
-import { parseISO } from 'date-fns/parseISO';
-import isString from 'lodash/isString';
-import { ReactNode, useContext, useEffect, useRef, useState } from 'react';
-import { RenderEditCellProps } from 'react-data-grid';
-import ComboboxWithItems from '../form/combobox/ComboboxWithItems';
-import { RecordLookupCombobox } from '../form/combobox/RecordLookupCombobox';
-import DatePicker from '../form/date/DatePicker';
-import Input from '../form/input/Input';
-import Picklist from '../form/picklist/Picklist';
-import PopoverContainer, { PopoverContainerProps } from '../popover/PopoverContainer';
-import { DataTableGenericContext } from './data-table-context';
-import { getRowId } from './data-table-utils';
-
-function autoFocusAndSelect(input: HTMLInputElement | null) {
- input?.focus();
- input?.select();
-}
-
-function DataTableEditorPopover({
- rowIdx,
- colIdx,
- popoverContainerProps,
- onClose,
- children,
-}: {
- rowIdx: number;
- colIdx: number;
- popoverContainerProps?: Omit;
- onClose: (commitChanges?: boolean, shouldFocusCell?: boolean) => void;
- children: ReactNode;
-}) {
- const { rows } = useContext(DataTableGenericContext) as { rows: { _idx: number }[] };
- const popoverRef = useRef(null);
- /** This is not set on initial render because the date picker is open on render and the reference element must exist to render correctly */
- const [referenceElement, setReferenceElement] = useState(null);
-
- useEffect(() => {
- try {
- // If rows are filtered, the provided index will not be accurate
- const actualRowIdx = !rows || rows[rowIdx]?._idx === rowIdx ? rowIdx : rows.findIndex(({ _idx }) => _idx === rowIdx);
- if ((actualRowIdx ?? -1) >= 0) {
- setReferenceElement(document.querySelector(`[aria-rowindex="${actualRowIdx + 2}"] > [aria-colindex="${colIdx + 1}"]`));
- }
- } catch (ex) {
- logger.warn('Error setting reference element', ex);
- }
- }, [colIdx, rowIdx, rows]);
-
- return (
- {
- if (isEscapeKey(event)) {
- onClose();
- }
- if (isTabKey(event)) {
- event.preventDefault();
- event.stopPropagation();
- onClose(true, true);
- }
- }}
- >
- {referenceElement && {children}
}
-
- );
-}
-
-export function DataTableEditorText({
- row,
- column,
- onRowChange,
- onClose,
-}: RenderEditCellProps) {
- return (
-
-
- {
- const _touchedColumns = new Set((row as any)._touchedColumns || []);
- _touchedColumns.add(column.key);
- onRowChange({ ...row, [column.key]: event.target.value, _touchedColumns });
- }}
- onBlur={() => onClose(true, true)}
- />
-
-
- );
-}
-
-export function DataTableEditorBoolean({
- row,
- column,
- onRowChange,
- onClose,
-}: RenderEditCellProps) {
- const value = (row[column.key as keyof TRow] as unknown as boolean) || false;
- const id = `${getRowId(row)}_${column.key}_checkbox`;
- return (
-
-
-
-
-
- {
- const _touchedColumns = new Set((row as any)._touchedColumns || []);
- _touchedColumns.add(column.key);
- onRowChange({ ...row, [column.key]: event.target.checked, _touchedColumns });
- }}
- onBlur={() => onClose(true, true)}
- />
-
-
-
-
-
- );
-}
-
-const BLANK_LIST_ITEM: ListItem = { id: '_BLANK_', label: '--None--', value: '' };
-
-export function dataTableEditorDropdownWrapper({
- values: _values,
- isMultiSelect,
-}: {
- values: ListItem[];
- isMultiSelect: boolean;
-}) {
- return ({ row, column, onRowChange, onClose }: RenderEditCellProps) => {
- const allValues = useRef(new Set([BLANK_LIST_ITEM.value, ..._values.map((v) => v.value)]));
- const [values, setValues] = useState(() => [BLANK_LIST_ITEM, ..._values]);
-
- const selectedItemId = row[column.key as keyof TRow] as unknown as string;
- // only used if multi-select is enabled
-
- // make sure inactive value (if selected) shows up as an option in the dropdown
- // only runs on first render
- useEffect(() => {
- if (isMultiSelect) {
- const selectedItemIds = selectedItemId ? selectedItemId.split(';') : [];
- if (selectedItemIds.length) {
- const missingItems = selectedItemIds
- .filter((itemId) => !allValues.current.has(itemId))
- .map((itemId) => {
- allValues.current.add(itemId);
- return itemId;
- });
- setValues([...values, ...missingItems.map((itemId) => ({ id: itemId, label: itemId, value: itemId }))]);
- }
- } else {
- if (selectedItemId && !allValues.current.has(selectedItemId)) {
- allValues.current.add(selectedItemId);
- setValues([...values, { id: selectedItemId, label: selectedItemId, value: selectedItemId }]);
- }
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
- if (isMultiSelect) {
- const selectedItemIds = selectedItemId ? selectedItemId.split(';') : [];
- return (
-
- onClose(true, true)}
- onChange={(items) => {
- const _touchedColumns = new Set((row as any)._touchedColumns || []);
- _touchedColumns.add(column.key);
- onRowChange(
- {
- ...row,
- [column.key]: (items || [])
- .map((item) => item.value)
- .filter(Boolean)
- .join(';'),
- _touchedColumns,
- },
- false,
- );
- }}
- />
-
- );
- }
-
- return (
-
- {
- const _touchedColumns = new Set((row as any)._touchedColumns || []);
- _touchedColumns.add(column.key);
- onRowChange({ ...row, [column.key]: item.value === '' ? null : item.value, _touchedColumns }, true);
- }}
- />
-
- );
- };
-}
-
-export function DataTableEditorDate({
- row,
- column,
- onRowChange,
- onClose,
-}: RenderEditCellProps) {
- const currValue = row[column.key as keyof TRow] as unknown as string;
- let currDate: Date | undefined;
- if (currValue) {
- currDate = parseISO(currValue);
- }
-
- return (
-
- {
- /** setTimeout is used to avoid a React error about flushSync being called during a render */
- setTimeout(() => {
- const _touchedColumns = new Set((row as any)._touchedColumns || []);
- _touchedColumns.add(column.key);
- onRowChange({ ...row, [column.key]: value ? formatISO(value, { representation: 'date' }) : null, _touchedColumns }, true);
- });
- }}
- />
-
- );
-}
-
-export const dataTableEditorRecordLookup = ({ sobjects }: { sobjects: string[] }) => {
- return function DataTableEditorRecordLookup({
- row,
- column,
- onRowChange,
- onClose,
- }: RenderEditCellProps) {
- const currValue = row[column.key as keyof TRow] as unknown as string;
- const { org } = useContext(DataTableGenericContext) as { org: SalesforceOrgUi; defaultApiVersion: string };
- const [selectedSobject, setSelectedSobject] = useState(sobjects[0]);
-
- if (!org || !selectedSobject) {
- return ;
- }
-
- return (
-
- {
- const _touchedColumns = new Set((row as any)._touchedColumns || []);
- _touchedColumns.add(column.key);
- onRowChange({ ...row, [column.key]: value || null, _touchedColumns }, false);
- }}
- onObjectChange={setSelectedSobject}
- />
-
- );
- };
-};
+/**
+ * Re-export of the new grid editors. The legacy implementation moved into `grid/editors/`.
+ */
+export { EditorBoolean, EditorDate, EditorText, editorDropdown, editorRecordLookup } from './grid/editors/CellEditors';
diff --git a/libs/ui/src/lib/data-table/DataTableRenderers.tsx b/libs/ui/src/lib/data-table/DataTableRenderers.tsx
index 99a40961f..261d392da 100644
--- a/libs/ui/src/lib/data-table/DataTableRenderers.tsx
+++ b/libs/ui/src/lib/data-table/DataTableRenderers.tsx
@@ -1,762 +1,20 @@
-/* eslint-disable @typescript-eslint/no-explicit-any */
-import { css } from '@emotion/react';
-import { IconName } from '@jetstream/icon-factory';
-import { isValidSalesforceRecordId, useDebounce } from '@jetstream/shared/ui-utils';
-import { getIdFromRecordUrl, multiWordStringFilter } from '@jetstream/shared/utils';
-import { CloneEditView, ListItem, SalesforceOrgUi } from '@jetstream/types';
-import { useVirtualizer } from '@tanstack/react-virtual';
-import classNames from 'classnames';
-import { formatISO } from 'date-fns/formatISO';
-import { parseISO } from 'date-fns/parseISO';
-import lodashGet from 'lodash/get';
-import isBoolean from 'lodash/isBoolean';
-import isFunction from 'lodash/isFunction';
-import isString from 'lodash/isString';
-import { Fragment, ReactNode, memo, useContext, useEffect, useRef, useState } from 'react';
-import { RenderCellProps, RenderGroupCellProps, RenderHeaderCellProps, useRowSelection } from 'react-data-grid';
-import Checkbox from '../form/checkbox/Checkbox';
-import DatePicker from '../form/date/DatePicker';
-import Input from '../form/input/Input';
-import Picklist from '../form/picklist/Picklist';
-import SearchInput from '../form/search-input/SearchInput';
-import TimePicker from '../form/time-picker/TimePicker';
-import Modal from '../modal/Modal';
-import { Popover, PopoverRef } from '../popover/Popover';
-import CopyToClipboard from '../widgets/CopyToClipboard';
-import Icon from '../widgets/Icon';
-import RecordLookupPopover from '../widgets/RecordLookupPopover';
-import Spinner from '../widgets/Spinner';
-import Tooltip from '../widgets/Tooltip';
-import { DataTableFilterContext, DataTableGenericContext, DataTableSelectedContext } from './data-table-context';
-import { dataTableDateFormatter } from './data-table-formatters';
-import {
- DataTableBooleanSetFilter,
- DataTableDateFilter,
- DataTableFilter,
- DataTableSetFilter,
- DataTableTextFilter,
- DataTableTimeFilter,
- RowWithKey,
-} from './data-table-types';
-import { getRowId, getSfdcRetUrl, isFilterActive, resetFilter } from './data-table-utils';
-
-// CONFIGURATION
-
-let _serverUrl: string;
-let _org: SalesforceOrgUi;
-let _skipFrontdoorLogin = false;
-
-export function configIdLinkRenderer(serverUrl: string, org: SalesforceOrgUi, skipFrontdoorLogin?: boolean) {
- if (_serverUrl !== serverUrl) {
- _serverUrl = serverUrl;
- }
- if (_org !== org) {
- _org = org;
- }
- _skipFrontdoorLogin = skipFrontdoorLogin ?? _skipFrontdoorLogin;
-}
-
/**
- * Map of function identities in order to identify the function in callback functions
- * This is generally used to have dedicated functionality for keyboard navigation based on the cell renderer type
+ * Re-export of the new grid renderers. The legacy implementation moved into `grid/renderers/`.
*/
-// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
-export const dataTableRenderFnMap = new Map();
-
-// HEADER RENDERERS
-
-export function SelectHeaderGroupRenderer(props: RenderGroupCellProps) {
- const { column, groupKey, row, childRows } = props;
- const { isRowSelectionDisabled, isRowSelected, onRowSelectionChange } = useRowSelection();
-
- return (
-
- {({ selectedRowIds, getRowKey }) => (
- 0 && childRows.some((childRow) => selectedRowIds.has((getRowKey || getRowId)(childRow)))}
- onChange={(checked) => onRowSelectionChange({ row, checked, isShiftClick: false })}
- />
- )}
-
- );
-}
-dataTableRenderFnMap.set(SelectHeaderGroupRenderer, 'SelectHeaderGroupRenderer');
-
-export function FilterRenderer({
- sortDirection,
- column,
- children,
-}: RenderHeaderCellProps & {
- children: (
- args: HeaderFilterProps & {
- ref?: React.RefObject;
- tabIndex?: number;
- },
- ) => React.ReactElement;
-}) {
- const { filters, filterSetValues, updateFilter } = useContext(DataTableFilterContext);
-
- const iconName: IconName = sortDirection === 'ASC' ? 'arrowup' : 'arrowdown';
-
- return (
-
-
{column.name}
-
- {sortDirection &&
}
-
- {children({
- columnKey: column.key,
- filters: filters[column.key],
- filterSetValues,
- updateFilter,
- })}
-
-
-
- );
-}
-dataTableRenderFnMap.set(FilterRenderer, 'FilterRenderer');
-
-/**
- * Filter renderer that can be used for any field that does not need sort
- * This can be used on summary rows as well to solve for headers that span multiple columns
- */
-export function SummaryFilterRenderer({ columnKey, label }: { columnKey: string; label: string }) {
- const { filters, filterSetValues, updateFilter } = useContext(DataTableFilterContext);
- return (
-
- );
-}
-dataTableRenderFnMap.set(SummaryFilterRenderer, 'SummaryFilterRenderer');
-
-interface HeaderFilterProps {
- columnKey: string;
- filters: DataTableFilter[];
- filterSetValues: Record;
- updateFilter: (column: string, filter: DataTableFilter) => void;
-}
-
-export const HeaderFilter = memo(({ columnKey, filters, filterSetValues, updateFilter }: HeaderFilterProps) => {
- const [active, setActive] = useState(false);
- const popoverRef = useRef(null);
-
- useEffect(() => {
- setActive(filters?.some((filter) => isFilterActive(filter, (filterSetValues[columnKey] || []).length)));
- }, [columnKey, filterSetValues, filters]);
-
- function getFilter(filter: DataTableFilter, autoFocus = false) {
- switch (filter.type) {
- case 'TEXT':
- return ;
- case 'NUMBER':
- return null;
- case 'DATE':
- return ;
- case 'TIME':
- return ;
- case 'BOOLEAN_SET':
- return (
-
- );
- case 'SET':
- return (
-
- );
- default:
- return null;
- }
- }
-
- function handleReset() {
- filters.map((filter) => updateFilter(columnKey, resetFilter(filter.type, filterSetValues[columnKey] || [])));
- popoverRef.current?.close();
- }
-
- if (!filters?.length) {
- return null;
- }
-
- return (
- {
- ev.stopPropagation();
- }}
- onPointerDown={(ev) => ev.stopPropagation()}
- onKeyDown={(ev) => ev.stopPropagation()}
- >
-
ev.stopPropagation()}>
- Filter
-
- }
- footer={
-
- }
- content={
- ev.stopPropagation()}>
- {filters
- .filter((filter) => filter.type)
- .map((filter, i) => (
-
- {i > 0 &&
}
- {getFilter(filter, i === 0)}
-
- ))}
-
- }
- buttonProps={{
- className: 'slds-button slds-button_icon',
- onClick: (ev) => ev.stopPropagation(),
- }}
- >
-
-
-
- );
-});
-dataTableRenderFnMap.set(HeaderFilter, 'HeaderFilter');
-
-interface DataTableTextFilterProps {
- columnKey: string;
- filter: DataTableTextFilter;
- autoFocus?: boolean;
- updateFilter: (column: string, filter: DataTableFilter) => void;
-}
-
-export const HeaderTextFilter = memo(({ columnKey, filter, autoFocus = false, updateFilter }: DataTableTextFilterProps) => {
- const [value, setValue] = useState(filter.value);
- const debouncedValue = useDebounce(value, 300);
-
- useEffect(() => {
- if (filter.value !== debouncedValue) {
- updateFilter(columnKey, { ...filter, value: debouncedValue });
- }
- }, [updateFilter, debouncedValue, columnKey, filter]);
-
- return (
- setValue('')}>
- setValue(ev.target.value)}
- autoFocus={autoFocus}
- />
-
- );
-});
-dataTableRenderFnMap.set(HeaderTextFilter, 'HeaderTextFilter');
-
-interface HeaderSetFilterProps {
- columnKey: string;
- filter: DataTableSetFilter | DataTableBooleanSetFilter;
- values: string[];
- updateFilter: (column: string, filter: DataTableFilter) => void;
-}
-
-export const HeaderSetFilter = memo(({ columnKey, filter, values, updateFilter }: HeaderSetFilterProps) => {
- const parentRef = useRef(null);
- const [selectedValues, setSelectedValues] = useState(() => new Set(filter.value));
- const [visibleItems, setVisibleItems] = useState(values);
- const [searchTerm, setSearchTerm] = useState('');
- const [allItemsSelected, setAllItemsSelected] = useState(true);
- const [indeterminate, setIndeterminate] = useState(false);
-
- useEffect(() => {
- if (searchTerm) {
- setVisibleItems(values.filter(multiWordStringFilter(searchTerm)));
- } else {
- setVisibleItems(values);
- }
- }, [searchTerm, values]);
-
- useEffect(() => {
- const allItemsSelected = visibleItems.every((item) => selectedValues.has(item));
- setIndeterminate(!allItemsSelected && visibleItems.some((item) => selectedValues.has(item)));
- setAllItemsSelected(allItemsSelected);
- }, [selectedValues, visibleItems]);
-
- const rowVirtualizer = useVirtualizer({
- count: visibleItems.length,
- getScrollElement: () => parentRef.current,
- estimateSize: () => 20.33,
- overscan: 50,
- });
-
- const handleSelectAll = (checked: boolean) => {
- const newSet = new Set(selectedValues);
- if (checked) {
- visibleItems.forEach((item) => newSet.add(item));
- } else {
- visibleItems.forEach((item) => newSet.delete(item));
- }
- setSelectedValues(newSet);
- updateFilter(columnKey, { ...filter, value: Array.from(newSet) });
- };
-
- function handleChange(value: string, checked: boolean) {
- const newSet = new Set(selectedValues);
- if (checked) {
- newSet.add(value);
- } else {
- newSet.delete(value);
- }
- setSelectedValues(newSet);
- updateFilter(columnKey, { ...filter, value: Array.from(newSet) });
- }
-
- const hasVisibleItems = visibleItems.length > 0;
-
- return (
-
-
- {!hasVisibleItems &&
No items
}
- {hasVisibleItems && (
- <>
-
-
-
- {rowVirtualizer.getVirtualItems().map((virtualItem) => (
-
- handleChange(visibleItems[virtualItem.index], checked)}
- />
-
- ))}
-
-
- >
- )}
-
- );
-});
-dataTableRenderFnMap.set(HeaderSetFilter, 'HeaderSetFilter');
-
-interface DataTableDateFilterProps {
- columnKey: string;
- filter: DataTableDateFilter;
- updateFilter: (column: string, filter: DataTableFilter) => void;
-}
-
-export const HeaderDateFilter = memo(({ columnKey, filter, updateFilter }: DataTableDateFilterProps) => {
- const [value, setValue] = useState(() => (filter.value ? parseISO(filter.value) : null));
- const [comparators] = useState[]>(() => [
- { id: 'EQUALS', label: 'Equals', value: 'EQUALS' },
- { id: 'GREATER_THAN', label: 'Greater Than', value: 'GREATER_THAN' },
- { id: 'LESS_THAN', label: 'Less Than', value: 'LESS_THAN' },
- ]);
- const [selectedComparator, setSelectedComparators] = useState<'EQUALS' | 'GREATER_THAN' | 'LESS_THAN'>(() => filter.comparator);
-
- function handleComparatorChange(comparator: 'EQUALS' | 'GREATER_THAN' | 'LESS_THAN') {
- setSelectedComparators(comparator);
- if (filter.comparator !== comparator) {
- updateFilter(columnKey, { ...filter, comparator });
- }
- }
-
- function handleDateChange(value: Date) {
- setValue(value);
- updateFilter(columnKey, { ...filter, value: value ? formatISO(value) : null });
- }
-
- return (
-
-
[]) => handleComparatorChange(items[0].value)}
- />
-
-
- );
-});
-dataTableRenderFnMap.set(HeaderDateFilter, 'HeaderDateFilter');
-
-interface HeaderTimeFilterProps {
- columnKey: string;
- filter: DataTableTimeFilter;
- updateFilter: (column: string, filter: DataTableFilter) => void;
-}
-
-export const HeaderTimeFilter = memo(({ columnKey, filter, updateFilter }: HeaderTimeFilterProps) => {
- const [value, setValue] = useState(() => filter.value);
- const [comparators] = useState[]>(() => [
- { id: 'EQUALS', label: 'Equals', value: 'EQUALS' },
- { id: 'GREATER_THAN', label: 'Greater Than', value: 'GREATER_THAN' },
- { id: 'LESS_THAN', label: 'Less Than', value: 'LESS_THAN' },
- ]);
- const [selectedComparator, setSelectedComparators] = useState<'EQUALS' | 'GREATER_THAN' | 'LESS_THAN'>(() => filter.comparator);
-
- function handleComparatorChange(comparator: 'EQUALS' | 'GREATER_THAN' | 'LESS_THAN') {
- setSelectedComparators(comparator);
- if (filter.comparator !== comparator) {
- updateFilter(columnKey, { ...filter, comparator });
- }
- }
-
- function handleTimeChange(value: string) {
- setValue(value);
- updateFilter(columnKey, { ...filter, value });
- }
-
- return (
-
-
[]) => handleComparatorChange(items[0].value)}
- />
-
-
- );
-});
-dataTableRenderFnMap.set(HeaderTimeFilter, 'HeaderTimeFilter');
-
-// CELL RENDERERS
-/** Generic cell renderer when the type of data is unknown */
-export function GenericRenderer(RenderCellProps: RenderCellProps): ReactNode {
- const { column, row } = RenderCellProps;
-
- if (!row) {
- return ;
- }
-
- let value = row[column.key];
-
- if (value instanceof Date) {
- value = dataTableDateFormatter(value);
- } else if (isBoolean(value)) {
- return ;
- } else if (value && typeof value === 'object') {
- value = ;
- }
-
- return {value}
;
-}
-dataTableRenderFnMap.set(GenericRenderer, 'GenericRenderer');
-
-export function SelectFormatter(props: RenderCellProps): ReactNode {
- const { column, row } = props;
- const { isRowSelectionDisabled, isRowSelected, onRowSelectionChange } = useRowSelection();
-
- return (
- onRowSelectionChange({ row, checked, isShiftClick: false })}
- />
- );
-}
-dataTableRenderFnMap.set(SelectFormatter, 'SelectFormatter');
-
-export function ValueOrLoadingRenderer({ column, row }: RenderCellProps): ReactNode {
- if (!row) {
- return ;
- }
- const { loading } = row;
- const value = row[column.key];
- if (loading) {
- return ;
- }
- return {value}
;
-}
-dataTableRenderFnMap.set(ValueOrLoadingRenderer, 'ValueOrLoadingRenderer');
-
-export const ComplexDataRenderer = ({ column, row }: RenderCellProps): ReactNode => {
- const value = row[column.key];
- const [isActive, setIsActive] = useState(false);
- const [jsonValue] = useState(JSON.stringify(value || '', null, 2));
-
- function handleViewData() {
- if (isActive) {
- setIsActive(false);
- } else {
- setIsActive(true);
- }
- }
-
- function handleCloseModal(canceled?: boolean) {
- if (typeof canceled === 'boolean' && canceled) {
- setIsActive(true);
- } else {
- setIsActive(false);
- }
- }
-
- return (
-
- {isActive && (
-
}
- >
-
- {jsonValue}
-
-
- )}
-
-
- );
-};
-dataTableRenderFnMap.set(ComplexDataRenderer, 'ComplexDataRenderer');
-
-export const IdLinkRenderer = ({ column, row }: RenderCellProps): ReactNode => {
- const { onRecordAction } = useContext(DataTableGenericContext) as {
- onRecordAction?: (action: CloneEditView, recordId: string, sobjectName: string) => void;
- };
- const recordId = row[column.key];
- const { skipFrontDoorAuth, url } = getSfdcRetUrl(row, recordId, _skipFrontdoorLogin);
- return (
-
- );
-};
-dataTableRenderFnMap.set(IdLinkRenderer, 'IdLinkRenderer');
-
-/**
- * Render a Name field (primary or via relationship like Account.Name) as a clickable
- * record-lookup popover, showing the Name text as the cell content instead of the id.
- * Falls back to plain text when no related record id can be resolved.
- */
-export const NameLinkRenderer = ({ column, row }: RenderCellProps): ReactNode => {
- const { onRecordAction } = useContext(DataTableGenericContext) as {
- onRecordAction?: (action: CloneEditView, recordId: string, sobjectName: string) => void;
- };
- const nameValue = row[column.key];
- // For "Account.Owner.Name" the parent path is "Account.Owner"; for bare "Name" it is the row's record itself.
- const parentPath = column.key.includes('.') ? column.key.split('.').slice(0, -1).join('.') : '';
- const relatedRecord = parentPath ? lodashGet(row._record, parentPath) : row._record;
- const relatedRecordUrl = relatedRecord?.attributes?.url;
- const recordId: string | undefined = relatedRecord?.Id || (relatedRecordUrl ? getIdFromRecordUrl(relatedRecordUrl) : undefined);
-
- if (nameValue == null || !recordId) {
- return {nameValue}
;
- }
-
- const { skipFrontDoorAuth, url } = getSfdcRetUrl(relatedRecord, recordId, _skipFrontdoorLogin);
-
- return (
- {nameValue}}
- />
- );
-};
-dataTableRenderFnMap.set(NameLinkRenderer, 'NameLinkRenderer');
-
-export function TextOrIdLinkRenderer(RenderCellProps: RenderCellProps): ReactNode {
- const { column, row } = RenderCellProps;
-
- if (!row) {
- return ;
- }
-
- const maybeSalesforceId = row[column.key];
-
- if (_org && isString(maybeSalesforceId) && maybeSalesforceId.length === 18 && isValidSalesforceRecordId(maybeSalesforceId, false)) {
- return ;
- }
-
- return GenericRenderer(RenderCellProps);
-}
-
-export const ActionRenderer = ({ row }: { row: any }): ReactNode => {
- if (!isFunction(row?._action)) {
- return null;
- }
-
- const isDeleted = !!row.IsDeleted;
-
- return (
-
-
-
-
-
-
-
-
-
-
-
- {isDeleted && (
-
-
-
- )}
- {!isDeleted && (
-
-
-
- )}
-
-
-
-
- );
-};
-dataTableRenderFnMap.set(ActionRenderer, 'ActionRenderer');
-
-export const BooleanRenderer = ({ column, row }: RenderCellProps): ReactNode => {
- const value = row[column.key];
- return (
-
- );
-};
-dataTableRenderFnMap.set(BooleanRenderer, 'BooleanRenderer');
-
-export const ErrorMessageRenderer = ({ row }: { row: any }): ReactNode => {
- if (!row?._saveError) {
- return null;
- }
- return (
-
-
-
-
-
-
-
- Save Error
-
-
-
-
- }
- content={
-
- }
- buttonProps={{ className: 'slds-button slds-button_icon slds-button_icon-error' }}
- >
-
-
- );
-};
-dataTableRenderFnMap.set(ErrorMessageRenderer, 'ErrorMessageRenderer');
+export { SummaryFilterRenderer } from './grid/filters/HeaderFilters';
+export {
+ ActionRenderer,
+ BooleanRenderer,
+ ComplexDataRenderer,
+ ErrorMessageRenderer,
+ GenericRenderer,
+ IdLinkRenderer,
+ NameLinkRenderer,
+ SelectFormatter,
+ SelectHeaderGroupRenderer,
+ TextOrIdLinkRenderer,
+ ValueOrLoadingRenderer,
+} from './grid/renderers/CellRenderers';
+export { SubqueryRenderer } from './grid/renderers/SubqueryRenderer';
+export { TreeExpander } from './grid/renderers/TreeExpander';
+export type { TreeExpanderProps } from './grid/renderers/TreeExpander';
diff --git a/libs/ui/src/lib/data-table/DataTableSubqueryRenderer.tsx b/libs/ui/src/lib/data-table/DataTableSubqueryRenderer.tsx
index 3366c7893..6ff20ca65 100644
--- a/libs/ui/src/lib/data-table/DataTableSubqueryRenderer.tsx
+++ b/libs/ui/src/lib/data-table/DataTableSubqueryRenderer.tsx
@@ -1,380 +1,2 @@
-/* eslint-disable @typescript-eslint/no-explicit-any */
-import { queryMore } from '@jetstream/shared/data';
-import { appActionObservable, copyRecordsToClipboard, formatNumber } from '@jetstream/shared/ui-utils';
-import { flattenRecord } from '@jetstream/shared/utils';
-import { CloneEditView, ContextMenuItem, Maybe, QueryResult, SalesforceOrgUi } from '@jetstream/types';
-import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { RenderCellProps } from 'react-data-grid';
-import RecordDownloadModal from '../file-download-modal/RecordDownloadModal';
-import Grid from '../grid/Grid';
-import AutoFullHeightContainer from '../layout/AutoFullHeightContainer';
-import Modal from '../modal/Modal';
-import Icon from '../widgets/Icon';
-import Spinner from '../widgets/Spinner';
-import { DataTable } from './DataTable';
-import { DataTableSubqueryContext } from './data-table-context';
-import { ColumnWithFilter, ContextAction, ContextMenuActionData, RowWithKey, SubqueryContext } from './data-table-types';
-import {
- NON_DATA_COLUMN_KEYS,
- TABLE_CONTEXT_MENU_ITEMS,
- copySalesforceRecordTableDataToClipboard,
- getRowId,
- getSubqueryModalTagline,
-} from './data-table-utils';
-
-export const SubqueryRenderer = ({ column, row, onRowChange }: RenderCellProps): ReactNode => {
- const isMounted = useRef(true);
- const [isActive, setIsActive] = useState(false);
- const [modalTagline, setModalTagline] = useState>(null);
- const [downloadModalIsActive, setDownloadModalIsActive] = useState(false);
- const [isLoadingMore, setIsLoadingMore] = useState(false);
- const [queryResults, setQueryResults] = useState>(row[column.key] || {});
- // const [rows, setRows] = useState([]);
- const [selectedRows, setSelectedRows] = useState>(() => new Set());
-
- const { records, nextRecordsUrl } = queryResults;
-
- useEffect(() => {
- isMounted.current = true;
- return () => {
- isMounted.current = false;
- };
- }, []);
-
- // Not yet supported
- const handleRowAction = useCallback((row: any, action: 'view' | 'edit' | 'clone' | 'apex') => {
- // logger.info('row action', row, action);
- // switch (action) {
- // case 'edit':
- // onEdit(row);
- // break;
- // case 'clone':
- // onClone(row);
- // break;
- // case 'view':
- // onView(row);
- // break;
- // case 'apex':
- // onGetAsApex(row);
- // break;
- // default:
- // break;
- // }
- }, []);
-
- function handleViewData() {
- if (isActive) {
- setIsActive(false);
- } else {
- if (!modalTagline && row) {
- setModalTagline(getSubqueryModalTagline(row));
- }
- setIsActive(true);
- }
- }
-
- function handleCloseModal(canceled?: boolean) {
- if (typeof canceled === 'boolean' && canceled) {
- setIsActive(true);
- setDownloadModalIsActive(false);
- } else {
- setIsActive(false);
- setDownloadModalIsActive(false);
- }
- }
-
- function openDownloadModal() {
- setIsActive(false);
- setDownloadModalIsActive(true);
- }
-
- function handleCopyToClipboard(fields: string[]) {
- copyRecordsToClipboard(records, 'excel', fields);
- }
-
- async function loadMore(org: SalesforceOrgUi, isTooling: boolean) {
- try {
- if (!nextRecordsUrl) {
- return;
- }
- setIsLoadingMore(true);
- const results = await queryMore(org, nextRecordsUrl, isTooling);
- if (!isMounted.current) {
- return;
- }
- results.queryResults.records = records.concat(results.queryResults.records);
- setQueryResults(results.queryResults);
- setIsLoadingMore(false);
- } catch {
- if (!isMounted.current) {
- return;
- }
- setIsLoadingMore(false);
- }
- }
-
- if (!Array.isArray(records) || records.length === 0) {
- return ;
- }
-
- return (
-
- {(props) => {
- if (!props) {
- return null;
- }
- const {
- serverUrl,
- skipFrontdoorLogin,
- org,
- columnDefinitions,
- onSubqueryFieldReorder,
- isTooling,
- hasGoogleDriveAccess,
- googleShowUpgradeToPro,
- google_apiKey,
- google_appId,
- google_clientId,
- } = props;
-
- const columns = columnDefinitions?.[column.key.toLowerCase()];
-
- if (!columns) {
- return null;
- }
-
- return (
-
- {(downloadModalIsActive || isActive) && (
-
- )}
-
-
- );
- }}
-
- );
-};
-
-interface ModalDataTableProps extends SubqueryContext {
- isActive: boolean;
- columnKey: string;
- columns: ColumnWithFilter[];
- modalTagline?: Maybe;
- queryResults: QueryResult;
- isLoadingMore: boolean;
- selectedRows: ReadonlySet;
- downloadModalIsActive: boolean;
- onSubqueryFieldReorder?: (columnKey: string, fields: string[], columnOrder: number[]) => void;
- loadMore: (org: SalesforceOrgUi, isTooling: boolean) => void;
- openDownloadModal: () => void;
- handleCloseModal: (canceled?: boolean) => void;
- handleCopyToClipboard: (fields: string[]) => void;
- handleRowAction: (row: any, action: 'view' | 'edit' | 'clone' | 'apex') => void;
- setSelectedRows: (rows: ReadonlySet) => void;
-}
-
-function ModalDataTable({
- isActive,
- columnKey,
- columns,
- modalTagline,
- queryResults,
- selectedRows,
- isLoadingMore,
- columnDefinitions,
- isTooling,
- org,
- downloadModalIsActive,
- serverUrl,
- skipFrontdoorLogin,
- hasGoogleDriveAccess,
- googleShowUpgradeToPro,
- google_apiKey,
- google_appId,
- google_clientId,
- onSubqueryFieldReorder,
- loadMore,
- openDownloadModal,
- handleCloseModal,
- handleCopyToClipboard,
- handleRowAction,
- setSelectedRows,
-}: ModalDataTableProps) {
- const { records, done, totalSize } = queryResults;
-
- const { fields: _fields, rows } = useMemo(() => {
- const columnKeys = columns?.map((col) => col.key) || null;
- const fields = columns.filter((column) => column.key && !NON_DATA_COLUMN_KEYS.has(column.key)).map((column) => column.key);
- const rows = records.map((row) => {
- return {
- _key: getRowId(row),
- _action: handleRowAction,
- _record: row,
- ...(columnKeys ? flattenRecord(row, columnKeys, false) : row),
- };
- });
- return {
- fields,
- rows,
- };
- }, [columns, handleRowAction, records]);
-
- const [fields, setFields] = useState(_fields);
-
- useEffect(() => {
- setFields(_fields);
- }, [_fields]);
-
- const handleContextMenuAction = useCallback(
- (item: ContextMenuItem, data: ContextMenuActionData) => {
- copySalesforceRecordTableDataToClipboard(item.value, fields, data);
- },
- [fields],
- );
-
- const handleColumnReorder = useCallback((columns: string[], columnOrder: number[]) => {
- setFields(columns);
- onSubqueryFieldReorder && onSubqueryFieldReorder(columnKey, columns, columnOrder);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
- return (
- <>
- {isActive && (
- {
- ev.preventDefault();
- ev.stopPropagation();
- },
- }}
- >
-
-
- Showing {formatNumber(records.length)} of {formatNumber(totalSize)} records
-
- {!done && (
-
- )}
- {isLoadingMore && }
-
-
-
-
-
-
- }
- >
- {
- ev.preventDefault();
- ev.stopPropagation();
- }}
- >
-
- {
- switch (action) {
- case 'view':
- appActionObservable.next({ action: 'VIEW_RECORD', payload: { recordId, objectName } });
- break;
- case 'edit':
- appActionObservable.next({ action: 'EDIT_RECORD', payload: { recordId, objectName } });
- break;
- }
- },
- }}
- />
-
-
-
- )}
- {downloadModalIsActive && (
- {}}
- />
- )}
- >
- );
-}
+/** Re-export of the new grid subquery renderer. */
+export { SubqueryRenderer } from './grid/renderers/SubqueryRenderer';
diff --git a/libs/ui/src/lib/data-table/DataTree.tsx b/libs/ui/src/lib/data-table/DataTree.tsx
index 8ab4df262..310742061 100644
--- a/libs/ui/src/lib/data-table/DataTree.tsx
+++ b/libs/ui/src/lib/data-table/DataTree.tsx
@@ -1,158 +1,59 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
-import { ContextMenuItem, SalesforceOrgUi } from '@jetstream/types';
-import { forwardRef } from 'react';
-import { SortColumn, TreeDataGrid, TreeDataGridProps } from 'react-data-grid';
-import 'react-data-grid/lib/styles.css';
-import { ContextMenu } from '../form/context-menu/ContextMenu';
-import { DataTableFilterContext, DataTableGenericContext } from './data-table-context';
-import './data-table-styles.css';
-import { ColumnWithFilter, ContextMenuActionData, RowWithKey } from './data-table-types';
-import { useDataTable } from './useDataTable';
+import { ExpandedState } from '@tanstack/react-table';
+import { forwardRef, useMemo } from 'react';
+import { DataTableProps, useMappedV2Props } from './DataTable';
+import { DataTableV2 } from './grid/DataTableV2';
+import { DataTableRef, RowWithKey } from './grid/grid-types';
-interface PropsWithServer {
- serverUrl: string;
- skipFrontdoorLogin: boolean;
+/**
+ * Public tree/grouped data table. Bridges the legacy `groupBy` / `expandedGroupIds` (Set of group
+ * values) API to the new grid's TanStack grouping + expanded state.
+ */
+export interface DataTreeProps> extends DataTableProps {
+ groupBy: readonly (keyof T)[];
+ /** Accepted for API compatibility; the grid groups internally so this is not used. */
+ rowGrouper?: (rows: readonly T[], columnKey: keyof T) => Record;
+ expandedGroupIds?: ReadonlySet;
+ onExpandedGroupIdsChange?: (expandedGroupIds: Set) => void;
}
-interface PropsWithoutServer {
- serverUrl?: never;
- skipFrontdoorLogin?: never;
-}
+function DataTreeInner(props: DataTreeProps, ref: React.Ref>) {
+ const { groupBy, expandedGroupIds, onExpandedGroupIdsChange, rowGrouper, ...rest } = props;
+ const grouping = useMemo(() => groupBy.map((key) => String(key)), [groupBy]);
+ const groupColumnId = grouping[0];
-export type DataTreeProps> = DataTreePropsBase &
- (PropsWithServer | PropsWithoutServer);
+ // Bridge: a group row's TanStack id is `${columnId}:${groupingValue}` for single-level grouping.
+ const expanded = useMemo(() => {
+ if (!expandedGroupIds) {
+ return undefined;
+ }
+ const state: Record = {};
+ expandedGroupIds.forEach((value) => (state[`${groupColumnId}:${value}`] = true));
+ return state;
+ }, [expandedGroupIds, groupColumnId]);
-interface DataTreePropsBase> extends Omit<
- TreeDataGridProps,
- 'columns' | 'rows' | 'rowKeyGetter'
-> {
- data: T[];
- columns: ColumnWithFilter[];
- serverUrl?: string;
- skipFrontdoorLogin?: boolean;
- org?: SalesforceOrgUi;
- quickFilterText?: string | null;
- includeQuickFilter?: boolean;
- initialSortColumns?: SortColumn[];
- context?: TContext;
- /** Must be stable to avoid constant re-renders */
- contextMenuItems?: ContextMenuItem[];
- /** Must be stable to avoid constant re-renders */
- contextMenuAction?: (item: ContextMenuItem, data: ContextMenuActionData) => void;
- getRowKey: (row: T) => string;
- rowAlwaysVisible?: (row: T) => boolean;
- ignoreRowInSetFilter?: (row: T) => boolean;
- onReorderColumns?: (columns: string[], columnOrder: number[]) => void;
- onSortedAndFilteredRowsChange?: (rows: readonly T[]) => void;
-}
+ const onExpandedChange = useMemo(() => {
+ if (!onExpandedGroupIdsChange) {
+ return undefined;
+ }
+ return (state: ExpandedState) => {
+ if (state === true) {
+ return;
+ }
+ const prefix = `${groupColumnId}:`;
+ const next = new Set(
+ Object.keys(state)
+ .filter((key) => state[key])
+ .map((key) => (key.startsWith(prefix) ? key.slice(prefix.length) : key)),
+ );
+ onExpandedGroupIdsChange(next);
+ };
+ }, [onExpandedGroupIdsChange, groupColumnId]);
-export const DataTree = forwardRef>(
- (
- {
- data,
- columns: _columns,
- serverUrl,
- skipFrontdoorLogin,
- org,
- quickFilterText,
- includeQuickFilter,
- initialSortColumns,
- context,
- contextMenuItems,
- contextMenuAction,
- getRowKey,
- ignoreRowInSetFilter,
- rowAlwaysVisible,
- onReorderColumns,
- onSortedAndFilteredRowsChange,
- ...rest
- }: DataTreeProps,
- ref,
- ) => {
- const {
- gridId,
- columns,
- sortColumns,
- renderers,
- filters,
- reorderedColumns,
- filterSetValues,
- filteredRows,
- contextMenuProps,
- setSortColumns,
- updateFilter,
- handleReorderColumns,
- handleCellKeydown,
- handleCloseContextMenu,
- } = useDataTable({
- data,
- columns: _columns,
- serverUrl,
- skipFrontdoorLogin,
- org,
- quickFilterText,
- includeQuickFilter,
- contextMenuItems,
- initialSortColumns,
- ref,
- contextMenuAction,
- getRowKey,
- ignoreRowInSetFilter,
- rowAlwaysVisible,
- onReorderColumns,
- onSortedAndFilteredRowsChange,
- });
+ const mapped = useMappedV2Props(rest as DataTableProps);
+ return {...mapped} grouping={grouping} expanded={expanded} onExpandedChange={onExpandedChange} ref={ref} role="treegrid" />;
+}
- return (
-
-
- {
- setSortColumns(columns);
- // Allow subscriber to subscribe to changes as a side-effect
- rest?.onSortColumnsChange?.(columns);
- }}
- />
- {contextMenuProps && contextMenuItems && contextMenuAction && (
- {
- contextMenuAction(item, {
- row: filteredRows[contextMenuProps.rowIdx] as T,
- rowIdx: contextMenuProps.rowIdx,
- rows: filteredRows as T[],
- column: contextMenuProps.column,
- columns,
- });
- handleCloseContextMenu();
- }}
- onClose={handleCloseContextMenu}
- />
- )}
-
-
- );
- },
-);
+export const DataTree = forwardRef(DataTreeInner) as unknown as (
+ props: DataTreeProps & { ref?: React.Ref> },
+) => React.ReactElement;
diff --git a/libs/ui/src/lib/data-table/SalesforceRecordDataTable.tsx b/libs/ui/src/lib/data-table/SalesforceRecordDataTable.tsx
index e29f9dc39..23988d131 100644
--- a/libs/ui/src/lib/data-table/SalesforceRecordDataTable.tsx
+++ b/libs/ui/src/lib/data-table/SalesforceRecordDataTable.tsx
@@ -2,12 +2,11 @@
import { css } from '@emotion/react';
import { logger } from '@jetstream/shared/client-logger';
import { queryRemaining } from '@jetstream/shared/data';
-import { formatNumber, tracker } from '@jetstream/shared/ui-utils';
+import { formatNumber, hasCtrlOrMeta, isEnterKey, tracker, useGlobalEventHandler } from '@jetstream/shared/ui-utils';
import { flattenRecord, getIdFromRecordUrl, groupByFlat, nullifyEmptyStrings } from '@jetstream/shared/utils';
import { CloneEditView, ContextMenuItem, Field, Maybe, QueryResults, SalesforceOrgUi, SobjectCollectionResponse } from '@jetstream/types';
import uniqueId from 'lodash/uniqueId';
import { Fragment, ReactNode, memo, useCallback, useEffect, useRef, useState } from 'react';
-import { Column, RowsChangeData } from 'react-data-grid';
import SearchInput from '../form/search-input/SearchInput';
import Grid from '../grid/Grid';
import AutoFullHeightContainer from '../layout/AutoFullHeightContainer';
@@ -25,6 +24,7 @@ import {
copySalesforceRecordTableDataToClipboard,
getColumnDefinitions,
} from './data-table-utils';
+import { RowsChangeData } from './grid/rdg-compat';
const SFDC_EMPTY_ID = '000000000000000AAA';
@@ -120,7 +120,7 @@ export const SalesforceRecordDataTable = memo(
onReloadQuery,
}: SalesforceRecordDataTableProps) => {
const isMounted = useRef(true);
- const [columns, setColumns] = useState[]>();
+ const [columns, setColumns] = useState[]>();
const [subqueryColumnsMap, setSubqueryColumnsMap] = useState[]>>();
const [records, setRecords] = useState();
// Same as records but with additional data added
@@ -139,6 +139,8 @@ export const SalesforceRecordDataTable = memo(
const [visibleRecordCount, setVisibleRecordCount] = useState(records?.length);
const [isSavingRecords, setIsSavingRecords] = useState(false);
+ // Synchronous guard so a key-repeat / rapid Cmd+Enter can't launch concurrent saves before state flushes.
+ const isSavingRef = useRef(false);
useEffect(() => {
isMounted.current = true;
@@ -182,7 +184,7 @@ export const SalesforceRecordDataTable = memo(
if (fieldMetadata && queryResults) {
const { parentColumns, subqueryColumns } = getColumnDefinitions(queryResults, isTooling, fieldMetadata, fieldMetadataSubquery);
- setColumns(addFieldLabelToColumn(parentColumns, fieldMetadata));
+ setColumns(addFieldLabelToColumn(parentColumns, fieldMetadata) as unknown as ColumnWithFilter[]);
// If there are subqueries, update field definition
if (fieldMetadataSubquery) {
@@ -338,15 +340,18 @@ export const SalesforceRecordDataTable = memo(
};
const handleSaveRecords = async () => {
+ // Guards stay OUTSIDE the try: an early `return` inside it still runs the `finally`, which would
+ // clear isSavingRef / saving state while a concurrent save is mid-flight — allowing overlapping saves.
+ if (!rows || isSavingRef.current) {
+ return;
+ }
+ if (!dirtyRows.length) {
+ setRecords((records) => (records ? [...records] : records));
+ return;
+ }
+ isSavingRef.current = true;
+ setIsSavingRecords(true);
try {
- if (!rows) {
- return;
- }
- if (!dirtyRows.length) {
- setRecords((records) => (records ? [...records] : records));
- return;
- }
- setIsSavingRecords(true);
const modifiedRecords = dirtyRows.map((row) =>
nullifyEmptyStrings(
Array.from(row._touchedColumns).reduce(
@@ -403,10 +408,26 @@ export const SalesforceRecordDataTable = memo(
});
tracker.error('Error saving records - inline query', ex);
} finally {
+ isSavingRef.current = false;
setIsSavingRecords(false);
}
};
+ // Cmd/Ctrl+Enter saves modified records. A live ref lets the stable global handler call the latest
+ // save logic, and deferring to the next tick lets an in-progress cell edit (committed on Enter)
+ // settle into dirty state before saving. `handleSaveRecords` no-ops when there is nothing to save.
+ const saveRecordsRef = useRef(handleSaveRecords);
+ saveRecordsRef.current = handleSaveRecords;
+ const handleSaveShortcut = useCallback((event: KeyboardEvent) => {
+ if (!isEnterKey(event as any) || !hasCtrlOrMeta(event as any)) {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ window.setTimeout(() => saveRecordsRef.current());
+ }, []);
+ useGlobalEventHandler('keydown', handleSaveShortcut);
+
function handleSubqueryFieldsChanged(columnKey: string, newFields: string[], columnOrder: number[]) {
onSubqueryFieldReorder(columnKey, newFields, columnOrder);
// FIXME: this causes an infinite render loop
diff --git a/libs/ui/src/lib/data-table/__tests__/useDataTable.spec.tsx b/libs/ui/src/lib/data-table/__tests__/useDataTable.spec.tsx
deleted file mode 100644
index b7aea6a4a..000000000
--- a/libs/ui/src/lib/data-table/__tests__/useDataTable.spec.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-import { act, renderHook } from '@testing-library/react';
-import { describe, expect, test } from 'vitest';
-import { ColumnWithFilter, DataTableRef } from '../data-table-types';
-import { useDataTable } from '../useDataTable';
-
-interface Row {
- _key: string;
- Id: string;
- Name: string;
- Notes: string;
-}
-
-function buildProps(overrides: Partial[0]> = {}) {
- const columns: ColumnWithFilter[] = [
- { key: 'Id', name: 'Id' },
- { key: 'Name', name: 'Name' },
- { key: 'Notes', name: 'Notes' },
- ];
- const data: Row[] = [
- { _key: '1', Id: '001', Name: 'Alpha', Notes: 'hello world' },
- { _key: '2', Id: '002', Name: 'Bravo', Notes: 'foo bar' },
- ];
- return {
- data,
- columns,
- includeQuickFilter: true,
- getRowKey: (row: Row) => row._key,
- ref: { current: null } as React.RefObject>,
- ...overrides,
- };
-}
-
-describe('useDataTable quick filter', () => {
- test('matches rows case-insensitively with a simple search', () => {
- const props = buildProps({ quickFilterText: 'ALPHA' });
- const { result } = renderHook(() => useDataTable(props));
- // rowFilterText is populated via useEffect; flush once data is ready
- act(() => {});
- expect(result.current.filteredRows).toHaveLength(1);
- expect((result.current.filteredRows[0] as Row).Name).toBe('Alpha');
- });
-
- test('does not throw on very large pasted input with regex metacharacters', () => {
- // Simulate a user pasting an entire tab-separated row including regex-sensitive characters.
- // Reproduces the production crash where building `new RegExp(escapeRegExp(...))` threw
- // "Invalid regular expression" during the first `.test()` call on massive input.
- const giantPaste = 'Id\tName\tNotes\t' + '2026-04-17T11:46:03.000+0000 | V4 - step.failed (see error) . | '.repeat(400);
- const props = buildProps({ quickFilterText: giantPaste });
- expect(() => {
- const { result } = renderHook(() => useDataTable(props));
- act(() => {});
- // Force evaluation of filteredRows (the crash site)
- void result.current.filteredRows.length;
- }).not.toThrow();
- });
-
- test('returns no rows when the filter text does not match', () => {
- const props = buildProps({ quickFilterText: 'nonexistent-text-xyz' });
- const { result } = renderHook(() => useDataTable(props));
- act(() => {});
- expect(result.current.filteredRows).toHaveLength(0);
- });
-});
diff --git a/libs/ui/src/lib/data-table/data-table-context.tsx b/libs/ui/src/lib/data-table/data-table-context.tsx
index b074d08a4..695c81485 100644
--- a/libs/ui/src/lib/data-table/data-table-context.tsx
+++ b/libs/ui/src/lib/data-table/data-table-context.tsx
@@ -1,17 +1,9 @@
-import { NOOP } from '@jetstream/shared/utils';
-import { createContext } from 'react';
-import { FilterContextProps, SelectedRowsContext, SubqueryContext } from './data-table-types';
-
-// Used to ensure that renderers and filters can have access to global state
-export const DataTableFilterContext = createContext({
- filterSetValues: {},
- filters: {},
- updateFilter: NOOP,
-});
-export const DataTableSubqueryContext = createContext(undefined);
-export const DataTableSelectedContext = createContext({ selectedRowIds: new Set() });
-// Used to allow arbitrary data to be accessed by renderers
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-export const DataTableGenericContext = createContext>({});
-
-// serverUrl, org, columnDefinitions, isTooling, google_apiKey, google_appId, google_clientId
+/**
+ * Re-export of the new grid contexts under their legacy names.
+ */
+export {
+ GridFilterContext as DataTableFilterContext,
+ GridGenericContext as DataTableGenericContext,
+ GridSelectedContext as DataTableSelectedContext,
+ GridSubqueryContext as DataTableSubqueryContext,
+} from './grid/grid-context';
diff --git a/libs/ui/src/lib/data-table/data-table-styles.css b/libs/ui/src/lib/data-table/data-table-styles.css
deleted file mode 100644
index 2b8c82849..000000000
--- a/libs/ui/src/lib/data-table/data-table-styles.css
+++ /dev/null
@@ -1,72 +0,0 @@
-/* react-data-grid sets its own `color-scheme: light dark` on .rdg and has a
- `@media (prefers-color-scheme: dark) .rdg:not(.rdg-light)` rule that
- hard-codes dark theme variables. Both bypass our explicit user color scheme
- preference, so we scope our overrides under the body's slds-color-scheme--*
- class (specificity 0,2,1) to win against the rdg media-query rule (0,2,0)
- and force color-scheme to inherit from the body so SLDS 2 light-dark()
- tokens resolve from the user pref instead of the OS preference. */
-body.slds-color-scheme--light .rdg,
-body.slds-color-scheme--dark .rdg,
-body.slds-color-scheme--system .rdg {
- color-scheme: inherit;
- --rdg-color: var(--slds-g-color-on-surface-2);
- --rdg-background-color: var(--slds-g-color-surface-container-1);
- --rdg-border-color: var(--slds-g-color-border-1);
- --rdg-header-background-color: var(--slds-g-color-surface-container-2);
- --rdg-header-draggable-background-color: var(--slds-g-color-surface-container-3);
- --rdg-row-hover-background-color: var(--slds-g-color-surface-2);
- --rdg-row-selected-background-color: var(--slds-s-table-row-color-background-selected, var(--slds-g-color-brand-base-90));
- --rdg-row-selected-hover-background-color: var(--slds-g-color-brand-base-80);
- --rdg-checkbox-focus-color: var(--slds-g-color-border-accent-1);
- --rdg-selection-color: var(--slds-g-color-border-accent-1);
-}
-
-/* Match SLDS focus styling on grid cells: replace rdg's default outline with
- the inset double-border focus shadow used by .slds-table [role=gridcell]. */
-.rdg .rdg-cell[aria-selected='true'] {
- outline: none;
- box-shadow: var(--slds-g-shadow-insetinverse-focus-1);
-}
-
-.rdg {
- &.fill-grid {
- block-size: 100%;
- }
-
- .select-checkbox {
- display: flex;
- align-items: center;
- justify-content: center;
- > input {
- margin: 0;
- }
- }
-
- .rdg-checkbox {
- &:checked {
- outline: none !important;
- }
- }
-
- .rdg-row {
- &.save-error {
- background-color: var(--slds-g-color-error-base-90, #ffdede);
- }
- }
-
- .rdg-cell {
- &.slds-is-edited {
- font-weight: 600;
- background-color: var(--_slds-c-datatable-color-background-edit, var(--slds-g-color-palette-yellow-95, #faffbd));
- }
- &.active-item-error {
- outline-color: var(--slds-g-color-error-1, red);
- outline-width: 2px;
- outline-offset: -2px;
- outline-style: solid;
- }
- &.copied {
- background: var(--slds-g-color-warning-base-90, #faffbd);
- }
- }
-}
diff --git a/libs/ui/src/lib/data-table/data-table-types.ts b/libs/ui/src/lib/data-table/data-table-types.ts
index 65edfaa93..c99f334a4 100644
--- a/libs/ui/src/lib/data-table/data-table-types.ts
+++ b/libs/ui/src/lib/data-table/data-table-types.ts
@@ -1,149 +1,4 @@
-/* eslint-disable @typescript-eslint/no-explicit-any */
-import { Maybe, SalesforceOrgUi } from '@jetstream/types';
-import { Column } from 'react-data-grid';
-
-export type RowWithKey = Record & { _key: string };
-export type RowSalesforceRecordWithKey = RowWithKey & {
- _action: (row: RowWithKey, action: 'view' | 'edit' | 'clone' | 'apex') => void;
- _idx: number;
- _record: Record;
- _touchedColumns: Set;
- _saveError?: Maybe;
-};
-export type ColumnType =
- | 'text'
- | 'number'
- | 'subquery'
- | 'object'
- | 'location'
- | 'date'
- | 'time'
- | 'boolean'
- | 'address'
- | 'salesforceId'
- | 'salesforceName'
- | 'textOrSalesforceId';
-export type FilterType = 'TEXT' | 'NUMBER' | 'DATE' | 'TIME' | 'SET' | 'BOOLEAN_SET';
-export const FILTER_SET_TYPES = new Set(['SET', 'BOOLEAN_SET']);
-
-export interface DataTableRef {
- hasSortApplied: () => boolean;
- getFilteredAndSortedRows: () => readonly T[];
- hasReorderedColumns: () => boolean;
- /** Takes into account re-ordered columns */
- getCurrentColumns: () => ColumnWithFilter[];
- /** Takes into account re-ordered columns */
- getCurrentColumnNames: () => string[];
-}
-
-export type DataTableFilter =
- | DataTableTextFilter
- | DataTableNumberFilter
- | DataTableDateFilter
- | DataTableTimeFilter
- | DataTableSetFilter
- | DataTableBooleanSetFilter;
-
-export interface DataTableTextFilter {
- type: 'TEXT';
- value: string;
-}
-
-export interface DataTableNumberFilter {
- type: 'NUMBER';
- value: string | null;
- comparator: 'EQUALS' | 'GREATER_THAN' | 'LESS_THAN';
-}
-
-export interface DataTableDateFilter {
- type: 'DATE';
- value: string | null;
- comparator: 'EQUALS' | 'GREATER_THAN' | 'LESS_THAN';
-}
-
-export interface DataTableTimeFilter {
- type: 'TIME';
- value: string;
- comparator: 'EQUALS' | 'GREATER_THAN' | 'LESS_THAN';
-}
-
-export interface DataTableSetFilter {
- type: 'SET';
- value: string[];
-}
-
-export interface DataTableBooleanSetFilter {
- type: 'BOOLEAN_SET';
- value: string[];
-}
-
-export interface ColumnWithFilter extends Column {
- /** getValue is used when filtering or sorting rows */
- readonly getValue?: (params: { row: TRow; column: ColumnWithFilter }) => string | null;
- readonly filters?: FilterType[];
-}
-
-export interface SalesforceQueryColumnDefinition {
- parentColumns: ColumnWithFilter[];
- subqueryColumns: Record[]>;
-}
-
-export interface FilterContextProps {
- filterSetValues: Record;
- filters: Record;
- updateFilter: (column: string, filter: DataTableFilter) => void;
-}
-
-export interface SubqueryContext {
- serverUrl: string;
- skipFrontdoorLogin: boolean;
- org: SalesforceOrgUi;
- isTooling: boolean;
- columnDefinitions?: Record[]>;
- onSubqueryFieldReorder?: (columnKey: string, fields: string[], columnOrder: number[]) => void;
- hasGoogleDriveAccess: boolean;
- googleShowUpgradeToPro: boolean;
- google_apiKey: string;
- google_appId: string;
- google_clientId: string;
-}
-
-export interface SelectedRowsContext {
- selectedRowIds: Set;
- getRowKey?: (row: TRow) => string;
-}
-
-export interface SalesforceLocationField {
- latitude: number;
- longitude: number;
-}
-
-export interface SalesforceAddressField {
- city?: string;
- country?: string;
- CountryCode?: string;
- latitude?: number;
- longitude?: number;
- postalCode?: string;
- state?: string;
- StateCode?: string;
- street?: string;
-}
-
-export type ContextAction =
- | 'COPY_CELL'
- | 'COPY_ROW_EXCEL'
- | 'COPY_ROW_JSON'
- | 'COPY_COL'
- | 'COPY_COL_JSON'
- | 'COPY_COL_NO_HEADER'
- | 'COPY_TABLE'
- | 'COPY_TABLE_JSON';
-
-export type ContextMenuActionData = {
- row: T;
- rows: T[];
- rowIdx: number;
- column: ColumnWithFilter;
- columns: ColumnWithFilter[];
-};
+/**
+ * Re-export of the new grid types. The old react-data-grid-based types now live in `grid/grid-types.ts`.
+ */
+export * from './grid/grid-types';
diff --git a/libs/ui/src/lib/data-table/data-table-utils.tsx b/libs/ui/src/lib/data-table/data-table-utils.tsx
index a6acdbd54..66c2da3b4 100644
--- a/libs/ui/src/lib/data-table/data-table-utils.tsx
+++ b/libs/ui/src/lib/data-table/data-table-utils.tsx
@@ -1,946 +1,23 @@
-/* eslint-disable @typescript-eslint/no-explicit-any */
-import { logger } from '@jetstream/shared/client-logger';
-import { DATE_FORMATS, RECORD_PREFIX_MAP } from '@jetstream/shared/constants';
-import { copyRecordsToClipboard } from '@jetstream/shared/ui-utils';
-import { ensureBoolean, getIdFromRecordUrl, pluralizeFromNumber } from '@jetstream/shared/utils';
-import { ContextMenuItem, Field, Maybe, QueryResults, QueryResultsColumn } from '@jetstream/types';
-import { FieldSubquery, getField, getFlattenedFields, isFieldSubquery } from '@jetstreamapp/soql-parser-js';
-import { isAfter } from 'date-fns/isAfter';
-import { isBefore } from 'date-fns/isBefore';
-import { isSameDay } from 'date-fns/isSameDay';
-import { isValid as isDateValid } from 'date-fns/isValid';
-import { parse as parseDate } from 'date-fns/parse';
-import { parseISO } from 'date-fns/parseISO';
-import { startOfDay } from 'date-fns/startOfDay';
-import { startOfMinute } from 'date-fns/startOfMinute';
-import isNil from 'lodash/isNil';
-import isNumber from 'lodash/isNumber';
-import isObject from 'lodash/isObject';
-import isString from 'lodash/isString';
-import uniqueId from 'lodash/uniqueId';
-import { SelectColumn, SELECT_COLUMN_KEY as _SELECT_COLUMN_KEY } from 'react-data-grid';
-import {
- DataTableEditorBoolean,
- DataTableEditorDate,
- DataTableEditorText,
- dataTableEditorDropdownWrapper,
- dataTableEditorRecordLookup,
-} from './DataTableEditors';
-import {
- ActionRenderer,
- BooleanRenderer,
- ComplexDataRenderer,
- FilterRenderer,
- GenericRenderer,
- HeaderFilter,
- IdLinkRenderer,
- NameLinkRenderer,
- TextOrIdLinkRenderer,
-} from './DataTableRenderers';
-import { SubqueryRenderer } from './DataTableSubqueryRenderer';
-import {
- dataTableAddressValueFormatter,
- dataTableDateFormatter,
- dataTableLocationFormatter,
- dataTableTimeFormatter,
-} from './data-table-formatters';
-import {
- ColumnType,
- ColumnWithFilter,
- ContextAction,
- ContextMenuActionData,
- DataTableFilter,
- FilterType,
- RowWithKey,
- SalesforceQueryColumnDefinition,
-} from './data-table-types';
-
-const SFDC_EMPTY_ID = '000000000000000AAA';
-
-export const EMPTY_FIELD = '-BLANK-';
-export const ACTION_COLUMN_KEY = '_actions';
-export const RECORD_ERROR_COLUMN_KEY = '_saveError';
-export const SELECT_COLUMN_KEY = _SELECT_COLUMN_KEY;
-export const NON_DATA_COLUMN_KEYS = new Set([SELECT_COLUMN_KEY, ACTION_COLUMN_KEY]);
-
-export function getRowId(data: any): string {
- if (data?._key) {
- return data._key;
- }
- if (data?.key) {
- return data.key;
- }
- if (data?.attributes?.type === 'AggregateResult') {
- return uniqueId('row-id');
- }
- let nodeId = data?.attributes?.url || data.Id || data.id || data.key;
- if (!nodeId || (isString(nodeId) && nodeId.endsWith(SFDC_EMPTY_ID)) || data.Id === SFDC_EMPTY_ID) {
- nodeId = uniqueId('row-id');
- }
- return nodeId;
-}
-
/**
- * Get columns for a generic table. Use this when the data is provided by the user and types of columns are generally unknown
- *
- * @param headers
- * @param defaultFilters If no type is provided, the default filters that will be applied
- * @returns
- */
-export function getColumnsForGenericTable(
- headers: { label: string; key: string; columnProps?: Partial>; type?: ColumnType }[],
- defaultFilters: FilterType[] = ['TEXT', 'SET'],
-): ColumnWithFilter[] {
- return headers.map(({ label, key, columnProps, type }) => {
- const column: Mutable> = {
- name: label,
- key,
- resizable: true,
- sortable: true,
- filters: defaultFilters,
- renderCell: TextOrIdLinkRenderer,
- renderHeaderCell: (props) => (
-
- {({ filters, filterSetValues, updateFilter }) => (
-
- )}
-
- ),
- };
- if (type) {
- updateColumnFromType(column, type);
- }
- return { ...column, ...columnProps } as ColumnWithFilter;
- });
-}
-
-/**
- * Produce table columns from a Salesforce query
- * @param results
- * @param isTooling
- * @returns
- */
-export function getColumnDefinitions(
- results: QueryResults,
- isTooling: boolean,
- fieldMetadata?: Maybe>,
- fieldMetadataSubquery?: Maybe>>,
-): SalesforceQueryColumnDefinition {
- // if we have id, include record actions
- const includeRecordActions =
- !isTooling && results.queryResults.records.length
- ? !!(results.queryResults.records[0]?.Id || results.queryResults.records[0]?.attributes.url)
- : false;
- const output: SalesforceQueryColumnDefinition = {
- parentColumns: [],
- subqueryColumns: {},
- };
-
- // Build a set of subquery relationship names for reliable lookup
- // (positional index is unreliable because getFlattenedFields uses flatMap, e.g. TYPEOF expands to multiple entries)
- const subqueryRelationshipNames = new Set(
- results.parsedQuery?.fields?.filter(isFieldSubquery).map((f) => f.subquery.relationshipName.toLowerCase()) || [],
- );
-
- // map each field to the returned metadata from SFDC
- let queryColumnsByPath: Record = {};
- if (results.columns?.columns) {
- queryColumnsByPath = results.columns.columns.reduce(
- (out, curr) => {
- out[curr.columnFullPath.toLowerCase()] = curr;
- // some subqueries (e.x. TYPEOF) is not returned from the salesforce "column.childColumnPaths"
- // in this case, we need to mock the response structure
- // https://github.com/paustint/jetstream/issues/3#issuecomment-728028624
- if (!Array.isArray(curr.childColumnPaths) && subqueryRelationshipNames.has(curr.columnFullPath.toLowerCase())) {
- curr.childColumnPaths = [];
- }
-
- if (Array.isArray(curr.childColumnPaths)) {
- curr.childColumnPaths.forEach((subqueryField) => {
- out[subqueryField.columnFullPath.toLowerCase()] = {
- ...subqueryField,
- columnFullPath: subqueryField.columnFullPath.split('.').slice(1).join('.'), // remove child relationship name
- } as QueryResultsColumn;
- });
- }
- return out;
- },
- {} as Record,
- );
- }
- // If there is a FIELDS('') clause in the query, then we know the data will not be shown
- // in this case, fall back to Salesforce column data instead of the query results
- const hasFieldsQuery = results.parsedQuery?.fields?.some(
- (field) => field.type === 'FieldFunctionExpression' && field.functionName === 'FIELDS',
- );
- if (results.parsedQuery && hasFieldsQuery) {
- results.parsedQuery.fields = results.columns?.columns?.map((column) => getField(column.columnFullPath));
- }
-
- // Base fields
- const parentColumns: ColumnWithFilter[] = getFlattenedFields(results.parsedQuery || {}).map((field) =>
- getQueryResultColumn({
- field,
- queryColumnsByPath,
- isSubquery: subqueryRelationshipNames.has(field.toLowerCase()),
- fieldMetadata,
- }),
- );
-
- // set checkbox as first column
- if (parentColumns.length > 0) {
- parentColumns.unshift({
- ...SelectColumn,
- key: SELECT_COLUMN_KEY,
- resizable: false,
- });
- if (includeRecordActions) {
- parentColumns.unshift({
- key: ACTION_COLUMN_KEY,
- name: '',
- resizable: true,
- width: 116,
- minWidth: 100,
- maxWidth: 150,
- renderCell: ActionRenderer,
- frozen: true,
- sortable: false,
- });
- }
- }
-
- output.parentColumns = parentColumns;
-
- // subquery fields - only used if user clicks "view data" on a field so that the table can be built properly
- results.parsedQuery?.fields
- ?.filter((field) => isFieldSubquery(field))
- .forEach((parentField: FieldSubquery) => {
- output.subqueryColumns[parentField.subquery.relationshipName.toLowerCase()] = getFlattenedFields(parentField.subquery || {}).map(
- (field) =>
- getQueryResultColumn({
- field,
- subqueryRelationshipName: parentField.subquery.relationshipName,
- queryColumnsByPath,
- isSubquery: false,
- allowEdit: false,
- fieldMetadata: fieldMetadataSubquery?.[parentField.subquery.relationshipName.toLowerCase()],
- }),
- );
- });
-
- return output;
-}
-
-type Mutable = {
- -readonly [Key in keyof Type]: Type[Key];
-};
-
-function getQueryResultColumn({
- field,
- subqueryRelationshipName,
- queryColumnsByPath,
- isSubquery,
- fieldMetadata,
- allowEdit = true,
-}: {
- field: string;
- subqueryRelationshipName?: string;
- queryColumnsByPath: Record;
- isSubquery: boolean;
- fieldMetadata?: Maybe>;
- allowEdit?: boolean;
-}): ColumnWithFilter {
- const column: Mutable> = {
- name: field,
- key: field,
- cellClass: (row: any) => {
- const classes = ['slds-truncate'];
- if (row._touchedColumns instanceof Set && (row._touchedColumns as Set).has(field) && row[field] !== row._record?.[field]) {
- classes.push('slds-is-edited');
- if (row._saveError) {
- classes.push('active-item-error');
- }
- }
- return classes.join(' ');
- },
- resizable: true,
- sortable: true,
- draggable: true,
- width: 200,
- filters: ['TEXT', 'SET'],
- renderHeaderCell: (props) => (
-
- {({ filters, filterSetValues, updateFilter }) => (
-
- )}
-
- ),
- };
-
- let fieldLowercase = field.toLowerCase();
- if (subqueryRelationshipName) {
- fieldLowercase = `${subqueryRelationshipName.toLowerCase()}.${fieldLowercase}`;
- }
- const queryResultColumn = queryColumnsByPath[fieldLowercase];
- let resolvedType: ColumnType = 'text';
- if (queryResultColumn) {
- column.name = queryResultColumn.columnFullPath;
- column.key = queryResultColumn.columnFullPath;
- resolvedType = getColumnTypeFromQueryResultsColumn(queryResultColumn);
- updateColumnFromType(column, resolvedType);
- // exclude related records from edit mode
- if (allowEdit && !queryResultColumn.columnFullPath?.includes('.')) {
- updateColumnWithEditMode(column, queryResultColumn, fieldMetadata);
- }
- } else if (field.endsWith('Id')) {
- resolvedType = 'salesforceId';
- updateColumnFromType(column, 'salesforceId');
- } else if (isSubquery) {
- resolvedType = 'subquery';
- updateColumnFromType(column, 'subquery');
- }
-
- // Upgrade plain-text Name / relationship Name columns (e.g. Name, Account.Name, Account.Owner.Name)
- // to a clickable record-lookup popover, mirroring the IdLinkRenderer behavior.
- // Skipped for subquery child columns, aggregate (GROUP BY) columns, and anything that already
- // resolved to a non-text type (salesforceId, boolean, date, etc).
- // Use the canonical resolved column path when available so Name-field detection does not depend
- // on the original query casing from getFlattenedFields(results.parsedQuery).
- const canonicalColumnPath = queryResultColumn?.columnFullPath ?? column.key;
- const isNameField =
- !!fieldMetadata?.[field.toLowerCase()]?.nameField || canonicalColumnPath === 'Name' || canonicalColumnPath.endsWith('.Name');
- if (!subqueryRelationshipName && !queryResultColumn?.aggregate && resolvedType === 'text' && isNameField) {
- updateColumnFromType(column, 'salesforceName');
- }
-
- return column;
-}
-
-function getColumnTypeFromQueryResultsColumn(col: QueryResultsColumn): ColumnType {
- if (col.booleanType) {
- return 'boolean';
- } else if (col.numberType) {
- return 'number';
- } else if (col.apexType === 'Id') {
- return 'salesforceId';
- } else if (col.apexType === 'Date' || col.apexType === 'Datetime') {
- return 'date';
- } else if (col.apexType === 'Time') {
- return 'time';
- } else if (col.apexType === 'Address') {
- return 'address';
- } else if (col.apexType === 'Location') {
- return 'location';
- } else if (col.apexType === 'complexvaluetype' || col.columnName === 'Metadata') {
- return 'object';
- } else if (Array.isArray(col.childColumnPaths)) {
- return 'subquery';
- }
- return 'text';
-}
-
-/**
- * Based on field type, update formatter and filters
- *
- * @param fieldType
- * @param defaultProps
- * @returns
- */
-export function setColumnFromType(key: string, fieldType: ColumnType, defaultProps?: Partial>>) {
- const column: Partial>> = { ...defaultProps };
- column.renderHeaderCell = (props) => (
-
- {({ filters, filterSetValues, updateFilter }) => (
-
- )}
-
- );
- updateColumnFromType(column as Mutable>, fieldType);
- return column;
-}
-
-/**
- * Get type of data for column
- *
- * @param value
- * @param allowObject Object will show a link to "view data" in a modal. Set this to false if the data table is already in a modal to avoid stacked modals
- * @returns
- */
-export function getRowTypeFromValue(value: unknown, allowObject = true): ColumnType {
- if (allowObject && (isObject(value) || Array.isArray(value))) {
- return 'object';
- } else if (typeof value === 'boolean') {
- return 'boolean';
- } else if (typeof value === 'number') {
- return 'number';
- }
- return 'textOrSalesforceId';
-}
-
-/**
- * Based on field type, update formatters and filters
- * @param column
- * @param fieldType
- */
-export function updateColumnFromType(column: Mutable>, fieldType: ColumnType) {
- column.filters = ['TEXT', 'SET'];
- switch (fieldType) {
- case 'text':
- column.renderCell = GenericRenderer;
- break;
- case 'number':
- // TODO: add number filter (gte/lte/equals/etc..)
- break;
- case 'subquery':
- column.filters = ['SET'];
- column.renderCell = SubqueryRenderer;
- column.getValue = ({ column, row }) => {
- const results = row[column.key];
- if (!results || !results.totalSize) {
- return null;
- }
- return `${results.records.length} ${pluralizeFromNumber('record', results.records.length)}`;
- };
- break;
- case 'object':
- column.filters = [];
- column.renderCell = ComplexDataRenderer;
- break;
- case 'location':
- column.renderCell = ({ column, row }) => dataTableLocationFormatter(row[column.key]);
- column.getValue = ({ column, row }) => dataTableLocationFormatter(row[column.key]);
- break;
- case 'date':
- column.filters = ['DATE', 'SET'];
- column.renderCell = ({ column, row }) => dataTableDateFormatter(row[column.key]);
- column.getValue = ({ column, row }) => dataTableDateFormatter(row[column.key]);
- break;
- case 'time':
- column.filters = ['TIME', 'SET'];
- column.renderCell = ({ column, row }) => dataTableTimeFormatter(row[column.key]);
- column.getValue = ({ column, row }) => dataTableTimeFormatter(row[column.key]);
- break;
- case 'boolean':
- column.filters = ['BOOLEAN_SET'];
- column.renderCell = BooleanRenderer;
- column.width = 100;
- break;
- case 'address':
- column.renderCell = ({ column, row }) => dataTableAddressValueFormatter(row[column.key]);
- column.getValue = ({ column, row }) => dataTableAddressValueFormatter(row[column.key]);
- break;
- case 'salesforceId':
- column.renderCell = IdLinkRenderer;
- column.width = 175;
- break;
- case 'salesforceName':
- column.renderCell = NameLinkRenderer;
- break;
- case 'textOrSalesforceId':
- column.renderCell = TextOrIdLinkRenderer;
- column.width = 175;
- break;
- default:
- break;
- }
-}
-
-/**
- * Allow inline editing of a cell based on field type or column results
- */
-export function updateColumnWithEditMode(
- column: Mutable>,
- { updatable, booleanType, apexType, columnName }: QueryResultsColumn,
- fieldMetadata: Maybe> = {},
-) {
- column.editable = false;
- fieldMetadata = fieldMetadata || {};
- const field = fieldMetadata[column.key.toLowerCase()];
- const type = field?.type;
- if (
- (field && !field?.updateable) ||
- !updatable ||
- type === 'complexvalue' ||
- type === 'address' ||
- type === 'anyType' ||
- apexType === 'complexvaluetype' ||
- columnName === 'Metadata'
- ) {
- return;
- } else if (type === 'boolean' || booleanType) {
- column.editable = true;
- column.editorOptions = {
- commitOnOutsideClick: false,
- displayCellContent: true,
- };
- column.renderEditCell = DataTableEditorBoolean;
- } else if (type === 'date' || apexType === 'Date' || type === 'datetime' || apexType === 'Datetime') {
- column.editable = true;
- column.editorOptions = {
- commitOnOutsideClick: false,
- displayCellContent: true,
- };
- column.renderEditCell = DataTableEditorDate;
- } else if (field?.picklistValues && (type === 'picklist' || type === 'multipicklist')) {
- column.editable = true;
- column.editorOptions = {
- commitOnOutsideClick: false,
- displayCellContent: true,
- };
- column.renderEditCell = dataTableEditorDropdownWrapper({
- isMultiSelect: type === 'multipicklist',
- values: field.picklistValues
- .filter(({ active }) => active)
- .map(({ value, label }) => ({
- id: value,
- label: value,
- secondaryLabel: label !== value ? label : undefined,
- secondaryLabelOnNewLine: label !== value,
- value,
- })),
- });
- // We could differentiate number types
- // } else if (type === 'currency' || type === 'double' || type === 'int' || type === 'percent' || numberType) {
- // } else if (type === 'id' || apexType === 'Id') {
- // } else if (type === 'datetime' || apexType === 'Datetime') {
- // } else if (type === 'time' || apexType === 'Time') {
- } else if (type === 'reference' && field.referenceTo?.length && field.referenceTo?.length > 0) {
- column.editable = true;
- column.editorOptions = {
- commitOnOutsideClick: false,
- displayCellContent: true,
- };
- column.renderEditCell = dataTableEditorRecordLookup({ sobjects: field.referenceTo });
- } else {
- // textType
- column.editable = true;
- column.editorOptions = {
- commitOnOutsideClick: false,
- displayCellContent: true,
- };
- column.renderEditCell = DataTableEditorText;
- }
-}
-
-export function addFieldLabelToColumn(columnDefinitions: ColumnWithFilter[], fieldMetadata: Record) {
- if (fieldMetadata) {
- // set field api name and label
- return columnDefinitions.map((col) => {
- if (fieldMetadata[col.key?.toLowerCase()]?.label) {
- const label = fieldMetadata[col.key.toLowerCase()].label;
- return {
- ...col,
- name: `${col.name} (${label})`,
- };
- }
- return col;
- });
- }
- return columnDefinitions;
-}
-
-export function resetFilter(type: FilterType, setValues: string[] = []): DataTableFilter {
- switch (type) {
- case 'TEXT':
- return { type, value: '' };
- case 'NUMBER':
- return { type, value: null, comparator: 'EQUALS' };
- case 'DATE':
- return { type, value: '', comparator: 'GREATER_THAN' };
- case 'TIME':
- return { type, value: '', comparator: 'GREATER_THAN' };
- case 'SET':
- case 'BOOLEAN_SET':
- return { type, value: setValues };
- default:
- throw new Error(`Filter type ${type} not supported`);
- }
-}
-
-export function isFilterActive(filter: DataTableFilter, totalValues: number): boolean {
- switch (filter?.type) {
- case 'TEXT':
- return !!filter.value;
- case 'NUMBER':
- return isNumber(filter.value) || !!filter.value;
- case 'DATE':
- return !!filter.value; // TODO: is valid date
- case 'TIME':
- return !!filter.value;
- case 'SET':
- return (filter.value?.length || 0) < totalValues;
- case 'BOOLEAN_SET':
- return (filter.value?.length || 0) !== 2;
- default:
- return false;
- }
-}
-
-export function filterRecord(filter: DataTableFilter, value: any): boolean {
- switch (filter?.type) {
- case 'TEXT': {
- if (isNumber(value)) {
- value = value.toString();
- }
- if (!isString(value)) {
- return false;
- }
- return value.toLowerCase().includes(filter.value.toLowerCase());
- }
- case 'NUMBER': {
- const filterValue = Number(filter.value);
- if (!isNumber(value)) {
- return false;
- }
- switch (filter.comparator) {
- case 'GREATER_THAN':
- return value > filterValue;
- case 'LESS_THAN':
- return value < filterValue;
- case 'EQUALS':
- default:
- return value === filterValue;
- }
- }
- case 'DATE': {
- if (!value || !filter.value) {
- return false;
- }
- const dateFilter = startOfDay(parseISO(filter.value));
- let date: Date;
- if (value.length === 21) {
- date = parseDate(value, DATE_FORMATS.YYYY_MM_DD_HH_mm_ss_a, new Date());
- } else {
- date = startOfDay(parseISO(value));
- }
- if (!isDateValid(date)) {
- return false;
- }
- switch (filter.comparator) {
- case 'GREATER_THAN':
- return isAfter(date, dateFilter);
- case 'LESS_THAN':
- return isBefore(date, dateFilter);
- case 'EQUALS':
- default:
- return isSameDay(date, dateFilter);
- }
- }
- case 'TIME': {
- if (!value) {
- return false;
- }
- const dateFilter = startOfMinute(parseDate(filter.value, DATE_FORMATS.HH_MM_SS_SSSS, new Date()));
- const date = startOfMinute(parseDate(value, DATE_FORMATS.HH_MM_SS_a, new Date()));
- if (!isDateValid(dateFilter) || !isDateValid(date)) {
- return false;
- }
- switch (filter.comparator) {
- case 'GREATER_THAN':
- return isAfter(date, dateFilter);
- case 'LESS_THAN':
- return isBefore(date, dateFilter);
- case 'EQUALS':
- default:
- return isSameDay(date, dateFilter);
- }
- }
- case 'BOOLEAN_SET': {
- if (!filter.value.length) {
- return false;
- } else if (filter.value.length === 2) {
- return true;
- }
- const filterValue = ensureBoolean(filter.value[0]);
- return value === ensureBoolean(filterValue);
- }
- case 'SET': {
- const includeNulls = filter.value.includes(EMPTY_FIELD);
- return (includeNulls && isNil(value)) || (!isNil(value) && filter.value.includes(String(value)));
- }
- default:
- return false;
- }
-}
-
-export function getSubqueryModalTagline(parentRecord: any) {
- let currModalTagline: string | undefined = undefined;
- let recordName: string | undefined = undefined;
- let recordId: string | undefined = undefined;
- try {
- if (parentRecord.Name) {
- recordName = parentRecord.Name;
- }
- if (parentRecord?.Id) {
- recordId = parentRecord.Id;
- } else if (parentRecord?.attributes?.url) {
- recordId = parentRecord.attributes.url.substring(parentRecord.attributes.url.lastIndexOf('/') + 1);
- }
- } catch {
- // ignore error
- } finally {
- // if we have name and id, then show both, otherwise only show one or the other
- if (recordName || recordId) {
- currModalTagline = 'Parent Record: ';
- if (recordName) {
- currModalTagline += recordName;
- }
- if (recordName && recordId) {
- currModalTagline += ` (${recordId})`;
- } else if (recordId) {
- currModalTagline += recordId;
- }
- }
- }
- return currModalTagline;
-}
-
-/**
- * Some URLS from salesforce do not allow accessing from id, but have varying URL structures
- * Also, some url paths are not allowed as redirect urls and are flagged to be skipped
- *
- * @param id
- * @param record
- * @returns
- */
-export function getSfdcRetUrl(record: any, id?: string, skipFrontdoorLoginOverride?: boolean): { skipFrontDoorAuth: boolean; url: string } {
- try {
- id = id || getIdFromRecordUrl(record?.attributes?.url || record?._record?.attributes?.url);
- const baseRecordType = record?.attributes?.type || record?._record?.attributes?.type;
- const recordPrefix = (id || '').substring(0, 3) as keyof typeof RECORD_PREFIX_MAP;
- const relatedRecordType = RECORD_PREFIX_MAP[recordPrefix] || null;
-
- if (baseRecordType === 'Group') {
- return {
- skipFrontDoorAuth: skipFrontdoorLoginOverride ?? true,
- url: `/lightning/setup/PublicGroups/page?address=${encodeURIComponent(`/setup/own/groupdetail.jsp?id=${id}`)}`,
- };
- }
-
- switch (relatedRecordType) {
- case 'RecordType': {
- return {
- skipFrontDoorAuth: skipFrontdoorLoginOverride ?? false,
- url: `/lightning/setup/ObjectManager/${relatedRecordType}/RecordTypes/${id}/view`,
- };
- }
- case 'Profile': {
- return {
- skipFrontDoorAuth: skipFrontdoorLoginOverride ?? false,
- url: `/lightning/setup/EnhancedProfiles/page?address=${encodeURIComponent(`/${id}?noredirect=1`)}`,
- };
- }
- case 'PermissionSet': {
- return {
- skipFrontDoorAuth: skipFrontdoorLoginOverride ?? false,
- url: `/lightning/setup/PermSets/page?address=${encodeURIComponent(`/${id}?noredirect=1`)}`,
- };
- }
- default:
- return { skipFrontDoorAuth: skipFrontdoorLoginOverride ?? false, url: `/${id}` };
- }
- } catch (ex) {
- logger.error('Error formatting Salesforce URL', ex);
- return { skipFrontDoorAuth: skipFrontdoorLoginOverride ?? false, url: `/${id}` };
- }
-}
-
-/**
- * Get text to allow for global search filtering
- */
-export function getSearchTextByRow(rows: T[], columns: ColumnWithFilter[], getRowKey: (row: T) => string): Record {
- const output: Record = {};
- if (Array.isArray(rows)) {
- rows.forEach((row) => {
- const key = getRowKey(row);
- if (key) {
- columns.forEach((column) => {
- if (column.key) {
- let value = (row as Record)[column.key];
- if (column.getValue) {
- value = column.getValue({ row, column });
- }
- if (!isNil(value) && !isObject(value)) {
- let filterValue = String(value);
- if (filterValue === '[object Object]') {
- filterValue = JSON.stringify(value);
- }
- output[key] = `${output[key] || ''}${filterValue.toLowerCase()}`;
- }
- }
- });
- }
- });
- }
- return output;
-}
-
-export const TABLE_CONTEXT_MENU_ITEMS: ContextMenuItem[] = [
- { label: 'Copy cell to clipboard', value: 'COPY_CELL', trailingDivider: true },
- { label: 'Copy row to clipboard (Excel)', value: 'COPY_ROW_EXCEL' },
- { label: 'Copy row to clipboard (JSON)', value: 'COPY_ROW_JSON', trailingDivider: true },
- { label: 'Copy column to values clipboard', value: 'COPY_COL_NO_HEADER' },
- { label: 'Copy column to clipboard (Excel)', value: 'COPY_COL' },
- { label: 'Copy column to clipboard (JSON)', value: 'COPY_COL_JSON', trailingDivider: true },
- { label: 'Copy table to clipboard (Excel)', value: 'COPY_TABLE' },
- { label: 'Copy table to clipboard (JSON)', value: 'COPY_TABLE_JSON' },
-];
-
-/**
- * Generic function to copy table data to clipboard
- * Works with any row data - uses row data directly (no _record indirection)
- */
-export function copyGenericTableDataToClipboard>(
- action: ContextAction,
- fields: string[],
- { row, rows, column, columns }: ContextMenuActionData,
-) {
- let includeHeader = true;
- let recordsToCopy: unknown[] = [];
- const fieldsSet = new Set(fields);
- let fieldsToCopy = columns.map((column) => column.key).filter((field) => fieldsSet.has(field));
- let format: 'plain' | 'excel' | 'json' = 'plain';
-
- switch (action) {
- case 'COPY_CELL':
- includeHeader = false;
- fieldsToCopy = [column.key];
- recordsToCopy = [row];
- break;
-
- case 'COPY_ROW_EXCEL':
- format = 'excel';
- recordsToCopy = [row];
- break;
-
- case 'COPY_ROW_JSON':
- recordsToCopy = [row];
- format = 'json';
- break;
-
- case 'COPY_COL':
- fieldsToCopy = fieldsToCopy.filter((field) => field === column.key);
- recordsToCopy = rows.map((row) => ({ [column.key]: row[column.key] }));
- format = 'excel';
- break;
-
- case 'COPY_COL_JSON':
- fieldsToCopy = fieldsToCopy.filter((field) => field === column.key);
- recordsToCopy = rows.map((row) => ({ [column.key]: row[column.key] }));
- format = 'json';
- break;
-
- case 'COPY_COL_NO_HEADER':
- includeHeader = false;
- fieldsToCopy = fieldsToCopy.filter((field) => field === column.key);
- recordsToCopy = rows.map((row) => ({ [column.key]: row[column.key] }));
- format = 'plain';
- break;
-
- case 'COPY_TABLE':
- recordsToCopy = rows;
- break;
-
- case 'COPY_TABLE_JSON':
- recordsToCopy = rows;
- format = 'json';
- break;
-
- default:
- break;
- }
- if (recordsToCopy.length) {
- if (format === 'json') {
- const filteredRecords = recordsToCopy.map((record) =>
- fieldsToCopy.reduce