diff --git a/libs/features/automation-control/src/AutomationControlEditor.tsx b/libs/features/automation-control/src/AutomationControlEditor.tsx index 14367e6cd..38f136cba 100644 --- a/libs/features/automation-control/src/AutomationControlEditor.tsx +++ b/libs/features/automation-control/src/AutomationControlEditor.tsx @@ -66,12 +66,12 @@ export const AutomationControlEditor = () => { const { rows, - visibleRows, + treeRows, + getSubRows, hasError, loading, fetchData, refreshProcessBuilders, - toggleRowExpand, updateIsActiveFlag, toggleAll, resetChanges, @@ -290,7 +290,6 @@ export const AutomationControlEditor = () => { Deselect All */} - - + {formatNumber(dirtyCount)} {pluralizeFromNumber('item', dirtyCount)} modified - {selectedAutomationTypes.includes('FlowProcessBuilder') && ( -
- -
- )} +
+ {selectedAutomationTypes.includes('FlowProcessBuilder') && ( +
+ +
+ )} +
@@ -391,9 +393,9 @@ export const AutomationControlEditor = () => { serverUrl={serverUrl} skipFrontdoorLogin={skipFrontdoorLogin} selectedOrg={selectedOrg} - rows={visibleRows} + rows={treeRows} + getSubRows={getSubRows} quickFilterText={quickFilterText} - toggleRowExpand={toggleRowExpand} updateIsActiveFlag={updateIsActiveFlag} onSortedAndFilteredRowsChange={tableRowsChange} /> diff --git a/libs/features/automation-control/src/AutomationControlEditorReviewModal.tsx b/libs/features/automation-control/src/AutomationControlEditorReviewModal.tsx index 301af128f..10786db3b 100644 --- a/libs/features/automation-control/src/AutomationControlEditorReviewModal.tsx +++ b/libs/features/automation-control/src/AutomationControlEditorReviewModal.tsx @@ -2,10 +2,10 @@ import { css } from '@emotion/react'; import { logger } from '@jetstream/shared/client-logger'; import { ANALYTICS_KEYS } from '@jetstream/shared/constants'; import { SalesforceOrgUi } from '@jetstream/types'; +import type { Column } from '@jetstream/ui'; import { AutoFullHeightContainer, DataTable, Icon, Modal, Spinner } from '@jetstream/ui'; import { ConfirmPageChange, useAmplitude } from '@jetstream/ui-core'; import { Fragment, FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react'; -import { Column } from 'react-data-grid'; import { deployMetadata, getAutomationTypeLabel, preparePayloads } from './automation-control-data-utils'; import { AutomationDeployStatusRenderer, BooleanAndVersionRenderer } from './automation-control-table-renderers'; import { diff --git a/libs/features/automation-control/src/AutomationControlEditorTable.tsx b/libs/features/automation-control/src/AutomationControlEditorTable.tsx index a84aa074b..8554dea7c 100644 --- a/libs/features/automation-control/src/AutomationControlEditorTable.tsx +++ b/libs/features/automation-control/src/AutomationControlEditorTable.tsx @@ -9,12 +9,12 @@ import { copyGenericTableDataToClipboard, setColumnFromType, } from '@jetstream/ui'; -import { forwardRef, useCallback, useMemo } from 'react'; +import { ReactNode, forwardRef, useCallback, useMemo } from 'react'; import { isTableRow } from './automation-control-data-utils'; import { AdditionalDetailRenderer, ExpandingLabelRenderer, LoadingAndActiveRenderer } from './automation-control-table-renderers'; import { TableRowOrItemOrChild } from './automation-control-types'; -const getRowHeight = (row: TableRowOrItemOrChild) => { +const getRowHeight = ({ row }: { type: 'ROW' | 'GROUP'; row: TableRowOrItemOrChild }) => { if (isTableRow(row)) { return 28.5; } @@ -38,24 +38,15 @@ export interface AutomationControlEditorTableProps { skipFrontdoorLogin: boolean; selectedOrg: SalesforceOrgUi; rows: TableRowOrItemOrChild[]; + getSubRows: (row: TableRowOrItemOrChild, index: number) => TableRowOrItemOrChild[] | undefined; quickFilterText?: string | null; - toggleRowExpand: (row: TableRowOrItemOrChild, value: boolean) => void; updateIsActiveFlag: (row: TableRowOrItemOrChild, value: boolean) => void; onSortedAndFilteredRowsChange: (rows: readonly TableRowOrItemOrChild[]) => void; } export const AutomationControlEditorTable = forwardRef( ( - { - serverUrl, - skipFrontdoorLogin, - selectedOrg, - rows, - quickFilterText, - toggleRowExpand, - updateIsActiveFlag, - onSortedAndFilteredRowsChange, - }, + { serverUrl, skipFrontdoorLogin, selectedOrg, rows, getSubRows, quickFilterText, updateIsActiveFlag, onSortedAndFilteredRowsChange }, ref, ) => { const columns = useMemo(() => { @@ -65,14 +56,17 @@ export const AutomationControlEditorTable = forwardRef { + renderCell: ({ row, value, depth, canExpand, isExpanded, toggleExpanded }) => { return ( ); }, @@ -113,7 +107,7 @@ export const AutomationControlEditorTable = forwardRef[]; - }, [serverUrl, selectedOrg, toggleRowExpand, updateIsActiveFlag]); + }, [serverUrl, selectedOrg, updateIsActiveFlag]); const fields = useMemo(() => columns.map((col) => col.key), [columns]); @@ -132,6 +126,8 @@ export const AutomationControlEditorTable = forwardRef; row: TableRowOrItemOrChild; - toggleRowExpand: (row: TableRowOrItemOrChild, value: boolean) => void; -}> = ({ serverUrl, selectedOrg, column, row, toggleRowExpand }) => { - const value = row[column.key as keyof TableRowOrItemOrChild]; - const leftMargin = isTableRowItem(row) ? 2 : isTableRowChild(row) ? 4.5 : 0; - + value: ReactNode; + depth: number; + canExpand: boolean; + isExpanded: boolean; + toggleExpanded: () => void; +}> = ({ serverUrl, selectedOrg, row, value, depth, canExpand, isExpanded, toggleExpanded }) => { const wrappedValue = !isTableRow(row) && row.link && serverUrl && selectedOrg ? ( +
- - -
{wrappedValue}
- - ); - } - - return ( -
- {wrappedValue} -
+ {wrappedValue} +
+ ); }; diff --git a/libs/features/automation-control/src/useAutomationControlData.ts b/libs/features/automation-control/src/useAutomationControlData.ts index 24a6b5daf..c8a3f113e 100644 --- a/libs/features/automation-control/src/useAutomationControlData.ts +++ b/libs/features/automation-control/src/useAutomationControlData.ts @@ -4,7 +4,7 @@ import { tracker } from '@jetstream/shared/ui-utils'; import { getErrorMessage } from '@jetstream/shared/utils'; import { SalesforceOrgUi } from '@jetstream/types'; import { useAmplitude } from '@jetstream/ui-core'; -import { useCallback, useEffect, useReducer, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'; import { fetchAutomationData, getAdditionalItemsWorkflowRuleText, @@ -39,7 +39,6 @@ type Action = | { type: 'FETCH_ERROR'; payload: FetchErrorPayload } | { type: 'FETCH_FINISH' } | { type: 'UPDATE_IS_ACTIVE_FLAG'; payload: { row: TableRowOrItemOrChild; value: boolean } } - | { type: 'TOGGLE_ROW_EXPAND'; payload: { row: TableRowOrItemOrChild; value: boolean } } | { type: 'TOGGLE_ALL'; payload: { value: boolean } } | { type: 'RESTORE_SNAPSHOT'; payload: { snapshot: TableRowItemSnapshot[] } } | { type: 'TABLE_ROWS_CHANGED'; payload: { rows: readonly TableRowOrItemOrChild[] } } @@ -51,11 +50,11 @@ interface State { hasError: boolean; errorMessage?: string | null; data: StateData; - /** These are the only data points that are updated over time */ + /** Flat list of every row (all levels), rebuilt from `rowsByKey` on each change. Used for dirty + * tracking, export, and to derive the nested tree handed to the grid (see `useAutomationControlData`). + * Expand/collapse + visibility are owned by the grid's native tree (`getSubRows`), not this list. */ rows: (TableRow | TableRowItem | TableRowItemChild)[]; - /** All rows fetched from the backend, regardless of filtering or UI state */ - visibleRows: (TableRow | TableRowItem | TableRowItemChild)[]; - /** Rows currently displayed in the UI table (filtered subset of visibleRows) */ + /** Rows currently displayed in the UI table (post sort/filter), reported back by the grid. */ tableRows: readonly TableRowOrItemOrChild[]; /** allows accessing and changing data without iteration */ rowsByKey: Record; @@ -91,9 +90,7 @@ function toggleParentRow( if (Array.isArray(fullParentRow.children) && fullParentRow.children.length > 0) { if (value) { // ENABLE: Find the max version among VISIBLE children in the table - const visibleChildrenKeys = new Set( - tableRows.filter((r) => isTableRowChild(r) && r.parentKey === key).map((r) => r.key), - ); + const visibleChildrenKeys = new Set(tableRows.filter((r) => isTableRowChild(r) && r.parentKey === key).map((r) => r.key)); // Get all visible children from the full children list const visibleChildren = fullParentRow.children.filter((child) => visibleChildrenKeys.has(child.key)); @@ -222,7 +219,6 @@ function reducer(state: State, action: Action): State { output.keys = keys; output.rows = rows; output.rowsByKey = rowsByKey; - output.visibleRows = getVisibleRows(output.rows, rowsByKey); output.dirtyCount = rows.reduce((output, row) => output + (isDirty(row) ? 1 : 0), 0); return output; } @@ -248,7 +244,6 @@ function reducer(state: State, action: Action): State { output.keys = keys; output.rows = rows; output.rowsByKey = rowsByKey; - output.visibleRows = getVisibleRows(output.rows, rowsByKey); output.dirtyCount = rows.reduce((output, row) => output + (isDirty(row) ? 1 : 0), 0); return output; } @@ -268,7 +263,6 @@ function reducer(state: State, action: Action): State { output.keys = keys; output.rows = rows; output.rowsByKey = rowsByKey; - output.visibleRows = getVisibleRows(output.rows, rowsByKey); output.dirtyCount = rows.reduce((output, row) => output + (isDirty(row) ? 1 : 0), 0); return output; } @@ -293,7 +287,6 @@ function reducer(state: State, action: Action): State { const { keys, rows, rowsByKey } = flattenTableRows(output.data, state.rowsByKey); output.keys = keys; output.rows = rows; - output.visibleRows = getVisibleRows(rows, rowsByKey); output.rowsByKey = rowsByKey; output.dirtyCount = rows.reduce((output, row) => output + (isDirty(row) ? 1 : 0), 0); return output; @@ -333,25 +326,9 @@ function reducer(state: State, action: Action): State { rows: state.keys.map((rowKey) => rowsByKey[rowKey]), rowsByKey, }; - output.visibleRows = getVisibleRows(output.rows, rowsByKey); output.dirtyCount = output.rows.reduce((output, row) => output + (isDirty(row) ? 1 : 0), 0); return output; } - case 'TOGGLE_ROW_EXPAND': { - // toggleRowExpand - logger.log('TOGGLE_ROW_EXPAND', { state }); - const key = action.payload.row.key; - const rowsByKey = { ...state.rowsByKey }; - rowsByKey[key] = { ...rowsByKey[action.payload.row.key], isExpanded: action.payload.value }; - - const output: State = { - ...state, - rows: state.keys.map((rowKey) => rowsByKey[rowKey]), - rowsByKey, - }; - output.visibleRows = getVisibleRows(output.rows, rowsByKey); - return output; - } case 'TOGGLE_ALL': { const rowsByKey = { ...state.rowsByKey }; const processedParents = new Set(); @@ -359,9 +336,7 @@ function reducer(state: State, action: Action): State { state.tableRows.forEach((row) => { if (isTableRowItem(row)) { // Check if this parent has any visible children in tableRows - const hasVisibleChildren = state.tableRows.some( - (r) => isTableRowChild(r) && r.parentKey === row.key - ); + const hasVisibleChildren = state.tableRows.some((r) => isTableRowChild(r) && r.parentKey === row.key); // Only process parent if it has no children OR has visible children in tableRows if (!row.children || row.children.length === 0 || hasVisibleChildren) { @@ -376,7 +351,7 @@ function reducer(state: State, action: Action): State { // Get all visible child rows for this parent from the table const visibleSiblings = state.tableRows.filter( - (r) => isTableRowChild(r) && r.parentKey === row.parentKey + (r) => isTableRowChild(r) && r.parentKey === row.parentKey, ) as TableRowItemChild[]; if (action.payload.value) { @@ -400,7 +375,6 @@ function reducer(state: State, action: Action): State { rows: state.keys.map((rowKey) => rowsByKey[rowKey]), rowsByKey, }; - output.visibleRows = getVisibleRows(output.rows, rowsByKey); output.dirtyCount = output.rows.reduce((output, row) => output + (isDirty(row) ? 1 : 0), 0); return output; } @@ -420,7 +394,6 @@ function reducer(state: State, action: Action): State { rows: state.keys.map((rowKey) => rowsByKey[rowKey]), rowsByKey, }; - output.visibleRows = getVisibleRows(output.rows, rowsByKey); output.dirtyCount = output.rows.reduce((output, row) => output + (isDirty(row) ? 1 : 0), 0); return output; } @@ -444,7 +417,6 @@ function reducer(state: State, action: Action): State { rowsByKey, dirtyCount: 0, }; - output.visibleRows = getVisibleRows(output.rows, rowsByKey); output.dirtyCount = output.rows.reduce((output, row) => output + (isDirty(row) ? 1 : 0), 0); return output; } @@ -687,26 +659,6 @@ function flattenTableRows( return { rows, rowsByKey, keys }; } -function getVisibleRows( - rows: (TableRow | TableRowItem | TableRowItemChild)[], - rowsByKey: Record, -) { - return rows.filter((row) => { - if (isTableRow(row)) { - return true; - } else if (isTableRowItem(row) && rowsByKey[row.parentKey]?.isExpanded) { - return true; - } else if ( - isTableRowChild(row) && - rowsByKey[row.parentKey]?.isExpanded && - rowsByKey[(rowsByKey[row.parentKey] as TableRowItem)?.parentKey]?.isExpanded - ) { - return true; - } - return false; - }); -} - export function useAutomationControlData({ selectedOrg, defaultApiVersion, @@ -721,7 +673,7 @@ export function useAutomationControlData({ const isMounted = useRef(true); const { trackEvent } = useAmplitude(); - const [{ loading, hasError, errorMessage, data, rows, visibleRows, dirtyCount }, dispatch] = useReducer(reducer, { + const [{ loading, hasError, errorMessage, data, rows, rowsByKey, dirtyCount }, dispatch] = useReducer(reducer, { loading: false, hasError: false, data: { @@ -753,7 +705,6 @@ export function useAutomationControlData({ }, }, rows: [], - visibleRows: [], tableRows: [], rowsByKey: {}, keys: [], @@ -772,10 +723,6 @@ export function useAutomationControlData({ dispatch({ type: 'UPDATE_IS_ACTIVE_FLAG', payload: { row, value } }); }, []); - const toggleRowExpand = useCallback((row: TableRowOrItemOrChild, value: boolean) => { - dispatch({ type: 'TOGGLE_ROW_EXPAND', payload: { row, value } }); - }, []); - const resetChanges = useCallback(() => { dispatch({ type: 'RESET' }); trackEvent(ANALYTICS_KEYS.automation_toggle_all, { type: 'reset' }); @@ -846,17 +793,33 @@ export function useAutomationControlData({ fetchData(); }, [defaultApiVersion, fetchData, selectedOrg, selectedSObjects]); + // The grid renders the hierarchy natively via `getSubRows`, so we hand it the root (type) rows and let + // it walk down. `rows` is flat (and rebuilt fresh on every change), so we rebuild each root's + // `items`/`children` from `rowsByKey` to guarantee the nested objects reflect the latest active state. + const treeRows = useMemo(() => { + return rows.filter(isTableRow).map((tableRow) => ({ + ...tableRow, + items: tableRow.items.map((item) => { + const freshItem = rowsByKey[item.key] as TableRowItem; + if (!freshItem.children?.length) { + return freshItem; + } + return { ...freshItem, children: freshItem.children.map((child) => rowsByKey[child.key] as TableRowItemChild) }; + }), + })); + }, [rows, rowsByKey]); + return { loading, hasError, errorMessage, data, rows, - visibleRows, + treeRows, + getSubRows: getAutomationSubRows, fetchData, refreshProcessBuilders, updateIsActiveFlag, - toggleRowExpand, toggleAll, resetChanges, tableRowsChange, @@ -864,3 +827,14 @@ export function useAutomationControlData({ dirtyCount, }; } + +/** Tree accessor for the grid: a type row owns its automation items; a flow/PB item owns its versions. */ +function getAutomationSubRows(row: TableRowOrItemOrChild): TableRowOrItemOrChild[] | undefined { + if (isTableRow(row)) { + return row.items; + } + if (isTableRowItem(row)) { + return row.children; + } + return undefined; +} diff --git a/libs/features/create-object-and-fields/src/LoadExistingFieldsModal.tsx b/libs/features/create-object-and-fields/src/LoadExistingFieldsModal.tsx index a7cfa6b49..35f14d3a9 100644 --- a/libs/features/create-object-and-fields/src/LoadExistingFieldsModal.tsx +++ b/libs/features/create-object-and-fields/src/LoadExistingFieldsModal.tsx @@ -13,6 +13,8 @@ import { NotSeeingRecentMetadataPopover, ScopedNotification, SearchInput, + SELECT_COLUMN_KEY, + SelectColumn, SelectFormatter, Spinner, fireToast, @@ -29,7 +31,6 @@ import { useAmplitude, } from '@jetstream/ui-core'; import { FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react'; -import { SELECT_COLUMN_KEY, SelectColumn } from 'react-data-grid'; function getRowKey(row: ExistingFieldRow) { return row.key; diff --git a/libs/features/debug-log-viewer/src/DebugLogViewer.tsx b/libs/features/debug-log-viewer/src/DebugLogViewer.tsx index 2db3044f0..766728e08 100644 --- a/libs/features/debug-log-viewer/src/DebugLogViewer.tsx +++ b/libs/features/debug-log-viewer/src/DebugLogViewer.tsx @@ -273,7 +273,7 @@ export const DebugLogViewer: FunctionComponent = () => { )} - + diff --git a/libs/features/debug-log-viewer/src/DebugLogViewerTable.tsx b/libs/features/debug-log-viewer/src/DebugLogViewerTable.tsx index c6854a6b7..0e05260ba 100644 --- a/libs/features/debug-log-viewer/src/DebugLogViewerTable.tsx +++ b/libs/features/debug-log-viewer/src/DebugLogViewerTable.tsx @@ -2,18 +2,19 @@ import { css } from '@emotion/react'; import { ApexLogWithViewed, ContextMenuItem } from '@jetstream/types'; import { AutoFullHeightContainer, + CellMouseArgs, ColumnWithFilter, ContextAction, ContextMenuActionData, DataTable, Icon, + RenderCellProps, RowWithKey, TABLE_CONTEXT_MENU_ITEMS, copyGenericTableDataToClipboard, setColumnFromType, } from '@jetstream/ui'; -import { FunctionComponent, ReactNode, useCallback, useEffect, useRef } from 'react'; -import { CellMouseArgs, RenderCellProps } from 'react-data-grid'; +import { FunctionComponent, ReactNode, useCallback, useEffect, useMemo, useRef } from 'react'; export const LogViewedRenderer = ({ row }: RenderCellProps): ReactNode => { if (row?.viewed) { @@ -42,6 +43,7 @@ const COLUMNS: ColumnWithFilter[] = [ width: 12, renderCell: LogViewedRenderer, resizable: false, + sortable: false, }, { ...setColumnFromType('LogUser.Name', 'text'), @@ -95,10 +97,12 @@ function getRowId({ Id }: ApexLogWithViewed): string { export interface DebugLogViewerTableProps { logs: ApexLogWithViewed[]; + /** Id of the log currently shown in the results pane — its row is highlighted. */ + activeLogId?: string | null; onRowSelection: (log: ApexLogWithViewed) => void; } -export const DebugLogViewerTable: FunctionComponent = ({ logs, onRowSelection }) => { +export const DebugLogViewerTable: FunctionComponent = ({ logs, activeLogId, onRowSelection }) => { const isMounted = useRef(true); useEffect(() => { @@ -118,14 +122,51 @@ export const DebugLogViewerTable: FunctionComponent = copyGenericTableDataToClipboard(item.value, FIELDS, data); }, []); + // Highlight the row whose log is currently displayed in the results pane. + const rowClass = useCallback( + (row: ApexLogWithViewed) => (activeLogId && row.Id === activeLogId ? 'jgrid-row-selected' : undefined), + [activeLogId], + ); + + // The new DataTable has no `onCellClick`. Preserve the legacy "click a row to mark its log viewed" + // behavior by wrapping each column's rendered cell content in a click handler. `role="button"` (with + // tabIndex=-1 so it stays out of the page tab order) lets the grid's keyboard activation — Enter/Space + // on the cell — trigger the same handler as a click. + const columns = useMemo[]>( + () => + COLUMNS.map((column) => { + const renderCell = column.renderCell; + return { + ...column, + renderCell: (props: RenderCellProps) => ( +
handleSelectionChanged({ row: props.row, column: props.column, rowIdx: props.rowIdx })} + > + {renderCell ? renderCell(props) : props.value === null || props.value === undefined ? '' : String(props.value)} +
+ ), + }; + }), + // `onRowSelection` is stable for this component; columns only need to be built once. + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + return ( diff --git a/libs/features/deploy/src/DeployMetadataDeployment.tsx b/libs/features/deploy/src/DeployMetadataDeployment.tsx index fa83c8a0a..7b3e7e762 100644 --- a/libs/features/deploy/src/DeployMetadataDeployment.tsx +++ b/libs/features/deploy/src/DeployMetadataDeployment.tsx @@ -45,6 +45,7 @@ const TABLE_ACTION_CLIPBOARD_SELECTED = 'table-copy-to-clipboard-selected'; const TABLE_ACTION_DOWNLOAD = 'table-download'; const TABLE_ACTION_DOWNLOAD_SELECTED = 'table-download-selected'; const TABLE_ACTION_DOWNLOAD_MANIFEST = 'download-manifest'; +const TABLE_ACTION_DOWNLOAD_MANIFEST_ALL = 'download-manifest-all'; const TABLE_ACTION_DELETE_METADATA = 'delete-manifest'; export interface DeployMetadataDeploymentProps {} @@ -68,6 +69,8 @@ export const DeployMetadataDeployment: FunctionComponent(null); + // whether the active download applies to the selected rows or the entire table + const [downloadScope, setDownloadScope] = useState<'selected' | 'all'>('selected'); const listMetadataQueries = useAtomValue(fromDeployMetadataState.listMetadataQueriesSelector); const userSelection = useAtomValue(fromDeployMetadataState.userSelectionState); @@ -87,6 +90,8 @@ export const DeployMetadataDeployment: FunctionComponent | null>(null); const [deleteMetadataModalOpen, setDeleteMetadataModalOpen] = useState(false); const [isSingleOrgMode] = useState(() => isBrowserExtension() || isCanvasApp()); @@ -195,9 +200,10 @@ export const DeployMetadataDeployment: FunctionComponent { + setViewSingleItemMetadata(convertRowsToMapOfListMetadataResults([row])); + setViewOrCompareModalOpen(true); + }, []); + const selectedMetadata = useMemo(() => convertRowsToMapOfListMetadataResults(Array.from(selectedRows)), [selectedRows]); + const allMetadata = useMemo(() => convertRowsToMapOfListMetadataResults(rows || []), [rows]); + const allMetadataCount = Object.values(allMetadata).reduce((total, items) => total + items.length, 0); + const activeDownloadMetadata = downloadScope === 'all' ? allMetadata : selectedMetadata; + const activeDownloadCount = downloadScope === 'all' ? allMetadataCount : selectedRows.size; return (
@@ -246,8 +263,8 @@ export const DeployMetadataDeployment: FunctionComponent )} @@ -274,8 +291,11 @@ export const DeployMetadataDeployment: FunctionComponent setViewOrCompareModalOpen(false)} + selectedMetadata={viewSingleItemMetadata ?? selectedMetadata} + onClose={() => { + setViewOrCompareModalOpen(false); + setViewSingleItemMetadata(null); + }} /> )} @@ -328,6 +348,13 @@ export const DeployMetadataDeployment: FunctionComponent 0} onSelectedRows={setSelectedRows} onViewOrCompareOpen={handleViewOrCompareOpen} + onViewItem={handleViewItem} /> diff --git a/libs/features/deploy/src/DeployMetadataDeploymentTable.tsx b/libs/features/deploy/src/DeployMetadataDeploymentTable.tsx index 4bddfe80d..21ee2d960 100644 --- a/libs/features/deploy/src/DeployMetadataDeploymentTable.tsx +++ b/libs/features/deploy/src/DeployMetadataDeploymentTable.tsx @@ -2,14 +2,19 @@ import { formatNumber, isBrowserExtension, isCanvasApp } from '@jetstream/shared import { DeployMetadataTableRow } from '@jetstream/types'; import { AutoFullHeightContainer, DataTableSelectedContext, DataTree, Grid, Icon, SearchInput } from '@jetstream/ui'; import groupBy from 'lodash/groupBy'; -import { FunctionComponent, useEffect, useState } from 'react'; -import { getColumnDefinitions } from './utils/deploy-metadata.utils'; +import { FunctionComponent, useEffect, useMemo, useState } from 'react'; +import { + getColumnDefinitions, + getDeploymentTableContextMenuItems, + handleDeploymentTableContextMenuAction, +} from './utils/deploy-metadata.utils'; export interface DeployMetadataDeploymentTableProps { rows: DeployMetadataTableRow[]; hasSelectedRows: boolean; onSelectedRows: (selectedRows: Set) => void; onViewOrCompareOpen: () => void; + onViewItem: (row: DeployMetadataTableRow) => void; } function getRowId(row: DeployMetadataTableRow): string { @@ -18,14 +23,14 @@ function getRowId(row: DeployMetadataTableRow): string { const groupedRows = ['typeLabel'] as const; -const COLUMNS = getColumnDefinitions(); - export const DeployMetadataDeploymentTable: FunctionComponent = ({ rows, hasSelectedRows, onSelectedRows, onViewOrCompareOpen, + onViewItem, }) => { + const columns = useMemo(() => getColumnDefinitions(onViewItem), [onViewItem]); const [isSingleOrgMode] = useState(() => isBrowserExtension() || isCanvasApp()); const [visibleRows, setVisibleRows] = useState(rows); const [globalFilter, setGlobalFilter] = useState(null); @@ -59,7 +64,7 @@ export const DeployMetadataDeploymentTable: FunctionComponent setExpandedGroupIds(items)} selectedRows={selectedRowIds} onSelectedRowsChange={setSelectedRowIds} + contextMenuItems={getDeploymentTableContextMenuItems} + contextMenuAction={handleDeploymentTableContextMenuAction} /> diff --git a/libs/features/deploy/src/deploy-metadata-history/DeployMetadataHistoryTable.tsx b/libs/features/deploy/src/deploy-metadata-history/DeployMetadataHistoryTable.tsx index c8ee8b028..3c5fe0470 100644 --- a/libs/features/deploy/src/deploy-metadata-history/DeployMetadataHistoryTable.tsx +++ b/libs/features/deploy/src/deploy-metadata-history/DeployMetadataHistoryTable.tsx @@ -54,18 +54,20 @@ const COLUMNS: ColumnWithFilter[] = [ }, ]; -const getRowHeight = (orgsById: Record) => (row: SalesforceDeployHistoryItem) => { - const rowHeight = 27.5; - let numberOfRows = 3; - if (row.type === 'orgToOrg') { - /** we need 3 rows plus a little buffer */ - numberOfRows = 3.5; - } else if (row.fileKey || (row.sourceOrg && orgsById[row.sourceOrg.uniqueId])) { - /** we need 3 rows */ - return 27.5 * 3; - } - return rowHeight * numberOfRows; -}; +const getRowHeight = + (orgsById: Record) => + ({ row }: { type: 'ROW' | 'GROUP'; row: SalesforceDeployHistoryItem }) => { + const rowHeight = 27.5; + let numberOfRows = 3; + if (row.type === 'orgToOrg') { + /** we need 3 rows plus a little buffer */ + numberOfRows = 3.5; + } else if (row.fileKey || (row.sourceOrg && orgsById[row.sourceOrg.uniqueId])) { + /** we need 3 rows */ + return 27.5 * 3; + } + return rowHeight * numberOfRows; + }; const getRowId = ({ key }: SalesforceDeployHistoryItem) => key; export interface DeployMetadataHistoryTableProps { diff --git a/libs/features/deploy/src/deploy-metadata-history/DeployMetadataHistoryTableRenderers.tsx b/libs/features/deploy/src/deploy-metadata-history/DeployMetadataHistoryTableRenderers.tsx index 7e5b00dc5..ded2225df 100644 --- a/libs/features/deploy/src/deploy-metadata-history/DeployMetadataHistoryTableRenderers.tsx +++ b/libs/features/deploy/src/deploy-metadata-history/DeployMetadataHistoryTableRenderers.tsx @@ -1,10 +1,10 @@ import { css } from '@emotion/react'; import { IconName } from '@jetstream/icon-factory'; import { DeployHistoryTableContext, SalesforceDeployHistoryItem } from '@jetstream/types'; +import type { RenderCellProps } from '@jetstream/ui'; import { DataTableGenericContext, Grid, Icon } from '@jetstream/ui'; import { OrgLabelBadge } from '@jetstream/ui-core'; import { Fragment, useContext } from 'react'; -import { RenderCellProps } from 'react-data-grid'; const fallbackLabel = 'Unknown Org'; diff --git a/libs/features/deploy/src/utils/deploy-metadata.utils.tsx b/libs/features/deploy/src/utils/deploy-metadata.utils.tsx index f359d9cda..775a0d51a 100644 --- a/libs/features/deploy/src/utils/deploy-metadata.utils.tsx +++ b/libs/features/deploy/src/utils/deploy-metadata.utils.tsx @@ -5,6 +5,7 @@ import { tracker } from '@jetstream/shared/ui-utils'; import { ensureArray, getSuccessOrFailureChar, orderValues, pluralizeFromNumber } from '@jetstream/shared/utils'; import { ChangeSet, + ContextMenuItem, DeployMetadataTableRow, DeployOptions, DeployResult, @@ -16,8 +17,13 @@ import { } from '@jetstream/types'; import { ColumnWithFilter, + ContextAction, + ContextMenuActionData, + copyGenericTableDataToClipboard, Grid, Icon, + SELECT_COLUMN_KEY, + SelectColumn, SelectFormatter, SelectHeaderGroupRenderer, setColumnFromType, @@ -33,7 +39,6 @@ import localforage from 'localforage'; import isDate from 'lodash/isDate'; import isString from 'lodash/isString'; import { ReactNode } from 'react'; -import { SELECT_COLUMN_KEY, SelectColumn } from 'react-data-grid'; const MAX_HISTORY_ITEMS = 500; @@ -208,7 +213,7 @@ export function getQueryForPackage(): string { return soql; } -export function getColumnDefinitions(): ColumnWithFilter[] { +export function getColumnDefinitions(onViewItem?: (row: DeployMetadataTableRow) => void): ColumnWithFilter[] { const output: ColumnWithFilter[] = [ { ...SelectColumn, @@ -238,7 +243,7 @@ export function getColumnDefinitions(): ColumnWithFilter colSpan: (args) => { if (args.type === 'ROW') { const { row } = args; - if (!row.loading && !row.metadata) { + if (!row?.loading && !row?.metadata) { return 3; } } @@ -251,15 +256,40 @@ export function getColumnDefinitions(): ColumnWithFilter key: 'typeLabel', width: 40, frozen: true, - renderGroupCell: ({ isExpanded }) => ( - + // In the group header this cell spans the rest of the row (after the select-all checkbox) so the + // chevron + type label + count read as one wide expand/collapse target. The clamp in GridGroupRow + // caps the span at the remaining columns. Returns undefined for data rows (they keep this 40px cell). + colSpan: (args) => (args.type === 'GROUP' ? Number.MAX_SAFE_INTEGER : undefined), + // Rows are grouped by typeLabel, so the per-row value is redundant (it's shown in the group header). + // For child rows, surface a shortcut to view this single item's metadata; group headers keep the chevron toggle below. + renderCell: ({ row }) => + row.metadata ? ( +
+ + + +
+ ) : null, + renderGroupCell: ({ isExpanded, toggleGroup, groupKey, childRows }) => ( + ), }, { @@ -268,16 +298,6 @@ export function getColumnDefinitions(): ColumnWithFilter key: 'fullName', frozen: true, renderCell: ({ row }) => (row.loading ? : row.fullName), - renderGroupCell: ({ toggleGroup, groupKey, childRows }) => ( - <> - - {!childRows.some((row) => row.loading) && ( - ({childRows.length}) - )} - - ), width: 250, }, { @@ -288,7 +308,7 @@ export function getColumnDefinitions(): ColumnWithFilter colSpan: (args) => { if (args.type === 'ROW') { const { row } = args; - if (!row.loading && !row.metadata) { + if (!row?.loading && !row?.metadata) { return 5; } } @@ -326,6 +346,39 @@ export function getColumnDefinitions(): ColumnWithFilter return output; } +/** Custom action: copy the clicked column's values for only the right-clicked row's metadata type. */ +const COPY_COL_TYPE = 'COPY_COL_TYPE'; + +/** + * Per-cell context menu for the deployment table. Only data rows get a menu (group headers aren't passed + * here); the type-scoped copy uses the right-clicked row's `typeLabel` in its label and filters to it. + */ +export function getDeploymentTableContextMenuItems(data: ContextMenuActionData): ContextMenuItem[] { + // The select column carries no copyable data. + if (data.column.key === SELECT_COLUMN_KEY) { + return []; + } + return [ + { label: `Copy column (${data.row.typeLabel})`, value: COPY_COL_TYPE }, + { label: 'Copy column (All Types)', value: 'COPY_COL', trailingDivider: true }, + { label: 'Copy row to clipboard (Excel)', value: 'COPY_ROW_EXCEL', trailingDivider: true }, + { label: 'Copy Table to clipboard (Excel)', value: 'COPY_TABLE' }, + { label: 'Copy Table to clipboard (CSV)', value: 'COPY_TABLE_CSV' }, + { label: 'Copy Table to clipboard (JSON)', value: 'COPY_TABLE_JSON' }, + ]; +} + +export function handleDeploymentTableContextMenuAction(item: ContextMenuItem, data: ContextMenuActionData): void { + const fields = data.columns.map((column) => column.key); + if (item.value === COPY_COL_TYPE) { + // Scope the column copy to the right-clicked row's metadata type, then reuse the standard column copy. + const rowsForType = data.rows.filter((row) => row.typeLabel === data.row.typeLabel); + copyGenericTableDataToClipboard('COPY_COL', fields, { ...data, rows: rowsForType }); + return; + } + copyGenericTableDataToClipboard(item.value as ContextAction, fields, data); +} + const dataTableDateFormatter = (dateOrDateTime: Maybe): ReactNode => { if (!dateOrDateTime) { return null; diff --git a/libs/features/load-records/src/components/LoadRecordsDataPreview.tsx b/libs/features/load-records/src/components/LoadRecordsDataPreview.tsx index 32f0d5af1..8832a8329 100644 --- a/libs/features/load-records/src/components/LoadRecordsDataPreview.tsx +++ b/libs/features/load-records/src/components/LoadRecordsDataPreview.tsx @@ -23,7 +23,7 @@ import { applicationCookieState, selectSkipFrontdoorAuth } from '@jetstream/ui/a import { useAtom, useAtomValue } from 'jotai'; import isNil from 'lodash/isNil'; import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'; -import { Column } from 'react-data-grid'; +import type { Column } from '@jetstream/ui'; import { ErrorBoundary } from 'react-error-boundary'; const MAX_RECORD_FOR_PREVIEW = 100_000; diff --git a/libs/features/load-records/src/components/LoadRecordsDuplicateWarning.tsx b/libs/features/load-records/src/components/LoadRecordsDuplicateWarning.tsx index 6a81095d2..c21155faa 100644 --- a/libs/features/load-records/src/components/LoadRecordsDuplicateWarning.tsx +++ b/libs/features/load-records/src/components/LoadRecordsDuplicateWarning.tsx @@ -1,6 +1,7 @@ import { ContextMenuItem, FieldMapping, InsertUpdateUpsertDelete, Maybe } from '@jetstream/types'; import { AutoFullHeightContainer, + Column, ContextAction, ContextMenuActionData, DataTable, @@ -13,7 +14,6 @@ import { } from '@jetstream/ui'; import { checkForDuplicateRecords } from '@jetstream/ui-core'; import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'; -import { Column } from 'react-data-grid'; const DUPE_COLUMN = '_DUPLICATE'; diff --git a/libs/features/load-records/src/steps/FieldMapping.tsx b/libs/features/load-records/src/steps/FieldMapping.tsx index 2fed8c7eb..a66815345 100644 --- a/libs/features/load-records/src/steps/FieldMapping.tsx +++ b/libs/features/load-records/src/steps/FieldMapping.tsx @@ -293,7 +293,18 @@ export const LoadRecordsFieldMapping = memo( onReload={handleCacheRefresh} />
- +
thead > tr > th:last-of-type, + & > tbody > tr > td:last-of-type { + width: 1%; + white-space: nowrap; + } + `} + >
setExpandedGroupIds(items)} + contextMenuItems={getFieldPermissionContextMenuItems} + contextMenuAction={handleFieldPermissionContextMenuAction} /> diff --git a/libs/features/manage-permissions/src/utils/permission-manager-table-utils.tsx b/libs/features/manage-permissions/src/utils/permission-manager-table-utils.tsx index ebdfe8d6e..1d7514d76 100644 --- a/libs/features/manage-permissions/src/utils/permission-manager-table-utils.tsx +++ b/libs/features/manage-permissions/src/utils/permission-manager-table-utils.tsx @@ -1,6 +1,7 @@ import { css } from '@emotion/react'; import { formatNumber } from '@jetstream/shared/ui-utils'; import { groupByFlat, orderValues, pluralizeFromNumber } from '@jetstream/shared/utils'; +import type { ContextMenuItem } from '@jetstream/types'; import { BulkActionCheckbox, DirtyRow, @@ -27,9 +28,13 @@ import { TabVisibilityPermissionItem, TabVisibilityPermissionTypes, } from '@jetstream/types'; +import type { RenderCellProps, RenderSummaryCellProps } from '@jetstream/ui'; import { Checkbox, ColumnWithFilter, + ContextAction, + ContextMenuActionData, + copyGenericTableDataToClipboard, DataTableGenericContext, Grid, Icon, @@ -37,13 +42,12 @@ import { Popover, PopoverRef, SearchInput, + setColumnFromType, SummaryFilterRenderer, Tooltip, - setColumnFromType, } from '@jetstream/ui'; import startCase from 'lodash/startCase'; import { Fragment, useContext, useMemo, useRef, useState } from 'react'; -import { RenderCellProps, RenderSummaryCellProps } from 'react-data-grid'; type PermissionTypeColumn = T extends 'object' ? ColumnWithFilter @@ -451,18 +455,33 @@ export function getFieldColumns( ...(setColumnFromType('sobject', 'text') as any), name: 'Object', key: 'sobject', - width: 85, + // Grouping is by sobject. Because the permission columns supply their own `renderGroupCell` (the + // per-column checked counts), the grid no longer renders its fallback full-width header — so this + // column owns the group row's expand toggle + object name + child count. + width: 200, + resizable: true, cellClass: 'bg-color-gray-dark', summaryCellClass: 'bg-color-gray-dark', - renderGroupCell: ({ isExpanded }) => ( - + // In the group header only, span Object + Field + row-action so the object name has room. Returns + // undefined for data/summary rows, so those cells stay one column wide. + colSpan: (args) => (args.type === 'GROUP' ? 3 : undefined), + renderGroupCell: ({ groupKey, childRows, isExpanded, toggleGroup }) => ( + ), }, { @@ -471,14 +490,7 @@ export function getFieldColumns( key: 'tableLabel', frozen: true, width: 300, - renderGroupCell: ({ groupKey, childRows, toggleGroup }) => ( - <> - - ({childRows.length}) - - ), + resizable: true, summaryCellClass: 'bg-color-gray-dark no-outline', renderSummaryCell: ({ row }) => { if (row.type === 'HEADING') { @@ -609,7 +621,7 @@ function getColumnForProfileOrPermSet({ } return undefined; }, - renderCell: ({ row, onRowChange }) => { + renderCell: ({ row, commitEdit }) => { // If the row is not editable, then we don't want to show the checkbox if (permissionType === 'tabVisibility' && 'canSetPermission' in row && !row.canSetPermission) { return null; @@ -621,10 +633,10 @@ function getColumnForProfileOrPermSet({ function handleChange(value: boolean) { if (permissionType === 'object') { const newRow = setObjectValue(actionKey as PermissionActionAction<'object'>, row as PermissionTableObjectCell, id, value); - onRowChange(newRow); + commitEdit(newRow); } else if (permissionType === 'field') { const newRow = setFieldValue(actionKey as PermissionActionAction<'field'>, row as PermissionTableFieldCell, id, value); - onRowChange(newRow); + commitEdit(newRow); } else if (permissionType === 'tabVisibility') { const newRow = setTabVisibilityValue( actionKey as PermissionActionAction<'tabVisibility'>, @@ -632,7 +644,7 @@ function getColumnForProfileOrPermSet({ id, value, ); - onRowChange(newRow); + commitEdit(newRow); } } @@ -644,8 +656,11 @@ function getColumnForProfileOrPermSet({ type="checkbox" id={`${row.key}-${id}-${actionKey}`} checked={value} + tabIndex={-1} + // Stop the click from also reaching the wrapping div's onClick — otherwise a direct click + // (or programmatic keyboard activation) toggles via both handlers. onChange owns the toggle. + onClick={(ev) => ev.stopPropagation()} onChange={(ev) => { - ev.stopPropagation(); handleChange(ev.target.checked); }} disabled={disabled} @@ -680,10 +695,76 @@ function getColumnForProfileOrPermSet({ } return )} />; }, + // On grouped tables (field permissions) the group header shows how many of the group's child rows + // have this permission checked. Object/tab tables aren't grouped, so this never renders there. + renderGroupCell: ({ childRows }) => { + const checkedCount = childRows.reduce((total, childRow) => total + ((childRow.permissions[id] as any)?.[actionKey] ? 1 : 0), 0); + return ; + }, }; return column as PermissionTypeColumn; } +/** Group-header summary for a permission column: how many child rows have it checked, out of the total. */ +function GroupCheckedCount({ checkedCount, totalCount }: { checkedCount: number; totalCount: number }) { + return ( +
+ 0 ? 'slds-text-color_default' : 'slds-text-color_weak'}> + {formatNumber(checkedCount)} + {` / ${formatNumber(totalCount)}`} + +
+ ); +} + +// Field-name column copy scoped to the right-clicked object; the unique-objects copy for the object column. +const COPY_COL_OBJECT = 'COPY_COL_OBJECT'; +const COPY_COL_OBJECTS_UNIQUE = 'COPY_COL_OBJECTS_UNIQUE'; + +/** + * Per-cell context menu for the field-permissions table. Only the Object and Field columns get a menu + * (the Read/Edit checkbox columns are skipped), and the only actions are column copies. + */ +export function getFieldPermissionContextMenuItems(data: ContextMenuActionData): ContextMenuItem[] { + if (data.column.key === 'sobject') { + // The object value repeats once per field — copy the de-duplicated list of object names. + return [{ label: 'Copy column (All Objects)', value: COPY_COL_OBJECTS_UNIQUE }]; + } + if (data.column.key === 'tableLabel') { + // Field column: the field names for the clicked object, or for every object. + return [ + { label: `Copy column (${data.row.sobject})`, value: COPY_COL_OBJECT }, + { label: 'Copy column (All Objects)', value: 'COPY_COL' }, + ]; + } + return []; +} + +export function handleFieldPermissionContextMenuAction(item: ContextMenuItem, data: ContextMenuActionData): void { + const fields = data.columns.map((column) => column.key); + if (item.value === COPY_COL_OBJECT) { + const rowsForObject = data.rows.filter((row) => row.sobject === data.row.sobject); + copyGenericTableDataToClipboard('COPY_COL', fields, { ...data, rows: rowsForObject }); + return; + } + if (item.value === COPY_COL_OBJECTS_UNIQUE) { + const seen = new Set(); + const uniqueRows = data.rows.filter((row) => { + if (seen.has(row.sobject)) { + return false; + } + seen.add(row.sobject); + return true; + }); + copyGenericTableDataToClipboard('COPY_COL', fields, { ...data, rows: uniqueRows }); + return; + } + copyGenericTableDataToClipboard(item.value as ContextAction, fields, data); +} + export function getFieldRows( selectedSObjects: string[], fieldsByObject: Record, @@ -1179,14 +1260,13 @@ export const PinnedSelectAllRendererWrapper = ({ column }: RenderSummaryCellProp return (
- - } - 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={ -
-

{row._saveError}

-
- } - 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>((output, field) => { - output[field] = (record as Record)[field]; - return output; - }, {}), - ); - copyRecordsToClipboard(filteredRecords, 'json'); - } else { - copyRecordsToClipboard(recordsToCopy, 'excel', fieldsToCopy, includeHeader); - } - } -} - -/** - * FOR USE IN SALESFORCE RECORDS ONLY (assumes _record property) - * Generic function to copy table data to clipboard - * Assumes ContextMenuItem[] - * Other use-cases will need to implement their own - */ -export function copySalesforceRecordTableDataToClipboard( - action: ContextAction, - fields: string[], - { row, rows, column, columns }: ContextMenuActionData, -) { - let includeHeader = true; - let recordsToCopy: unknown[] = []; - const records = rows.map((row) => row._record); - const fieldsSet = new Set(fields); - let fieldsToCopy = columns.map((column) => column.key).filter((field) => fieldsSet.has(field)); // prefer this over fields because it accounts for reordering - let format: 'plain' | 'excel' | 'json' = 'plain'; - - switch (action) { - case 'COPY_CELL': - includeHeader = false; - fieldsToCopy = [column.key]; - recordsToCopy = [row._record]; - break; - - case 'COPY_ROW_EXCEL': - format = 'excel'; - recordsToCopy = [row._record]; - break; - - case 'COPY_ROW_JSON': - recordsToCopy = [row._record]; - format = 'json'; - break; - - case 'COPY_COL': - fieldsToCopy = fieldsToCopy.filter((field) => field === column.key); - recordsToCopy = records.map((row) => ({ [column.key]: row[column.key] })); - format = 'excel'; - break; - - case 'COPY_COL_JSON': - fieldsToCopy = fieldsToCopy.filter((field) => field === column.key); - recordsToCopy = records.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 = records.map((row) => ({ [column.key]: row[column.key] })); - format = 'plain'; - break; - - case 'COPY_TABLE': - recordsToCopy = records; - break; - - case 'COPY_TABLE_JSON': - recordsToCopy = records; - format = 'json'; - break; - - default: - break; - } - if (recordsToCopy.length) { - if (format === 'json') { - const filteredRecords = recordsToCopy.map((record) => - fieldsToCopy.reduce>((output, field) => { - output[field] = (record as Record)[field]; - return output; - }, {}), - ); - copyRecordsToClipboard(filteredRecords, 'json'); - } else { - copyRecordsToClipboard(recordsToCopy, 'excel', fieldsToCopy, includeHeader); - } - } -} + * Re-export of the new grid utilities. The legacy implementation moved into `grid/`. + */ +export { + ACTION_COLUMN_KEY, + DEFAULT_ROW_HEIGHT, + EMPTY_FIELD, + NON_DATA_COLUMN_KEYS, + RECORD_ERROR_COLUMN_KEY, + SELECT_COLUMN_KEY, + TABLE_CONTEXT_MENU_ITEMS, +} from './grid/grid-constants'; +export { copyGenericTableDataToClipboard, copySalesforceRecordTableDataToClipboard } from './grid/grid-clipboard'; +export { computeFilterSetValues, filterRecord, isFilterActive, resetFilter } from './grid/grid-filters'; +export { getRowId, getRowTypeFromValue, getSearchTextByRow, getSfdcRetUrl, getSubqueryModalTagline } from './grid/grid-row-utils'; +export { + addFieldLabelToColumn, + getColumnDefinitions, + getColumnsForGenericTable, + setColumnFromType, + updateColumnFromType, + updateColumnWithEditMode, +} from './grid/grid-column-utils'; diff --git a/libs/ui/src/lib/data-table/grid/DataTableV2.tsx b/libs/ui/src/lib/data-table/grid/DataTableV2.tsx new file mode 100644 index 000000000..d538ed759 --- /dev/null +++ b/libs/ui/src/lib/data-table/grid/DataTableV2.tsx @@ -0,0 +1,195 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { ContextMenuItem, SalesforceOrgUi } from '@jetstream/types'; +import { ExpandedState, RowSelectionState } from '@tanstack/react-table'; +import { forwardRef, useCallback, useMemo } from 'react'; +import { RowHeightFn } from './components/GridBody'; +import { GridContainer } from './components/GridContainer'; +import { useJetstreamTable } from './core/useJetstreamTable'; +import './data-table-grid.css'; +import { GridFilterContext, GridGenericContext } from './grid-context'; +import { getSortedFilteredLeafRows } from './grid-row-utils'; +import { + ColumnWithFilter, + ContextMenuActionData, + ContextMenuItems, + DataTableRef, + DefaultColumnOptions, + RowWithKey, + SortColumn, +} from './grid-types'; + +export interface DataTableV2Props> { + data: TRow[]; + columns: ColumnWithFilter[]; + getRowKey: (row: TRow) => string; + org?: SalesforceOrgUi; + serverUrl?: string; + skipFrontdoorLogin?: boolean; + quickFilterText?: string | null; + includeQuickFilter?: boolean; + context?: TContext; + initialSortColumns?: SortColumn[]; + defaultColumnOptions?: DefaultColumnOptions; + rowAlwaysVisible?: (row: TRow) => boolean; + ignoreRowInSetFilter?: (row: TRow) => boolean; + onReorderColumns?: (columns: string[], columnOrder: number[]) => void; + onSortedAndFilteredRowsChange?: (rows: readonly TRow[]) => void; + onSortColumnsChange?: (sortColumns: SortColumn[]) => void; + /** Called when an inline edit commits; `rows` are the current display rows with the edit applied. */ + onRowsChange?: (rows: TRow[], data: { indexes: number[]; column: ColumnWithFilter }) => void; + /** Seed row height for the virtualizer (actual heights are measured dynamically). Either a fixed + * number or a per-row callback that distinguishes group rows from data rows. */ + rowHeight?: number | RowHeightFn; + rowClass?: (row: TRow) => string | undefined; + enableRowSelection?: boolean; + rowSelection?: RowSelectionState; + onRowSelectionChange?: (selection: RowSelectionState) => void; + /** Allow shift-click on headers to add secondary sorts (legacy grid parity). */ + enableMultiSort?: boolean; + ariaLabel?: string; + className?: string; + // ── Grouping / tree ── + role?: 'grid' | 'treegrid'; + /** Column keys to group rows by (creates group header rows). */ + grouping?: string[]; + /** For genuine parent→child hierarchy: return a row's child rows. */ + getSubRows?: (row: TRow, index: number) => TRow[] | undefined; + expanded?: ExpandedState; + onExpandedChange?: (expanded: ExpandedState) => void; + /** Initial expanded state when uncontrolled — `true` expands all groups/rows. */ + defaultExpanded?: ExpandedState | boolean; + /** Pinned summary rows rendered below the header. */ + summaryRows?: unknown[]; + /** Fixed height (px) for each pinned summary row; content-sized when omitted. */ + summaryRowHeight?: number; + /** Right-click context menu items — a static list or a per-cell builder (must be stable). */ + contextMenuItems?: ContextMenuItems; + /** Right-click context menu action handler (must be stable). */ + contextMenuAction?: (item: ContextMenuItem, data: ContextMenuActionData) => void; +} + +function DataTableV2Inner(props: DataTableV2Props, ref: React.Ref>) { + const { + data, + columns, + getRowKey, + org, + serverUrl, + skipFrontdoorLogin, + quickFilterText, + includeQuickFilter, + context, + initialSortColumns, + defaultColumnOptions, + rowAlwaysVisible, + ignoreRowInSetFilter, + onReorderColumns, + onSortedAndFilteredRowsChange, + onSortColumnsChange, + onRowsChange, + rowHeight, + rowClass, + enableRowSelection, + rowSelection, + onRowSelectionChange, + enableMultiSort, + ariaLabel, + className, + role, + grouping, + getSubRows, + expanded, + onExpandedChange, + defaultExpanded, + summaryRows, + summaryRowHeight, + contextMenuItems, + contextMenuAction, + } = props; + + const { table, gridId, orderedColumns, filters, filterSetValues, updateFilter, registerEditedValues } = useJetstreamTable({ + data, + columns, + getRowKey, + ref, + initialSortColumns, + quickFilterText, + includeQuickFilter, + rowAlwaysVisible, + ignoreRowInSetFilter, + defaultColumnOptions, + enableRowSelection, + rowSelection, + onRowSelectionChange, + enableMultiSort, + onReorderColumns, + onSortedAndFilteredRowsChange, + onSortColumnsChange, + grouping, + getSubRows, + expanded, + onExpandedChange, + defaultExpanded, + }); + + // Keep a freshly edited value selected under an active SET filter so the edited row doesn't vanish + // from view (legacy ADD_MODIFIED_VALUE_TO_SET_FILTER behavior), then forward the commit. + const handleRowsChange = useCallback( + (rows: TRow[], data: { indexes: number[]; column: ColumnWithFilter }) => { + registerEditedValues( + data.column.key, + data.indexes.map((index) => (rows[index] as Record)?.[data.column.key]), + ); + onRowsChange?.(rows, data); + }, + [onRowsChange, registerEditedValues], + ); + + const filterContextValue = useMemo(() => ({ filterSetValues, filters, updateFilter }), [filterSetValues, filters, updateFilter]); + + // The legacy DataTable exposed the filtered+sorted rows on the generic context; several consumers + // (e.g. permission-manager's ColumnSearchFilterSummary / BulkActionRenderer) still read `rows`, so + // keep providing them or those components crash on `rows.filter(...)` of undefined. Uses the + // collapse-independent leaf rows — group rows would duplicate each group's first child and + // collapsing a group would shrink the list (both corrupt "Showing X of Y" and bulk actions). + const sortedFlatRows = table.getSortedRowModel().flatRows; + const genericContextValue = useMemo( + () => ({ + ...context, + org, + serverUrl, + skipFrontdoorLogin, + columns: orderedColumns, + rows: getSortedFilteredLeafRows(table).map((row) => row.original), + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [context, org, serverUrl, skipFrontdoorLogin, orderedColumns, sortedFlatRows], + ); + + return ( + + + + + + ); +} + +export const DataTableV2 = forwardRef(DataTableV2Inner) as ( + props: DataTableV2Props & { ref?: React.Ref> }, +) => React.ReactElement; diff --git a/libs/ui/src/lib/data-table/grid/__tests__/buildColumnDefs.spec.ts b/libs/ui/src/lib/data-table/grid/__tests__/buildColumnDefs.spec.ts new file mode 100644 index 000000000..0950fe2c6 --- /dev/null +++ b/libs/ui/src/lib/data-table/grid/__tests__/buildColumnDefs.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from 'vitest'; +import { buildColumnDefs } from '../buildColumnDefs'; +import { ACTION_COLUMN_KEY, DEFAULT_COLUMN_WIDTH, SELECT_COLUMN_KEY } from '../grid-constants'; +import { ColumnWithFilter, JetstreamColumnMeta } from '../grid-types'; + +interface Row { + _key: string; + Name: string; + Amount: number; +} + +function metaOf(def: { meta?: { jetstream?: JetstreamColumnMeta } }) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return def.meta!.jetstream!; +} + +describe('buildColumnDefs', () => { + const columns: ColumnWithFilter[] = [ + { key: ACTION_COLUMN_KEY, name: '', frozen: true, width: 116 }, + { key: SELECT_COLUMN_KEY, name: '', resizable: false }, + { key: 'Name', name: 'Name', sortable: true, resizable: true, filters: ['TEXT', 'SET'], width: 250 }, + { key: 'Amount', name: 'Amount', sortable: true, filters: ['NUMBER'] }, + ]; + + test('maps keys to ids and preserves order', () => { + const defs = buildColumnDefs(columns); + expect(defs.map((def) => def.id)).toEqual([ACTION_COLUMN_KEY, SELECT_COLUMN_KEY, 'Name', 'Amount']); + }); + + test('classifies cellKind for select/action/data columns', () => { + const defs = buildColumnDefs(columns); + expect(metaOf(defs[0]).cellKind).toBe('action'); + expect(metaOf(defs[1]).cellKind).toBe('select'); + expect(metaOf(defs[2]).cellKind).toBe('data'); + }); + + test('data columns get an accessor; non-data columns do not', () => { + const defs = buildColumnDefs(columns); + // accessorFn present only on data columns + expect('accessorFn' in defs[0]).toBe(false); + expect('accessorFn' in defs[1]).toBe(false); + expect('accessorFn' in defs[2]).toBe(true); + }); + + test('sizing maps width/min/max; string/absent width falls back to default', () => { + const defs = buildColumnDefs(columns); + expect(defs[2].size).toBe(250); + expect(defs[3].size).toBe(DEFAULT_COLUMN_WIDTH); + }); + + test('sorting/filtering enabled per column; disabled on non-data columns', () => { + const defs = buildColumnDefs(columns); + expect(defs[0].enableSorting).toBe(false); + expect(defs[2].enableSorting).toBe(true); + expect(defs[2].enableColumnFilter).toBe(true); + expect(defs[1].enableColumnFilter).toBe(false); + }); + + test('carries the original column on meta for the presentational layer', () => { + const defs = buildColumnDefs(columns); + expect(metaOf(defs[2]).column.key).toBe('Name'); + expect(metaOf(defs[0]).frozen).toBe(true); + }); + + test('defaultColumnOptions fill gaps', () => { + const defs = buildColumnDefs(columns, { resizable: true, sortable: true }); + // Amount column did not set resizable; default applies + expect(defs[3].enableResizing).toBe(true); + }); +}); diff --git a/libs/ui/src/lib/data-table/grid/__tests__/grid-filters.spec.ts b/libs/ui/src/lib/data-table/grid/__tests__/grid-filters.spec.ts new file mode 100644 index 000000000..15b56db21 --- /dev/null +++ b/libs/ui/src/lib/data-table/grid/__tests__/grid-filters.spec.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from 'vitest'; +import { EMPTY_FIELD } from '../grid-constants'; +import { computeFilterSetValues, filterRecord, hasFilterApplied, isFilterActive, resetFilter } from '../grid-filters'; +import { ColumnWithFilter } from '../grid-types'; + +describe('resetFilter', () => { + test('produces empty defaults per type', () => { + expect(resetFilter('TEXT')).toEqual({ type: 'TEXT', value: '' }); + expect(resetFilter('NUMBER')).toEqual({ type: 'NUMBER', value: null, comparator: 'EQUALS' }); + expect(resetFilter('DATE')).toEqual({ type: 'DATE', value: '', comparator: 'GREATER_THAN' }); + expect(resetFilter('TIME')).toEqual({ type: 'TIME', value: '', comparator: 'GREATER_THAN' }); + expect(resetFilter('SET', ['a', 'b'])).toEqual({ type: 'SET', value: ['a', 'b'] }); + expect(resetFilter('BOOLEAN_SET', ['True', 'False'])).toEqual({ type: 'BOOLEAN_SET', value: ['True', 'False'] }); + }); +}); + +describe('isFilterActive', () => { + test('TEXT active only when non-empty', () => { + expect(isFilterActive({ type: 'TEXT', value: '' }, 0)).toBe(false); + expect(isFilterActive({ type: 'TEXT', value: 'x' }, 0)).toBe(true); + }); + test('SET active when fewer than total selected', () => { + expect(isFilterActive({ type: 'SET', value: ['a', 'b'] }, 2)).toBe(false); + expect(isFilterActive({ type: 'SET', value: ['a'] }, 2)).toBe(true); + }); + test('BOOLEAN_SET active unless both selected', () => { + expect(isFilterActive({ type: 'BOOLEAN_SET', value: ['True', 'False'] }, 2)).toBe(false); + expect(isFilterActive({ type: 'BOOLEAN_SET', value: ['True'] }, 2)).toBe(true); + }); +}); + +describe('filterRecord', () => { + test('TEXT does case-insensitive contains', () => { + expect(filterRecord({ type: 'TEXT', value: 'lph' }, 'Alpha')).toBe(true); + expect(filterRecord({ type: 'TEXT', value: 'zzz' }, 'Alpha')).toBe(false); + expect(filterRecord({ type: 'TEXT', value: '42' }, 42)).toBe(true); + }); + test('NUMBER honors comparator', () => { + expect(filterRecord({ type: 'NUMBER', value: '5', comparator: 'GREATER_THAN' }, 6)).toBe(true); + expect(filterRecord({ type: 'NUMBER', value: '5', comparator: 'LESS_THAN' }, 4)).toBe(true); + expect(filterRecord({ type: 'NUMBER', value: '5', comparator: 'EQUALS' }, 5)).toBe(true); + expect(filterRecord({ type: 'NUMBER', value: '5', comparator: 'EQUALS' }, 6)).toBe(false); + expect(filterRecord({ type: 'NUMBER', value: '5', comparator: 'EQUALS' }, 'not-a-number')).toBe(false); + }); + test('SET matches selected values and EMPTY_FIELD matches null', () => { + expect(filterRecord({ type: 'SET', value: ['Alpha', 'Bravo'] }, 'Alpha')).toBe(true); + expect(filterRecord({ type: 'SET', value: ['Alpha'] }, 'Bravo')).toBe(false); + expect(filterRecord({ type: 'SET', value: [EMPTY_FIELD] }, null)).toBe(true); + expect(filterRecord({ type: 'SET', value: ['Alpha'] }, null)).toBe(false); + }); + test('BOOLEAN_SET both selected always matches; single selection compares', () => { + expect(filterRecord({ type: 'BOOLEAN_SET', value: ['True', 'False'] }, true)).toBe(true); + expect(filterRecord({ type: 'BOOLEAN_SET', value: ['True'] }, true)).toBe(true); + expect(filterRecord({ type: 'BOOLEAN_SET', value: ['True'] }, false)).toBe(false); + expect(filterRecord({ type: 'BOOLEAN_SET', value: [] }, true)).toBe(false); + }); +}); + +describe('computeFilterSetValues', () => { + interface Row { + _key: string; + Name: string; + Active: boolean; + } + const data: Row[] = [ + { _key: '1', Name: 'Alpha', Active: true }, + { _key: '2', Name: 'Bravo', Active: false }, + { _key: '3', Name: 'Alpha', Active: true }, + ]; + const columns: ColumnWithFilter[] = [ + { key: 'Name', name: 'Name', filters: ['TEXT', 'SET'] }, + { key: 'Active', name: 'Active', filters: ['BOOLEAN_SET'] }, + { key: 'NoFilter', name: 'NoFilter' }, + ]; + + test('distinct SET values + boolean fixed values, skipping unfiltered columns', () => { + const result = computeFilterSetValues(columns, data); + expect(result['Name'].sort()).toEqual(['Alpha', 'Bravo']); + expect(result['Active']).toEqual(['True', 'False']); + expect(result['NoFilter']).toBeUndefined(); + }); + + test('null values collapse to EMPTY_FIELD and ignoreRowInSetFilter excludes rows', () => { + const withNull: Row[] = [...data, { _key: '4', Name: null as unknown as string, Active: true }]; + const result = computeFilterSetValues(columns, withNull, (row) => row._key === '4'); + expect(result['Name']).not.toContain(EMPTY_FIELD); + const resultIncluding = computeFilterSetValues(columns, withNull); + expect(resultIncluding['Name']).toContain(EMPTY_FIELD); + }); +}); + +describe('hasFilterApplied', () => { + test('true only when a filter narrows results', () => { + const filterSetValues = { Name: ['Alpha', 'Bravo'] }; + expect(hasFilterApplied({ Name: [{ type: 'SET', value: ['Alpha', 'Bravo'] }] }, filterSetValues)).toBe(false); + expect(hasFilterApplied({ Name: [{ type: 'SET', value: ['Alpha'] }] }, filterSetValues)).toBe(true); + expect(hasFilterApplied({ Name: [{ type: 'TEXT', value: '' }] }, filterSetValues)).toBe(false); + expect(hasFilterApplied({ Name: [{ type: 'TEXT', value: 'x' }] }, filterSetValues)).toBe(true); + }); +}); diff --git a/libs/ui/src/lib/data-table/grid/__tests__/reorderColumnOrder.spec.ts b/libs/ui/src/lib/data-table/grid/__tests__/reorderColumnOrder.spec.ts new file mode 100644 index 000000000..18652a3d5 --- /dev/null +++ b/libs/ui/src/lib/data-table/grid/__tests__/reorderColumnOrder.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from 'vitest'; +import { reorderColumnOrder } from '../grid-column-utils'; +import { ACTION_COLUMN_KEY, SELECT_COLUMN_KEY } from '../grid-constants'; + +describe('reorderColumnOrder', () => { + const order = [ACTION_COLUMN_KEY, SELECT_COLUMN_KEY, 'Name', 'Amount', 'Stage']; + + test('moves a column to the right of a later target', () => { + expect(reorderColumnOrder(order, 'Name', 'Stage', 'right')).toEqual([ACTION_COLUMN_KEY, SELECT_COLUMN_KEY, 'Amount', 'Stage', 'Name']); + }); + + test('moves a column to the left of a later target', () => { + expect(reorderColumnOrder(order, 'Name', 'Stage', 'left')).toEqual([ACTION_COLUMN_KEY, SELECT_COLUMN_KEY, 'Amount', 'Name', 'Stage']); + }); + + test('moves a column to the right of an earlier target', () => { + expect(reorderColumnOrder(order, 'Stage', 'Name', 'right')).toEqual([ACTION_COLUMN_KEY, SELECT_COLUMN_KEY, 'Name', 'Stage', 'Amount']); + }); + + test('moves a column to the left of an earlier target', () => { + expect(reorderColumnOrder(order, 'Stage', 'Name', 'left')).toEqual([ACTION_COLUMN_KEY, SELECT_COLUMN_KEY, 'Stage', 'Name', 'Amount']); + }); + + test('returns the original reference for a no-op move (same source and target)', () => { + expect(reorderColumnOrder(order, 'Name', 'Name', 'left')).toBe(order); + }); + + test('returns the original reference when the resulting order is unchanged', () => { + // Dropping Name to the left of the column immediately after it is a no-op. + expect(reorderColumnOrder(order, 'Name', 'Amount', 'left')).toBe(order); + }); + + test('returns the original reference when an id is missing', () => { + expect(reorderColumnOrder(order, 'Missing', 'Name', 'left')).toBe(order); + expect(reorderColumnOrder(order, 'Name', 'Missing', 'left')).toBe(order); + }); +}); diff --git a/libs/ui/src/lib/data-table/grid/__tests__/useJetstreamTable.spec.tsx b/libs/ui/src/lib/data-table/grid/__tests__/useJetstreamTable.spec.tsx new file mode 100644 index 000000000..aa60814cc --- /dev/null +++ b/libs/ui/src/lib/data-table/grid/__tests__/useJetstreamTable.spec.tsx @@ -0,0 +1,90 @@ +import { act, renderHook } from '@testing-library/react'; +import { createRef } from 'react'; +import { describe, expect, test } from 'vitest'; +import { useJetstreamTable } from '../core/useJetstreamTable'; +import { ColumnWithFilter, DataTableRef } from '../grid-types'; + +interface Row { + _key: string; + Id: string; + Name: string; + Notes: string; +} + +const columns: ColumnWithFilter[] = [ + { key: 'Id', name: 'Id', sortable: true, filters: ['TEXT', 'SET'] }, + { key: 'Name', name: 'Name', sortable: true, filters: ['TEXT', 'SET'] }, + { key: 'Notes', name: 'Notes', sortable: true, filters: ['TEXT'] }, +]; + +const data: Row[] = [ + { _key: '1', Id: '001', Name: 'Charlie', Notes: 'hello world' }, + { _key: '2', Id: '002', Name: 'Alpha', Notes: 'foo bar' }, + { _key: '3', Id: '003', Name: 'Bravo', Notes: 'baz qux' }, +]; + +function setup(overrides: Partial>[0]> = {}) { + const ref = createRef>(); + const result = renderHook(() => + useJetstreamTable({ + data, + columns, + getRowKey: (row) => row._key, + includeQuickFilter: true, + ref, + ...overrides, + }), + ); + // flush the INIT effect that seeds filters + distinct set values + act(() => undefined); + return { ...result, ref }; +} + +function rowKeys(rows: readonly Row[]) { + return rows.map((row) => row._key); +} + +describe('useJetstreamTable', () => { + test('renders all rows when no filter/sort applied', () => { + const { result } = setup(); + expect(result.current.table.getRowModel().rows).toHaveLength(3); + }); + + test('quick filter narrows rows case-insensitively', () => { + const { result } = setup({ quickFilterText: 'ALPHA' }); + const rows = result.current.table.getRowModel().rows.map((row) => row.original); + expect(rowKeys(rows)).toEqual(['2']); + }); + + test('sorting by Name ascending reorders rows', () => { + const { result } = setup({ initialSortColumns: [{ columnKey: 'Name', direction: 'ASC' }] }); + const rows = result.current.table.getRowModel().rows.map((row) => row.original); + expect(rows.map((row) => row.Name)).toEqual(['Alpha', 'Bravo', 'Charlie']); + }); + + test('SET filter narrows to selected values', () => { + const { result } = setup(); + act(() => { + result.current.updateFilter('Name', { type: 'SET', value: ['Alpha'] }); + }); + const rows = result.current.table.getRowModel().rows.map((row) => row.original); + expect(rowKeys(rows)).toEqual(['2']); + }); + + test('rowAlwaysVisible bypasses active filters', () => { + const { result } = setup({ rowAlwaysVisible: (row) => row._key === '3' }); + act(() => { + result.current.updateFilter('Name', { type: 'SET', value: ['Alpha'] }); + }); + const rows = result.current.table.getRowModel().rows.map((row) => row.original); + expect(rowKeys(rows).sort()).toEqual(['2', '3']); + }); + + test('ref exposes filtered + sorted rows and sort state', () => { + const { ref } = setup({ initialSortColumns: [{ columnKey: 'Name', direction: 'DESC' }] }); + expect(ref.current?.hasSortApplied()).toBe(true); + const refRows = ref.current?.getFilteredAndSortedRows() ?? []; + expect(refRows.map((row) => row.Name)).toEqual(['Charlie', 'Bravo', 'Alpha']); + expect(ref.current?.getCurrentColumnNames()).toEqual(['Id', 'Name', 'Notes']); + }); +}); diff --git a/libs/ui/src/lib/data-table/grid/buildColumnDefs.ts b/libs/ui/src/lib/data-table/grid/buildColumnDefs.ts new file mode 100644 index 000000000..73b19f07c --- /dev/null +++ b/libs/ui/src/lib/data-table/grid/buildColumnDefs.ts @@ -0,0 +1,79 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { ColumnDef } from '@tanstack/react-table'; +import { jetstreamColumnFilterFn } from './filterFns'; +import { ACTION_COLUMN_KEY, DEFAULT_COLUMN_WIDTH, DEFAULT_MIN_COLUMN_WIDTH, SELECT_COLUMN_KEY } from './grid-constants'; +import { CellKind, ColumnWithFilter, DefaultColumnOptions, JetstreamColumnMeta } from './grid-types'; + +/** + * Map our author-facing `ColumnWithFilter[]` to TanStack `ColumnDef[]`. + * + * The presentational layer (GridHeader / GridCell / GridGroupRow) renders directly from the original + * `ColumnWithFilter` carried on `meta.jetstream.column` rather than via `flexRender`, so this mapping + * only needs to get the data model right: stable id, accessor (drives sort/group), sizing, sortability, + * filterability, and meta. `cellKind` replaces the legacy `dataTableRenderFnMap` renderer-identity trick. + */ +export function buildColumnDefs( + columns: ColumnWithFilter[], + defaultColumnOptions?: DefaultColumnOptions, +): ColumnDef[] { + return columns.map((column) => toColumnDef(column, defaultColumnOptions)); +} + +function resolveCellKind(key: string): CellKind { + if (key === SELECT_COLUMN_KEY) { + return 'select'; + } + if (key === ACTION_COLUMN_KEY) { + return 'action'; + } + return 'data'; +} + +function toColumnDef( + column: ColumnWithFilter, + defaultColumnOptions?: DefaultColumnOptions, +): ColumnDef { + const cellKind = resolveCellKind(column.key); + const isDataColumn = cellKind === 'data'; + + const resizable = column.resizable ?? defaultColumnOptions?.resizable ?? false; + const sortable = isDataColumn && (column.sortable ?? defaultColumnOptions?.sortable ?? false); + const hasFilters = isDataColumn && Array.isArray(column.filters) && column.filters.length > 0; + + const width = column.width ?? defaultColumnOptions?.width; + const minWidth = column.minWidth ?? defaultColumnOptions?.minWidth ?? DEFAULT_MIN_COLUMN_WIDTH; + const maxWidth = column.maxWidth ?? defaultColumnOptions?.maxWidth; + + const meta: JetstreamColumnMeta = { + column, + cellKind, + filters: column.filters, + frozen: column.frozen, + cellClass: column.cellClass, + colSpan: column.colSpan, + renderGroupCell: column.renderGroupCell, + renderSummaryCell: column.renderSummaryCell, + editor: column.renderEditCell, + editable: column.editable, + editorOptions: column.editorOptions, + }; + + const columnDef: ColumnDef = { + id: column.key, + // Display (select/action) columns have no meaningful accessor. + ...(isDataColumn ? { accessorFn: (row: TRow) => (row as Record)[column.key] } : {}), + header: typeof column.name === 'string' ? column.name : column.key, + enableSorting: sortable, + enableResizing: resizable, + enableColumnFilter: hasFilters, + enableGlobalFilter: isDataColumn, + enableGrouping: isDataColumn, + size: typeof width === 'number' ? width : DEFAULT_COLUMN_WIDTH, + minSize: minWidth, + ...(typeof maxWidth === 'number' ? { maxSize: maxWidth } : {}), + filterFn: jetstreamColumnFilterFn, + meta: { jetstream: meta as JetstreamColumnMeta }, + }; + + return columnDef; +} diff --git a/libs/ui/src/lib/data-table/grid/components/GridBody.tsx b/libs/ui/src/lib/data-table/grid/components/GridBody.tsx new file mode 100644 index 000000000..ecc2c1321 --- /dev/null +++ b/libs/ui/src/lib/data-table/grid/components/GridBody.tsx @@ -0,0 +1,269 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Table } from '@tanstack/react-table'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { CSSProperties, RefObject, useEffect, useMemo, useRef } from 'react'; +import { DEFAULT_ROW_HEIGHT, HEADER_ROW_ID, isSummaryRowId } from '../grid-constants'; +import { GridMode, SelectionRange } from '../keyboard/useGridKeyboardNavigation'; +import { GridGroupRow } from './GridGroupRow'; +import { ActiveCell, GridRow } from './GridRow'; + +export type RowHeightFn = (args: { type: 'ROW' | 'GROUP'; row: TRow }) => number; + +export interface GridBodyProps { + table: Table; + /** Scroll element that owns vertical scrolling (the virtualizer measures against it). */ + scrollRef: RefObject; + gridTemplateColumns: string; + /** Visible leaf-column indexes to render (windowed + always-on frozen), passed through to each row. */ + visibleColumnIndexes: number[]; + /** Fixed numeric height per row, or a per-row callback. This is the authoritative, deterministic row + * height — each row's box is pinned to it. Rows are NOT DOM-measured: with column virtualization the + * mounted-cell set changes during horizontal scroll, so a measured height would oscillate and reflow + * the whole body. A callback returning data-driven heights (e.g. by content length) is the way to size + * variable rows. */ + rowHeight?: number | RowHeightFn; + overscan?: number; + /** Number of pinned summary rows rendered above the body (in the sticky header block). Body rows + * offset their `aria-rowindex` past these so the grid's row numbering stays continuous and unique. */ + summaryRowCount?: number; + activeCell?: ActiveCell | null; + mode?: GridMode; + /** Whether the active cell last changed via mouse vs keyboard — on mouse we skip auto-focusing the + * cell so popovers/controls opened by the click keep focus; on 'select-all' we additionally skip + * scroll-into-view (Ctrl+A must not jump the viewport to the last row). */ + getLastInteractionSource?: () => 'mouse' | 'keyboard' | 'select-all'; + /** The cell currently being edited (its editor owns focus, so the body must not steal it). */ + editingCell?: ActiveCell | null; + /** Rectangular cell selection (display-index bounds), or null when collapsed to one cell. */ + selectionRange?: SelectionRange | null; + onCellMouseDown?: (rowId: string, columnId: string, shiftKey: boolean, button?: number) => void; + onCellMouseEnter?: (rowId: string, columnId: string) => void; + onCellContextMenu?: (event: React.MouseEvent, rowId: string, columnId: string) => void; + rowClass?: (row: TRow) => string | undefined; + onStartEdit?: (rowId: string, columnId: string) => void; + onCommitRow?: (updatedRow: TRow, rowId: string, columnId: string) => void; +} + +// In-cell controls are removed from the page tab order (tabindex="-1") so the grid is a single tab stop. +// Entering actionable mode must still focus the first one, so this selector intentionally INCLUDES +// `tabindex="-1"` controls. `[aria-haspopup]` matches floating-ui popover triggers. +const ACTIONABLE_FOCUSABLE_SELECTOR = + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [role="button"], [aria-haspopup]'; + +/** + * Owns the row virtualizer (deepest-component rule: instantiating it here keeps the measurement cache + * and scroll position stable when upstream sort/filter state changes). Also resolves the keyboard + * navigation's logical active-cell coordinate to a DOM element — scrolling it into view and focusing + * it (retrying across a few frames while the virtualizer mounts the target row). + */ +export function GridBody({ + table, + scrollRef, + gridTemplateColumns, + visibleColumnIndexes, + rowHeight, + overscan = 8, + summaryRowCount = 0, + activeCell, + mode = 'navigation', + getLastInteractionSource, + editingCell, + selectionRange, + onCellMouseDown, + onCellMouseEnter, + onCellContextMenu, + rowClass, + onStartEdit, + onCommitRow, +}: GridBodyProps) { + const { rows } = table.getRowModel(); + const leafColumns = table.getVisibleLeafColumns(); + + // Hold rowHeight in a ref so the virtualizer's estimateSize closure always reads the latest function + // (and the virtualizer's identity stays stable across renders). + const rowHeightRef = useRef(rowHeight); + rowHeightRef.current = rowHeight; + + const rowVirtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => scrollRef.current, + estimateSize: (index) => { + const current = rowHeightRef.current; + if (typeof current === 'function') { + const row = rows[index]; + if (row) { + return current({ type: row.getIsGrouped() ? 'GROUP' : 'ROW', row: row.original }); + } + return DEFAULT_ROW_HEIGHT; + } + return current ?? DEFAULT_ROW_HEIGHT; + }, + overscan, + getItemKey: (index) => rows[index].id, + }); + + // Resolve the active cell to a DOM node: scroll its row into view, then focus the cell (navigation) + // or the first focusable inside it (actionable). Runs only when the coordinate/mode changes — NOT on + // manual scroll — so scrolling away from the active cell never yanks the viewport back. + // True when the active cell is the one being edited — its editor input owns focus, so skip cell focus. + const isEditingActiveCell = + !!editingCell && !!activeCell && editingCell.rowId === activeCell.rowId && editingCell.columnId === activeCell.columnId; + + useEffect(() => { + if (!activeCell) { + return; + } + // Select-all moves the active corner to the last cell purely as selection bookkeeping — never + // scroll or move focus for it. + if (getLastInteractionSource?.() === 'select-all') { + return; + } + // The column header and pinned summary rows are virtual rows in the sticky header block (always + // mounted) — they have no body-row index to scroll to, but still resolve to a DOM cell below for focus. + const isHeaderOrSummary = activeCell.rowId === HEADER_ROW_ID || isSummaryRowId(activeCell.rowId); + if (!isHeaderOrSummary) { + const rowIndex = rows.findIndex((row) => row.id === activeCell.rowId); + if (rowIndex < 0) { + return; + } + rowVirtualizer.scrollToIndex(rowIndex, { align: 'auto' }); + } + + // The editor owns focus while editing; don't yank it to the cell. (When editing ends this effect + // re-runs with isEditingActiveCell=false and restores focus to the cell.) + if (isEditingActiveCell) { + return; + } + + // A mouse click already placed focus correctly — on the cell, or on an interactive control / portaled + // popover rendered inside it. Re-focusing the cell here would steal focus back and close those + // popovers, so only keyboard / programmatic activation drives cell focus. + if (getLastInteractionSource?.() === 'mouse') { + return; + } + + let attempts = 0; + // A captured cancel flag (vs cancelling only the last scheduled frame) terminates the retry chain + // even if effect cleanup fires between rAF dispatch and the next tryFocus call. Without this, rapid + // arrow-key navigation can leave a stale focus chain racing the new effect's focus target. + let cancelled = false; + const tryFocus = () => { + if (cancelled) { + return; + } + const cellEl = scrollRef.current?.querySelector( + `[data-row-id="${CSS.escape(activeCell.rowId)}"][data-col-id="${CSS.escape(activeCell.columnId)}"]`, + ); + if (cellEl) { + if (mode === 'actionable') { + // Move focus to the first interactive control. The cell DIV holding focus (the navigation-mode + // state) must NOT count as "already inside" — otherwise entering actionable mode from the cell + // would leave focus on the div, and Space/arrows would scroll the page instead of driving the + // control. Only skip when a control inside the cell already has focus. + const focusable = cellEl.querySelector(ACTIONABLE_FOCUSABLE_SELECTOR); + const controlAlreadyFocused = document.activeElement !== cellEl && cellEl.contains(document.activeElement); + if (!controlAlreadyFocused) { + (focusable ?? cellEl).focus(); + } + } else if (!cellEl.contains(document.activeElement)) { + cellEl.focus(); + } + return; + } + if (attempts++ < 6) { + requestAnimationFrame(tryFocus); + } + }; + requestAnimationFrame(tryFocus); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeCell, mode, isEditingActiveCell]); + + // Rows are pinned to deterministic heights (see `rowHeight` doc), so we never attach `measureElement`. + // The virtualizer's size memo omits `estimateSize` from its key, so when a rowHeight callback would + // return new values (e.g. a row's content grew after an edit) we must re-measure explicitly. Keyed on + // the row model + height source — both stable across horizontal scroll, so this never fires mid-scroll. + useEffect(() => { + rowVirtualizer.measure(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rows, rowHeight]); + + const virtualRows = rowVirtualizer.getVirtualItems(); + const totalSize = rowVirtualizer.getTotalSize(); + + const style: CSSProperties = { blockSize: totalSize }; + + // Stable identity for the column-range slice of the selection — every in-range row shares it, so + // memo'd rows don't re-render just because GridBody re-rendered for an unrelated reason. + const selectionColRange = useMemo( + () => (selectionRange ? { start: selectionRange.minCol, end: selectionRange.maxCol } : null), + [selectionRange], + ); + + if (rows.length === 0) { + return ( +
+
+
+ No data available +
+
+
+ ); + } + + return ( +
+ {virtualRows.map((virtualRow) => { + const row = rows[virtualRow.index]; + // Narrow the active cell to the row it belongs to — passing the (new-identity-per-move) object + // to every row would defeat GridRow's memo and re-render all mounted rows on each arrow key. + const rowActiveCell = activeCell && activeCell.rowId === row.id ? activeCell : null; + if (row.getIsGrouped()) { + return ( + + ); + } + const rowInRange = !!selectionRange && virtualRow.index >= selectionRange.minRow && virtualRow.index <= selectionRange.maxRow; + return ( + + ); + })} +
+ ); +} diff --git a/libs/ui/src/lib/data-table/grid/components/GridCell.tsx b/libs/ui/src/lib/data-table/grid/components/GridCell.tsx new file mode 100644 index 000000000..0190c9f0b --- /dev/null +++ b/libs/ui/src/lib/data-table/grid/components/GridCell.tsx @@ -0,0 +1,146 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Cell, Column } from '@tanstack/react-table'; +import classNames from 'classnames'; +import { CSSProperties, memo } from 'react'; +import { DataTableCellProps } from '../grid-types'; +import { getFrozenCellStyle } from './grid-layout'; + +export interface GridCellProps { + cell: Cell; + /** Visible leaf columns (for frozen offset math). */ + columns: Column[]; + rowIndex: number; + colIndex: number; + /** 1-based ARIA column index. */ + ariaColIndex: number; + /** Number of grid tracks this cell spans (ROW colSpan). Defaults to 1. */ + colSpan?: number; + /** Roving tabindex: only the active cell is focusable in navigation mode (phase 5). */ + isActive: boolean; + /** Explicit selection flag so memo'd cells re-render when selection flips (row refs are stable). */ + isSelected: boolean; + /** Explicit expanded flag (tree rows). Same rationale as `isSelected`: the Row instance is stable + * across expand/collapse, so the memo needs this prop to re-render the chevron on toggle. */ + rowIsExpanded?: boolean; + /** True when this cell is inside the rectangular cell-selection. */ + isRangeSelected: boolean; + onCellMouseDown?: (rowId: string, columnId: string, shiftKey: boolean, button?: number) => void; + onCellMouseEnter?: (rowId: string, columnId: string) => void; + onCellContextMenu?: (event: React.MouseEvent, rowId: string, columnId: string) => void; + onStartEdit?: (rowId: string, columnId: string) => void; + /** Commit an in-cell edit (e.g. a checkbox renderer) without opening the popover editor. */ + onCommitRow?: (updatedRow: TRow, rowId: string, columnId: string) => void; +} + +function GridCellComponent({ + cell, + columns, + rowIndex, + colIndex, + ariaColIndex, + colSpan = 1, + isActive, + isSelected, + rowIsExpanded, + isRangeSelected, + onCellMouseDown, + onCellMouseEnter, + onCellContextMenu, + onStartEdit, + onCommitRow, +}: GridCellProps) { + const meta = cell.column.columnDef.meta?.jetstream; + const column = meta?.column; + const row = cell.row.original; + const value = cell.getValue(); + + const cellKind = meta?.cellKind ?? 'data'; + const role = cellKind === 'rowheader' ? 'rowheader' : 'gridcell'; + + const dynamicClass = typeof meta?.cellClass === 'function' ? meta.cellClass(row) : meta?.cellClass; + + // `editable` may be a per-row predicate, so resolve it against this row before announcing read-only. + const editable = meta?.editable; + const isEditable = typeof editable === 'function' ? editable(row) : !!editable; + + const style: CSSProperties = { + ...(colSpan > 1 ? { gridColumn: `${colIndex + 1} / span ${colSpan}` } : { gridColumnStart: colIndex + 1 }), + ...getFrozenCellStyle(columns, colIndex), + }; + + let content: React.ReactNode; + // A custom renderCell wins even on the select column — consumers override it to suppress the + // checkbox for placeholder rows (e.g. deploy's "No metadata found" rows). The built-in checkbox is + // the fallback for bare select columns. + if (cellKind === 'select' && !column?.renderCell) { + content = ( + + event.stopPropagation()} + /> + + ); + } else if (column?.renderCell) { + const canExpand = cell.row.getCanExpand(); + const renderProps: DataTableCellProps = { + row, + column, + value, + rowIndex, + rowIdx: rowIndex, + tanstackRow: cell.row, + depth: cell.row.depth, + canExpand, + // Prefer the explicit prop (kept fresh by GridBody) so the chevron re-renders on toggle; fall + // back to the row for any caller that renders GridCell without threading the flag. + isExpanded: canExpand && (rowIsExpanded ?? cell.row.getIsExpanded()), + toggleExpanded: () => canExpand && cell.row.toggleExpanded(), + isEditing: false, + startEdit: () => onStartEdit?.(cell.row.id, cell.column.id), + commitEdit: (updatedRow) => onCommitRow?.(updatedRow, cell.row.id, cell.column.id), + cancelEdit: () => undefined, + }; + content = column.renderCell(renderProps); + } else { + content = value === null || value === undefined ? '' : String(value); + } + + return ( +
= columns.length, + }, + dynamicClass, + )} + style={style} + onMouseDown={(event) => onCellMouseDown?.(cell.row.id, cell.column.id, event.shiftKey, event.button)} + onMouseEnter={() => onCellMouseEnter?.(cell.row.id, cell.column.id)} + onContextMenu={(event) => onCellContextMenu?.(event, cell.row.id, cell.column.id)} + onDoubleClick={() => onStartEdit?.(cell.row.id, cell.column.id)} + > + {content} +
+ ); +} + +export const GridCell = memo(GridCellComponent) as typeof GridCellComponent; diff --git a/libs/ui/src/lib/data-table/grid/components/GridContainer.tsx b/libs/ui/src/lib/data-table/grid/components/GridContainer.tsx new file mode 100644 index 000000000..971394e3c --- /dev/null +++ b/libs/ui/src/lib/data-table/grid/components/GridContainer.tsx @@ -0,0 +1,623 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { ContextMenuItem } from '@jetstream/types'; +import { Table } from '@tanstack/react-table'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import classNames from 'classnames'; +import { CSSProperties, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ContextMenu } from '../../../form/context-menu/ContextMenu'; +import { EditorHost } from '../editors/EditorHost'; +import { reorderColumnOrder } from '../grid-column-utils'; +import { NON_DATA_COLUMN_KEYS } from '../grid-constants'; +import { GridRuntime, GridRuntimeContext } from '../grid-context'; +import { getSortedFilteredLeafRows } from '../grid-row-utils'; +import { ColumnWithFilter, ContextMenuActionData, ContextMenuItems, DataTableHeaderProps, RowWithKey } from '../grid-types'; +import { useGridKeyboardNavigation } from '../keyboard/useGridKeyboardNavigation'; +import { GridBody, RowHeightFn } from './GridBody'; +import { GridHeader } from './GridHeader'; +import { ActiveCell } from './GridRow'; +import { getGridTemplateColumns } from './grid-layout'; + +const COPY_RANGE_ACTION = '__COPY_RANGE__'; +const COPY_RANGE_WITH_HEADER_ACTION = '__COPY_RANGE_WITH_HEADER__'; + +/** Context-menu actions that operate on a column/table (no specific row) — the subset offered when + * right-clicking a column HEADER. Matches the `ContextAction` values used by the standard + * TABLE_CONTEXT_MENU_ITEMS; consumer items outside this set are cell-scoped and excluded. */ +const COLUMN_SCOPED_CONTEXT_ACTIONS = new Set([ + 'COPY_COL', + 'COPY_COL_JSON', + 'COPY_COL_NO_HEADER', + 'COPY_TABLE', + 'COPY_TABLE_JSON', + 'COPY_TABLE_CSV', +]); + +interface ContextMenuState { + area: 'cell' | 'header'; + /** Set for cell menus; absent for header menus. */ + rowId?: string; + columnId: string; + element: HTMLElement; + /** Items resolved when the menu opened (a per-cell builder may have produced these). */ + items: ContextMenuItem[]; + /** Cell action data captured at open time, passed to `contextMenuAction` on selection. */ + actionData: ContextMenuActionData | null; +} + +export interface GridContainerProps { + table: Table; + gridId: string; + getRowKey: (row: TRow) => string; + orderedColumns: ColumnWithFilter[]; + role?: 'grid' | 'treegrid'; + ariaLabel?: string; + className?: string; + /** Fixed numeric height, or per-row callback. Powers the virtualizer's initial size estimate. */ + rowHeight?: number | RowHeightFn; + overscan?: number; + rowClass?: (row: TRow) => string | undefined; + /** Optional header filter popover override (defaults to the built-in HeaderFilterButton). */ + renderFilter?: (props: DataTableHeaderProps) => ReactNode; + /** Called when an inline edit commits; `rows` are the filtered+sorted data rows with the edit applied. */ + onRowsChange?: (rows: TRow[], data: { indexes: number[]; column: ColumnWithFilter }) => void; + /** Pinned summary rows rendered below the header. */ + summaryRows?: unknown[]; + /** Fixed height (px) for each pinned summary row; content-sized when omitted. */ + summaryRowHeight?: number; + /** Right-click context menu items — a static list or a per-cell builder (must be stable). */ + contextMenuItems?: ContextMenuItems; + /** Right-click context menu action handler (must be stable). */ + contextMenuAction?: (item: ContextMenuItem, data: ContextMenuActionData) => void; + /** Slot for editor popovers / context menu portals rendered as siblings of the grid. */ + children?: ReactNode; +} + +export function GridContainer({ + table, + gridId, + getRowKey, + orderedColumns, + role = 'grid', + ariaLabel, + className, + rowHeight, + overscan, + rowClass, + renderFilter, + onRowsChange, + summaryRows, + summaryRowHeight, + contextMenuItems, + contextMenuAction, + children, +}: GridContainerProps) { + const scrollRef = useRef(null); + const gridRef = useRef(null); + + const [editingCell, setEditingCell] = useState(null); + const [contextMenu, setContextMenu] = useState(null); + + // Polite live-region message for actions/state changes that are otherwise only visual (copy, filter + // results). Clear-then-set on the next frame so repeating the same message re-announces it. + const [announcement, setAnnouncement] = useState(''); + const announce = useCallback((message: string) => { + setAnnouncement(''); + requestAnimationFrame(() => setAnnouncement(message)); + }, []); + // Column reorder (drag-and-drop). Track which column is in flight so headers can render the dragged + // state and the scroller can edge-auto-scroll while a drag is active. + const [draggingColumnId, setDraggingColumnId] = useState(null); + + // Mirrors for the blur handler — focus moving into the grid's own portaled UI (context menu / + // popover editor) must not clear the active cell/selection, since those UIs act on it. + const editingCellRef = useRef(editingCell); + editingCellRef.current = editingCell; + const contextMenuRef = useRef(contextMenu); + contextMenuRef.current = contextMenu; + const shouldRetainFocusOnBlur = useCallback((relatedTarget: Node | null): boolean => { + if (editingCellRef.current || contextMenuRef.current) { + return true; + } + // A popover/modal opened from a cell (via Space/Enter or click) moves focus into its portaled panel; + // keep the active cell so closing the overlay returns to a live grid coordinate. + return relatedTarget instanceof HTMLElement && !!relatedTarget.closest('.jgrid-editor, .slds-popover, .slds-modal'); + }, []); + + const isColumnEditable = useCallback( + (cell: ActiveCell): boolean => { + const column = table.getColumn(cell.columnId); + const meta = column?.columnDef.meta?.jetstream; + const editable = meta?.editable; + if (!editable || !meta?.editor) { + return false; + } + if (typeof editable === 'function') { + const row = table.getRowModel().rows.find((candidate) => candidate.id === cell.rowId); + return !!row && editable(row.original); + } + return editable === true; + }, + [table], + ); + + const startEdit = useCallback( + (cell: ActiveCell): boolean => { + if (!onRowsChange || !isColumnEditable(cell)) { + return false; + } + setEditingCell(cell); + return true; + }, + [onRowsChange, isColumnEditable], + ); + + const handleStartEdit = useCallback((rowId: string, columnId: string) => void startEdit({ rowId, columnId }), [startEdit]); + + const focusCellEl = useCallback((cell: ActiveCell) => { + const cellEl = gridRef.current?.querySelector( + `[data-row-id="${CSS.escape(cell.rowId)}"][data-col-id="${CSS.escape(cell.columnId)}"]`, + ); + cellEl?.focus(); + }, []); + + const handleEditorClose = useCallback( + (_commit?: boolean, focusCell?: boolean) => { + const cell = editingCell; + setEditingCell(null); + if (focusCell && cell) { + // Defer so the editor unmounts before we move focus back to the cell. + setTimeout(() => focusCellEl(cell)); + } + }, + [editingCell, focusCellEl], + ); + + // Commit path shared by the popover editor and in-cell renderers (e.g. a checkbox calling + // `commitEdit(row)`). Consumers receive only DATA rows — synthetic group header rows are excluded + // and collapsed groups' leaves are included, with `indexes` relative to that list. This matches the + // legacy react-data-grid contract (group rows would appear as duplicates of each group's first leaf + // and corrupt consumer state reconciliation). + const handleCommitRow = useCallback( + (updatedRow: TRow, rowId: string, column: ColumnWithFilter) => { + if (!onRowsChange) { + return; + } + const leafRows = getSortedFilteredLeafRows(table); + const rowIndex = leafRows.findIndex((modelRow) => modelRow.id === rowId); + if (rowIndex < 0) { + return; + } + const displayRows = leafRows.map((modelRow, index) => (index === rowIndex ? updatedRow : modelRow.original)); + onRowsChange(displayRows, { indexes: [rowIndex], column }); + }, + [onRowsChange, table], + ); + + // In-cell commit from renderers — they only know their coordinates, so resolve the column by id. + const handleCellCommit = useCallback( + (updatedRow: TRow, rowId: string, columnId: string) => { + const column = table.getColumn(columnId)?.columnDef.meta?.jetstream?.column as ColumnWithFilter | undefined; + if (column) { + handleCommitRow(updatedRow, rowId, column); + } + }, + [handleCommitRow, table], + ); + + const keyboardNav = useGridKeyboardNavigation({ + table, + getRootElement: () => gridRef.current, + onRequestEdit: startEdit, + shouldRetainFocusOnBlur, + summaryRowCount: summaryRows?.length ?? 0, + onAnnounce: announce, + }); + + // Announce the matching row count after the filter set changes (the filtered model is pre-grouping, so + // this counts data rows and isn't perturbed by expanding/collapsing groups or sorting). Skips the + // initial render so the grid doesn't announce on mount. + const filteredRowCount = table.getFilteredRowModel().rows.length; + const previousFilteredRowCountRef = useRef(null); + useEffect(() => { + if (previousFilteredRowCountRef.current !== null && previousFilteredRowCountRef.current !== filteredRowCount) { + announce(`${filteredRowCount} ${filteredRowCount === 1 ? 'row' : 'rows'}`); + } + previousFilteredRowCountRef.current = filteredRowCount; + }, [filteredRowCount, announce]); + + const leafColumns = table.getVisibleLeafColumns(); + const columnSizing = table.getState().columnSizing; + + // Recompute the grid template whenever sizing changes. With `columnResizeMode: 'onEnd'` widths only + // change when the drag is released (columnSizing updates), so this intentionally does NOT depend on + // columnSizingInfo — depending on it would recompute on every mousemove of a resize drag. + const gridTemplateColumns = useMemo( + () => getGridTemplateColumns(leafColumns), + // eslint-disable-next-line react-hooks/exhaustive-deps + [leafColumns, columnSizing], + ); + const totalWidth = table.getTotalSize(); + + // Single shared horizontal virtualizer so the header, body, group, and summary rows window the exact + // same set of columns and therefore stay perfectly aligned. The vertical scroll element also owns + // horizontal scroll (the `.jgrid` is wider than the viewport), so we measure against it. + const columnVirtualizer = useVirtualizer({ + horizontal: true, + count: leafColumns.length, + getScrollElement: () => scrollRef.current, + estimateSize: (index) => leafColumns[index].getSize(), + overscan: 3, + }); + + // Re-measure when column sizes/order change so the windowed tracks reflect the latest widths. + useEffect(() => { + columnVirtualizer.measure(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [columnSizing, leafColumns.length]); + + const virtualColumns = columnVirtualizer.getVirtualItems(); + + // Frozen (sticky-left) columns must render at EVERY horizontal scroll position. The virtualizer windows + // them out the moment you scroll past their tracks, which unmounts them and makes the pinned columns + // vanish mid-scroll. Union the always-on frozen indexes with the windowed indexes — positioning is pure + // CSS grid (gridColumnStart + sticky offset), so the rendered set only needs to *include* them. + const frozenColumnIndexes = useMemo(() => { + const indexes: number[] = []; + leafColumns.forEach((column, index) => { + if (column.columnDef.meta?.jetstream?.frozen) { + indexes.push(index); + } + }); + return indexes; + }, [leafColumns]); + const visibleColumnIndexes = useMemo(() => { + const indexes = new Set(frozenColumnIndexes); + for (const virtualColumn of virtualColumns) { + indexes.add(virtualColumn.index); + } + return Array.from(indexes).sort((a, b) => a - b); + }, [virtualColumns, frozenColumnIndexes]); + + // Scroll the active column into view (mirrors the active-row logic in GridBody) so keyboard + // navigation to off-screen columns brings them into the window before focus resolves. + useEffect(() => { + if (!keyboardNav.activeCell || keyboardNav.getLastInteractionSource() === 'select-all') { + return; + } + const index = leafColumns.findIndex((column) => column.id === keyboardNav.activeCell!.columnId); + if (index >= 0) { + columnVirtualizer.scrollToIndex(index, { align: 'auto' }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [keyboardNav.activeCell?.columnId]); + + const runtime: GridRuntime = useMemo( + () => ({ table, gridId, getRowKey, columns: orderedColumns }), + [table, gridId, getRowKey, orderedColumns], + ); + + const rowModelRows = table.getRowModel().rows; + const rowCount = rowModelRows.length; + const gridStyle: CSSProperties = { inlineSize: totalWidth, minInlineSize: '100%' }; + + // Display-index lookups (rebuilt only when the row model / column order changes) so the selection + // rectangle bounds resolve in O(1) per render instead of O(rows). + const rowIndexById = useMemo(() => { + const map = new Map(); + rowModelRows.forEach((row, index) => map.set(row.id, index)); + return map; + }, [rowModelRows]); + const colIndexById = useMemo(() => { + const map = new Map(); + leafColumns.forEach((column, index) => map.set(column.id, index)); + return map; + }, [leafColumns]); + + const { activeCell, anchorCell } = keyboardNav; + const selectionRange = useMemo(() => { + if (!activeCell || !anchorCell) { + return null; + } + const anchorRow = rowIndexById.get(anchorCell.rowId); + const activeRow = rowIndexById.get(activeCell.rowId); + const anchorCol = colIndexById.get(anchorCell.columnId); + const activeCol = colIndexById.get(activeCell.columnId); + if (anchorRow == null || activeRow == null || anchorCol == null || activeCol == null) { + return null; + } + const minRow = Math.min(anchorRow, activeRow); + const maxRow = Math.max(anchorRow, activeRow); + const minCol = Math.min(anchorCol, activeCol); + const maxCol = Math.max(anchorCol, activeCol); + // A collapsed (single-cell) selection is just the active cell — no range highlight. + if (minRow === maxRow && minCol === maxCol) { + return null; + } + return { minRow, maxRow, minCol, maxCol }; + }, [activeCell, anchorCell, rowIndexById, colIndexById]); + + // Resolve the right-clicked cell to the consumer-facing action data (leaf rows only; group rows are + // excluded). Returns null for a group header / column with no data column. + const buildCellActionData = useCallback( + (rowId: string, columnId: string): ContextMenuActionData | null => { + const leafRows = getSortedFilteredLeafRows(table); + const rowIndex = leafRows.findIndex((modelRow) => modelRow.id === rowId); + const column = table.getColumn(columnId)?.columnDef.meta?.jetstream?.column; + if (rowIndex < 0 || !column) { + return null; + } + return { + row: leafRows[rowIndex].original, + rows: leafRows.map((modelRow) => modelRow.original), + rowIdx: rowIndex, + column, + columns: orderedColumns, + }; + }, + [table, orderedColumns], + ); + + // A static list, or a per-cell builder evaluated against the right-clicked cell (e.g. group-aware + // "Copy column (Apex Classes)"). The builder returning `[]` suppresses the menu for that cell. + const resolveCellMenuItems = useCallback( + (data: ContextMenuActionData): ContextMenuItem[] => + typeof contextMenuItems === 'function' ? contextMenuItems(data) : Array.isArray(contextMenuItems) ? contextMenuItems : [], + [contextMenuItems], + ); + + const handleCellContextMenu = useCallback( + (event: React.MouseEvent, rowId: string, columnId: string) => { + if (event.ctrlKey || event.metaKey) { + return; + } + const actionData = contextMenuAction ? buildCellActionData(rowId, columnId) : null; + const items = actionData ? resolveCellMenuItems(actionData) : []; + // Ctrl/Meta lets the native browser menu through; so does an empty menu with no active selection. + if (!items.length && !selectionRange) { + return; + } + event.preventDefault(); + const element = event.currentTarget as HTMLElement; + // Re-open on the next tick so an already-open menu closes first (matches legacy behavior). + setContextMenu(null); + setTimeout(() => setContextMenu({ area: 'cell', rowId, columnId, element, items, actionData })); + }, + [contextMenuAction, buildCellActionData, resolveCellMenuItems, selectionRange], + ); + + // Right-clicking a column HEADER offers the column/table-scoped copy actions — only for a static item + // list (per-cell builders are cell-scoped). Non-data columns (select/action) keep the native menu. + const headerContextMenuItems = useMemo( + () => + Array.isArray(contextMenuItems) && contextMenuAction + ? contextMenuItems.filter((item) => COLUMN_SCOPED_CONTEXT_ACTIONS.has(item.value)) + : [], + [contextMenuItems, contextMenuAction], + ); + const handleHeaderContextMenu = useCallback( + (event: React.MouseEvent, columnId: string) => { + if (event.ctrlKey || event.metaKey || !headerContextMenuItems.length || NON_DATA_COLUMN_KEYS.has(columnId)) { + return; + } + event.preventDefault(); + const element = event.currentTarget as HTMLElement; + // Column/table copy operates over the whole column — anchor the action data on the first leaf row. + const actionData = buildCellActionData(getSortedFilteredLeafRows(table)[0]?.id ?? '', columnId); + setContextMenu(null); + setTimeout(() => setContextMenu({ area: 'header', columnId, element, items: headerContextMenuItems, actionData })); + }, + [headerContextMenuItems, table, buildCellActionData], + ); + + // Closing the menu can strand DOM focus on (the menu auto-focuses its items and unmounts on + // selection/Escape). Re-focus the origin cell so keyboard navigation continues — but never steal + // focus from a click target (outside-click dismissal already moved focus where the user wanted it). + const closeContextMenu = useCallback(() => { + const menu = contextMenuRef.current; + setContextMenu(null); + requestAnimationFrame(() => { + const focused = document.activeElement; + if ((!focused || focused === document.body) && menu?.area === 'cell' && menu.rowId) { + focusCellEl({ rowId: menu.rowId, columnId: menu.columnId }); + } + }); + }, [focusCellEl]); + + // ── Column reorder (drag-and-drop) ────────────────────────────────────────────────────────────── + const handleColumnReorder = useCallback( + (sourceColumnId: string, targetColumnId: string, side: 'left' | 'right') => { + table.setColumnOrder((order) => reorderColumnOrder(order, sourceColumnId, targetColumnId, side)); + }, + [table], + ); + + // Edge auto-scroll: because columns are horizontally virtualized, the drop target may be off-screen. + // While a column drag is active and the cursor nears the scroller's left/right edge, nudge the + // horizontal scroll so more columns (and drop targets) render. The rAF loop runs only during a drag. + const autoScrollFrameRef = useRef(null); + const autoScrollStepRef = useRef(0); + + const stopAutoScroll = useCallback(() => { + if (autoScrollFrameRef.current !== null) { + cancelAnimationFrame(autoScrollFrameRef.current); + autoScrollFrameRef.current = null; + } + autoScrollStepRef.current = 0; + }, []); + + const runAutoScroll = useCallback(() => { + const scroller = scrollRef.current; + if (!scroller || autoScrollStepRef.current === 0) { + autoScrollFrameRef.current = null; + return; + } + scroller.scrollLeft += autoScrollStepRef.current; + autoScrollFrameRef.current = requestAnimationFrame(runAutoScroll); + }, []); + + const handleColumnDragOverScroller = useCallback( + (event: React.DragEvent) => { + if (!draggingColumnId) { + return; + } + const EDGE_SIZE = 60; + const SCROLL_STEP = 12; + const rect = event.currentTarget.getBoundingClientRect(); + const distanceFromLeft = event.clientX - rect.left; + const distanceFromRight = rect.right - event.clientX; + let step = 0; + if (distanceFromLeft < EDGE_SIZE) { + step = -SCROLL_STEP; + } else if (distanceFromRight < EDGE_SIZE) { + step = SCROLL_STEP; + } + autoScrollStepRef.current = step; + if (step !== 0 && autoScrollFrameRef.current === null) { + autoScrollFrameRef.current = requestAnimationFrame(runAutoScroll); + } else if (step === 0) { + stopAutoScroll(); + } + }, + [draggingColumnId, runAutoScroll, stopAutoScroll], + ); + + const handleColumnDragEnd = useCallback(() => { + setDraggingColumnId(null); + stopAutoScroll(); + }, [stopAutoScroll]); + + // Stop any in-flight auto-scroll frame when the grid unmounts mid-drag. + useEffect(() => stopAutoScroll, [stopAutoScroll]); + + return ( + +
+
+
+ + +
+
+ + {/* Screen-reader announcement of the current navigation/actionable mode. */} + + {keyboardNav.mode === 'actionable' ? 'Actionable mode' : 'Navigation mode'} + + + {/* Screen-reader feedback for actions/state changes that are otherwise only visual (copy, filter results). */} + + {announcement} + + + {editingCell && ( + gridRef.current} + onCommitRow={handleCommitRow} + onClose={handleEditorClose} + /> + )} + + {contextMenu && + (() => { + // Cell menus prepend the rectangular-selection copy actions; items were resolved at open time + // (a per-cell builder may have produced them). Header menus use their column-scoped items. + const menuItems: ContextMenuItem[] = + contextMenu.area === 'header' + ? contextMenu.items + : [ + ...(selectionRange + ? [ + { label: 'Copy selected cells', value: COPY_RANGE_ACTION } as ContextMenuItem, + { + label: 'Copy selected cells with header', + value: COPY_RANGE_WITH_HEADER_ACTION, + trailingDivider: true, + } as ContextMenuItem, + ] + : []), + ...contextMenu.items, + ]; + if (!menuItems.length) { + return null; + } + return ( + { + if (item.value === COPY_RANGE_ACTION) { + keyboardNav.copySelection(); + } else if (item.value === COPY_RANGE_WITH_HEADER_ACTION) { + keyboardNav.copySelection(true); + } else if (contextMenuAction && contextMenu.actionData) { + // Action data (leaf rows only; group rows excluded) was captured when the menu opened. + contextMenuAction(item, contextMenu.actionData); + } + closeContextMenu(); + }} + /> + ); + })()} + {children} +
+
+ ); +} diff --git a/libs/ui/src/lib/data-table/grid/components/GridGroupRow.tsx b/libs/ui/src/lib/data-table/grid/components/GridGroupRow.tsx new file mode 100644 index 000000000..230d1505c --- /dev/null +++ b/libs/ui/src/lib/data-table/grid/components/GridGroupRow.tsx @@ -0,0 +1,160 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Column, Row } from '@tanstack/react-table'; +import { CSSProperties } from 'react'; +import Icon from '../../../widgets/Icon'; +import { DataTableGroupCellProps } from '../grid-types'; +import { ActiveCell } from './GridRow'; +import { getFrozenCellStyle } from './grid-layout'; + +export interface GridGroupRowProps { + row: Row; + columns: Column[]; + gridTemplateColumns: string; + /** Visible leaf-column indexes to render (windowed + always-on frozen). */ + visibleColumnIndexes: number[]; + ariaRowIndex: number; + rowIndex: number; + virtualStart: number; + /** Pinned row height (px) from the virtualizer (see GridRow). */ + height: number; + activeCell?: ActiveCell | null; + onCellMouseDown?: (rowId: string, columnId: string, shiftKey: boolean, button?: number) => void; +} + +// `row.getLeafRows()` walks + maps the entire group on every call; cache per TanStack row instance +// (instances are recreated whenever the row model recomputes, so the cache can never go stale). +const childRowsCache = new WeakMap(); + +function getChildRows(row: Row): TRow[] { + let cached = childRowsCache.get(row); + if (!cached) { + cached = row.getLeafRows().map((leaf) => leaf.original); + childRowsCache.set(row, cached); + } + return cached as TRow[]; +} + +/** + * A group header row. Unlike react-data-grid's tree (which limited what group cells could render), + * this is just a `role=row` of cells we render ourselves: each column may supply `renderGroupCell` + * (honoring `colSpan`), or — when no column does — we fall back to a single full-width header with a + * chevron, the grouping value, and the child count. This is the flexibility the rewrite was for. + */ +export function GridGroupRow({ + row, + columns, + gridTemplateColumns, + visibleColumnIndexes, + ariaRowIndex, + rowIndex, + virtualStart, + height, + activeCell, + onCellMouseDown, +}: GridGroupRowProps) { + const isExpanded = row.getIsExpanded(); + const firstColumnId = columns[0]?.id; + const isActive = !!activeCell && activeCell.rowId === row.id; + const groupValue = row.groupingValue; + const childRows = getChildRows(row); + const toggleGroup = () => row.toggleExpanded(); + const chevron = ( + + ); + + const rowStyle: CSSProperties = { transform: `translateY(${virtualStart}px)`, blockSize: height, gridTemplateColumns }; + const anyGroupCell = columns.some((column) => column.columnDef.meta?.jetstream?.renderGroupCell); + + const baseRowProps = { + role: 'row' as const, + 'aria-rowindex': ariaRowIndex, + 'aria-level': (row.depth ?? 0) + 1, + 'aria-expanded': isExpanded, + 'data-row-id': row.id, + 'data-index': rowIndex, + className: 'jgrid-group-row', + }; + + // Fallback: single full-width group header. + if (!anyGroupCell) { + return ( +
+
firstColumnId && onCellMouseDown?.(row.id, firstColumnId, event.shiftKey, event.button)} + > + +
+
+ ); + } + + // Per-column group cells, honoring colSpan. We walk every column to keep the running `startCol` + // (grid track) accumulation correct, but only render the cells that intersect the visible window. + const representativeRow = childRows[0]; + const visibleIndexSet = new Set(visibleColumnIndexes); + const cells: React.ReactNode[] = []; + let startCol = 1; + let index = 0; + while (index < columns.length) { + const column = columns[index]; + const meta = column.columnDef.meta?.jetstream; + // Group rows resolve span via the GROUP discriminant so a column can span the header (e.g. the + // grouping column owning the toggle + label) without widening that column on data rows. Clamp to the + // remaining tracks so an over-large span can't overrun the row. + const requestedSpan = meta?.colSpan?.({ type: 'GROUP', row: representativeRow }) ?? 1; + const span = Math.max(1, Math.min(requestedSpan, columns.length - index)); + // A spanning cell is visible when any of the tracks it covers is in the visible window. + let cellIsVisible = false; + for (let track = index; track < index + span; track++) { + if (visibleIndexSet.has(track)) { + cellIsVisible = true; + break; + } + } + if (cellIsVisible) { + const groupCellProps: DataTableGroupCellProps | null = meta?.column + ? { groupKey: groupValue, childRows, isExpanded, toggleGroup, column: meta.column, tanstackRow: row } + : null; + cells.push( +
onCellMouseDown?.(row.id, column.id, event.shiftKey, event.button)} + style={{ gridColumn: `${startCol} / span ${span}`, ...getFrozenCellStyle(columns, index) }} + > + {meta?.renderGroupCell && groupCellProps ? meta.renderGroupCell(groupCellProps) : null} +
, + ); + } + startCol += span; + index += span; + } + + return ( +
+ {cells} +
+ ); +} diff --git a/libs/ui/src/lib/data-table/grid/components/GridHeader.tsx b/libs/ui/src/lib/data-table/grid/components/GridHeader.tsx new file mode 100644 index 000000000..f76639279 --- /dev/null +++ b/libs/ui/src/lib/data-table/grid/components/GridHeader.tsx @@ -0,0 +1,122 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Table } from '@tanstack/react-table'; +import { CSSProperties, ReactNode } from 'react'; +import { getSummaryRowId, HEADER_ROW_ID } from '../grid-constants'; +import { DataTableHeaderProps } from '../grid-types'; +import { ActiveCell } from './GridRow'; +import { GridSummaryRow } from './GridSummaryRow'; +import { HeaderCell } from './HeaderCell'; + +export interface GridHeaderProps { + table: Table; + gridTemplateColumns: string; + /** Visible leaf-column indexes to render (windowed + always-on frozen), keeps header aligned with the body. */ + visibleColumnIndexes: number[]; + renderFilter?: (props: DataTableHeaderProps) => ReactNode; + /** Pinned summary rows rendered below the header (sticky). */ + summaryRows?: TSummaryRow[]; + /** Fixed height (px) per summary row; content-sized when omitted. */ + summaryRowHeight?: number; + onHeaderContextMenu?: (event: React.MouseEvent, columnId: string) => void; + /** Active cell — used to drive roving tabindex when the header row is keyboard-focused. */ + activeCell?: ActiveCell | null; + /** Mouse down on a header cell — makes it the keyboard-active cell. */ + onHeaderCellMouseDown?: (columnId: string) => void; + /** Mouse down on a summary cell — makes it the keyboard-active cell. */ + onSummaryCellMouseDown?: (rowId: string, columnId: string) => void; + /** Column id currently being dragged (column reorder), or null. */ + draggingColumnId?: string | null; + /** Drag of a header started. */ + onColumnDragStart?: (columnId: string) => void; + /** Drag ended (drop or cancel). */ + onColumnDragEnd?: () => void; + /** A column was dropped onto another header — owner applies the new column order. */ + onColumnReorder?: (sourceColumnId: string, targetColumnId: string, side: 'left' | 'right') => void; +} + +export function GridHeader({ + table, + gridTemplateColumns, + visibleColumnIndexes, + renderFilter, + summaryRows, + summaryRowHeight, + onHeaderContextMenu, + activeCell, + onHeaderCellMouseDown, + onSummaryCellMouseDown, + draggingColumnId, + onColumnDragStart, + onColumnDragEnd, + onColumnReorder, +}: GridHeaderProps) { + const leafColumns = table.getVisibleLeafColumns(); + const style: CSSProperties = { gridTemplateColumns }; + const visibleIndexSet = new Set(visibleColumnIndexes); + const activeHeaderColumnId = activeCell?.rowId === HEADER_ROW_ID ? activeCell.columnId : null; + + return ( +
+ {table.getHeaderGroups().map((headerGroup) => { + // A column may declare a HEADER colSpan (column-group header, e.g. a profile name spanning its + // Read/Edit sub-columns). Walk ALL columns (not just the visible window) so span ownership is + // stable — a spanning header must still render when its own track is scrolled out of the window + // but a column it covers is visible. Only headers intersecting the window are emitted. + const headerCells: ReactNode[] = []; + let columnIndex = 0; + while (columnIndex < headerGroup.headers.length) { + const header = headerGroup.headers[columnIndex]; + const colSpanFn = header.column.columnDef.meta?.jetstream?.colSpan; + const span = Math.max(1, Math.min(colSpanFn?.({ type: 'HEADER' }) ?? 1, leafColumns.length - columnIndex)); + let intersectsWindow = false; + for (let track = columnIndex; track < columnIndex + span; track++) { + if (visibleIndexSet.has(track)) { + intersectsWindow = true; + break; + } + } + if (intersectsWindow) { + headerCells.push( + , + ); + } + columnIndex += span; + } + return ( +
+ {headerCells} +
+ ); + })} + {summaryRows?.map((summaryRow, index) => ( + + ))} +
+ ); +} diff --git a/libs/ui/src/lib/data-table/grid/components/GridRow.tsx b/libs/ui/src/lib/data-table/grid/components/GridRow.tsx new file mode 100644 index 000000000..2892acc3e --- /dev/null +++ b/libs/ui/src/lib/data-table/grid/components/GridRow.tsx @@ -0,0 +1,151 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Column, Row } from '@tanstack/react-table'; +import classNames from 'classnames'; +import { CSSProperties, memo } from 'react'; +import { RowWithKey } from '../grid-types'; +import { GridCell } from './GridCell'; + +export interface ActiveCell { + rowId: string; + columnId: string; +} + +export interface GridRowProps { + row: Row; + columns: Column[]; + gridTemplateColumns: string; + /** Visible leaf-column indexes to render (windowed + always-on frozen). */ + visibleColumnIndexes: number[]; + /** 1-based ARIA row index (header occupies row 1). */ + ariaRowIndex: number; + /** Index within the filtered/sorted row model (used by renderers + keyboard). */ + rowIndex: number; + /** Virtualizer translateY offset (px). */ + virtualStart: number; + /** Pinned row height (px) from the virtualizer. Fixing it keeps vertical layout independent of which + * columns are windowed, so horizontal scrolling never resizes/reflows rows. */ + height: number; + activeCell?: ActiveCell | null; + /** Explicit selection flag so the memo'd row re-renders when selection flips (row refs are stable). */ + isSelected: boolean; + /** Explicit expanded flag — same reason as `isSelected`: TanStack reuses the Row instance across + * expand/collapse, so without this prop the memo'd row (and its chevron) never re-renders on toggle. */ + isExpanded: boolean; + /** True for the last data row — lets its corner cells round to match the table's bottom corners. */ + isLastRow: boolean; + /** Inclusive column-index range selected on this row (cell range), or null. */ + selectionColRange?: { start: number; end: number } | null; + rowClass?: (row: TRow) => string | undefined; + onCellMouseDown?: (rowId: string, columnId: string, shiftKey: boolean, button?: number) => void; + onCellMouseEnter?: (rowId: string, columnId: string) => void; + onCellContextMenu?: (event: React.MouseEvent, rowId: string, columnId: string) => void; + onStartEdit?: (rowId: string, columnId: string) => void; + onCommitRow?: (updatedRow: TRow, rowId: string, columnId: string) => void; +} + +function GridRowComponent({ + row, + columns, + gridTemplateColumns, + visibleColumnIndexes, + ariaRowIndex, + rowIndex, + virtualStart, + height, + activeCell, + isSelected, + isExpanded, + isLastRow, + selectionColRange, + rowClass, + onCellMouseDown, + onCellMouseEnter, + onCellContextMenu, + onStartEdit, + onCommitRow, +}: GridRowProps) { + const original = row.original as TRow & Partial; + const consumerRowClass = rowClass?.(row.original); + const cells = row.getVisibleCells(); + + const style: CSSProperties = { + gridTemplateColumns, + blockSize: height, + transform: `translateY(${virtualStart}px)`, + }; + + // ROW colSpan support (e.g. deploy's "No metadata found" message spanning several tracks). Resolve + // span ownership across ALL columns so it stays stable as the horizontal window moves, then render + // only the cells that intersect the window. Tables without colSpan take the cheap map below. + const hasColSpan = columns.some((column) => column.columnDef.meta?.jetstream?.colSpan); + let renderedCells: React.ReactNode[]; + if (hasColSpan) { + const visibleIndexSet = new Set(visibleColumnIndexes); + renderedCells = []; + let columnIndex = 0; + while (columnIndex < cells.length) { + const cell = cells[columnIndex]; + const colSpanFn = cell.column.columnDef.meta?.jetstream?.colSpan; + const span = Math.max(1, Math.min(colSpanFn?.({ type: 'ROW', row: row.original }) ?? 1, cells.length - columnIndex)); + let intersectsWindow = false; + for (let track = columnIndex; track < columnIndex + span; track++) { + if (visibleIndexSet.has(track)) { + intersectsWindow = true; + break; + } + } + if (intersectsWindow) { + renderedCells.push(renderCell(cell, columnIndex, span)); + } + columnIndex += span; + } + } else { + renderedCells = visibleColumnIndexes.map((columnIndex) => { + const cell = cells[columnIndex]; + return cell ? renderCell(cell, columnIndex, 1) : null; + }); + } + + function renderCell(cell: (typeof cells)[number], columnIndex: number, colSpan: number) { + return ( + = selectionColRange.start && columnIndex <= selectionColRange.end} + onCellMouseDown={onCellMouseDown} + onCellMouseEnter={onCellMouseEnter} + onCellContextMenu={onCellContextMenu} + onStartEdit={onStartEdit} + onCommitRow={onCommitRow} + /> + ); + } + + return ( +
0 ? row.depth + 1 : undefined} + aria-expanded={row.getCanExpand() ? isExpanded : undefined} + aria-selected={row.getCanSelect() ? isSelected : undefined} + data-row-id={row.id} + data-index={rowIndex} + className={classNames('jgrid-row', { 'jgrid-row-selected': isSelected, 'jgrid-row-last': isLastRow }, consumerRowClass, { + 'save-error': !!(original as Partial)?._saveError, + })} + style={style} + > + {renderedCells} +
+ ); +} + +export const GridRow = memo(GridRowComponent) as typeof GridRowComponent; diff --git a/libs/ui/src/lib/data-table/grid/components/GridSummaryRow.tsx b/libs/ui/src/lib/data-table/grid/components/GridSummaryRow.tsx new file mode 100644 index 000000000..8cdc83241 --- /dev/null +++ b/libs/ui/src/lib/data-table/grid/components/GridSummaryRow.tsx @@ -0,0 +1,71 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Column } from '@tanstack/react-table'; +import classNames from 'classnames'; +import { CSSProperties } from 'react'; +import { ActiveCell } from './GridRow'; +import { getFrozenCellStyle } from './grid-layout'; + +export interface GridSummaryRowProps { + summaryRow: TSummaryRow; + columns: Column[]; + gridTemplateColumns: string; + /** Visible leaf-column indexes to render (windowed + always-on frozen). */ + visibleColumnIndexes: number[]; + ariaRowIndex: number; + /** Fixed row height (px); content-sized when omitted. */ + height?: number; + /** Sentinel row id for the keyboard-navigation model (see `getSummaryRowId`). */ + rowId: string; + /** Active cell — drives this row's roving tabindex so arrows can land on a summary cell. */ + activeCell?: ActiveCell | null; + /** Mouse down on a summary cell — makes it the keyboard-active cell so arrow nav continues from here. */ + onSummaryCellMouseDown?: (rowId: string, columnId: string) => void; +} + +/** + * A pinned summary row rendered inside the sticky header block. Each column may supply + * `renderSummaryCell` (e.g. select-all / reset column actions, aggregates). Full content freedom — + * the legacy grid faked these through react-data-grid's constrained summary mechanism. + */ +export function GridSummaryRow({ + summaryRow, + columns, + gridTemplateColumns, + visibleColumnIndexes, + ariaRowIndex, + height, + rowId, + activeCell, + onSummaryCellMouseDown, +}: GridSummaryRowProps) { + const style: CSSProperties = { gridTemplateColumns, ...(height ? { blockSize: height } : {}) }; + const activeColumnId = activeCell?.rowId === rowId ? activeCell.columnId : null; + return ( +
+ {visibleColumnIndexes.map((columnIndex) => { + const column = columns[columnIndex]; + if (!column) { + return null; + } + const meta = column.columnDef.meta?.jetstream; + const summaryCellClass = meta?.column?.summaryCellClass; + const dynamicClass = typeof summaryCellClass === 'function' ? summaryCellClass(summaryRow as any) : summaryCellClass; + const isActive = activeColumnId === column.id; + return ( +
onSummaryCellMouseDown?.(rowId, column.id)} + > + {meta?.renderSummaryCell && meta.column ? meta.renderSummaryCell({ row: summaryRow, column: meta.column as any }) : null} +
+ ); + })} +
+ ); +} diff --git a/libs/ui/src/lib/data-table/grid/components/HeaderCell.tsx b/libs/ui/src/lib/data-table/grid/components/HeaderCell.tsx new file mode 100644 index 000000000..d65817d42 --- /dev/null +++ b/libs/ui/src/lib/data-table/grid/components/HeaderCell.tsx @@ -0,0 +1,244 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { IconName } from '@jetstream/icon-factory'; +import { Header } from '@tanstack/react-table'; +import classNames from 'classnames'; +import { CSSProperties, ReactNode, useId, useState } from 'react'; +import Checkbox from '../../../form/checkbox/Checkbox'; +import Icon from '../../../widgets/Icon'; +import { HeaderFilterButton } from '../filters/HeaderFilters'; +import { DEFAULT_MIN_COLUMN_WIDTH, HEADER_ROW_ID } from '../grid-constants'; +import { DataTableHeaderProps, SortDirection } from '../grid-types'; +import { getFrozenCellStyle } from './grid-layout'; + +export interface HeaderCellProps { + header: Header; + colIndex: number; + ariaColIndex: number; + allColumns: Header['column'][]; + /** Number of leaf columns this header spans (column-group header). Defaults to 1. */ + colSpan?: number; + /** Slot for the header filter popover trigger (wired in phase 3). */ + renderFilter?: (props: DataTableHeaderProps) => ReactNode; + /** Right-click on the header — offers the column-scoped copy actions when the table has a context menu. */ + onHeaderContextMenu?: (event: React.MouseEvent, columnId: string) => void; + /** True when this header cell is the keyboard-active cell (header row navigation). Drives roving tabindex. */ + isActive?: boolean; + /** Mouse down on a header cell — makes it the keyboard-active cell so arrow nav continues from here. */ + onHeaderCellMouseDown?: (columnId: string) => void; + /** Column id currently being dragged (column reorder), or null. Drives the source's dimmed style. */ + draggingColumnId?: string | null; + /** Drag of this header started — owner tracks the dragged column id. */ + onColumnDragStart?: (columnId: string) => void; + /** Drag ended (drop or cancel) — owner clears the dragged column id. */ + onColumnDragEnd?: () => void; + /** A column was dropped onto this header — owner applies the new column order. */ + onColumnReorder?: (sourceColumnId: string, targetColumnId: string, side: 'left' | 'right') => void; +} + +function toAriaSort(sorted: false | 'asc' | 'desc'): 'ascending' | 'descending' | 'none' { + if (sorted === 'asc') { + return 'ascending'; + } + if (sorted === 'desc') { + return 'descending'; + } + return 'none'; +} + +export function HeaderCell({ + header, + colIndex, + ariaColIndex, + allColumns, + colSpan = 1, + renderFilter, + onHeaderContextMenu, + isActive = false, + onHeaderCellMouseDown, + draggingColumnId, + onColumnDragStart, + onColumnDragEnd, + onColumnReorder, +}: HeaderCellProps) { + const selectAllId = useId(); + const meta = header.column.columnDef.meta?.jetstream; + const column = meta?.column; + const sorted = header.column.getIsSorted(); + const canSort = header.column.getCanSort(); + const canResize = header.column.getCanResize(); + const table = header.getContext().table; + const isResizing = header.column.getIsResizing(); + + // Column reorder (drag-and-drop). Only data columns that opted in and are not pinned can be moved or + // can receive a drop. The reorder pipeline is wired through the owner (GridContainer → table.setColumnOrder). + const canReorder = !!column?.draggable && meta?.cellKind === 'data' && !meta?.frozen && !!onColumnReorder; + const isDraggingThis = !!draggingColumnId && draggingColumnId === header.column.id; + const [dropSide, setDropSide] = useState<'left' | 'right' | null>(null); + + const handleDragStart = (event: React.DragEvent) => { + event.dataTransfer.setData('text/plain', header.column.id); + event.dataTransfer.effectAllowed = 'move'; + onColumnDragStart?.(header.column.id); + }; + + const handleDragOver = (event: React.DragEvent) => { + // Only react while a column drag is in progress and this column isn't the source. + if (!draggingColumnId || draggingColumnId === header.column.id) { + return; + } + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + const rect = event.currentTarget.getBoundingClientRect(); + setDropSide(event.clientX < rect.left + rect.width / 2 ? 'left' : 'right'); + }; + + const handleDragLeave = (event: React.DragEvent) => { + // Ignore leave events that merely cross into a child element of this cell. + if (event.currentTarget.contains(event.relatedTarget as Node | null)) { + return; + } + setDropSide(null); + }; + + const handleDrop = (event: React.DragEvent) => { + event.preventDefault(); + const sourceColumnId = event.dataTransfer.getData('text/plain'); + const side = dropSide ?? 'left'; + setDropSide(null); + if (sourceColumnId && sourceColumnId !== header.column.id) { + onColumnReorder?.(sourceColumnId, header.column.id, side); + } + }; + + // With `columnResizeMode: 'onEnd'` the column keeps its width during the drag; only this handle + // (and its full-height ::after guide line) follows the cursor, clamped to the column's min/max so + // the guide never suggests a width the release won't honor. Widths apply once on mouse release. + let resizeIndicatorStyle: CSSProperties | undefined; + if (isResizing) { + const { deltaOffset, startSize } = table.getState().columnSizingInfo; + const currentSize = startSize ?? header.column.getSize(); + const minDelta = (header.column.columnDef.minSize ?? DEFAULT_MIN_COLUMN_WIDTH) - currentSize; + const maxDelta = (header.column.columnDef.maxSize ?? Number.MAX_SAFE_INTEGER) - currentSize; + resizeIndicatorStyle = { transform: `translateX(${Math.min(Math.max(deltaOffset ?? 0, minDelta), maxDelta)}px)` }; + } + // 1-based sort priority, shown only when more than one column participates in the sort. + const sortPriority = sorted && table.getState().sorting.length > 1 ? header.column.getSortIndex() + 1 : undefined; + + const headerProps: DataTableHeaderProps | undefined = column + ? { + column, + header, + sortDirection: sorted ? ((sorted === 'asc' ? 'ASC' : 'DESC') as SortDirection) : undefined, + priority: sortPriority, + } + : undefined; + + const style: CSSProperties = { + gridColumn: colSpan > 1 ? `${colIndex + 1} / span ${colSpan}` : `${colIndex + 1}`, + ...getFrozenCellStyle(allColumns, colIndex), + }; + + let label: ReactNode = column && column.renderHeaderCell && headerProps ? column.renderHeaderCell(headerProps) : (column?.name ?? null); + + // Built-in select-all checkbox for the row-selection column (parity with react-data-grid's + // SelectColumn header) unless the author supplied their own header content. + if (meta?.cellKind === 'select' && !column?.renderHeaderCell && table.options.enableRowSelection) { + label = ( + event.stopPropagation()} + > + table.toggleAllRowsSelected(checked)} + /> + + ); + } + + return ( +
= allColumns.length, + })} + style={style} + tabIndex={isActive ? 0 : -1} + draggable={canReorder} + onDragStart={canReorder ? handleDragStart : undefined} + onDragEnd={canReorder ? () => onColumnDragEnd?.() : undefined} + onDragOver={canReorder ? handleDragOver : undefined} + onDragLeave={canReorder ? handleDragLeave : undefined} + onDrop={canReorder ? handleDrop : undefined} + onMouseDown={() => onHeaderCellMouseDown?.(header.column.id)} + onContextMenu={(event) => onHeaderContextMenu?.(event, header.column.id)} + > + {canSort ? ( + + ) : ( + {label} + )} + + {column?.filters?.length ? ( + event.stopPropagation()}> + {renderFilter && headerProps ? ( + renderFilter(headerProps) + ) : ( + + )} + + ) : null} + + {canResize && ( + event.stopPropagation()} + onDoubleClick={() => header.column.resetSize()} + /> + )} +
+ ); +} diff --git a/libs/ui/src/lib/data-table/grid/components/grid-layout.ts b/libs/ui/src/lib/data-table/grid/components/grid-layout.ts new file mode 100644 index 000000000..5199ce20c --- /dev/null +++ b/libs/ui/src/lib/data-table/grid/components/grid-layout.ts @@ -0,0 +1,36 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { Column } from '@tanstack/react-table'; +import { CSSProperties } from 'react'; + +/** True when a column is pinned/frozen to the left (sticky). */ +export function isFrozenColumn(column: Column): boolean { + return !!column.columnDef.meta?.jetstream?.frozen; +} + +/** CSS grid template built from the visible leaf columns' current sizes. */ +export function getGridTemplateColumns(columns: Column[]): string { + return columns.map((column) => `${column.getSize()}px`).join(' '); +} + +/** Cumulative left offset (px) for a frozen column, summing the widths of preceding frozen columns. */ +export function getFrozenLeftOffset(columns: Column[], targetIndex: number): number { + let offset = 0; + for (let index = 0; index < targetIndex; index++) { + if (isFrozenColumn(columns[index])) { + offset += columns[index].getSize(); + } + } + return offset; +} + +/** Sticky-left positioning style for a frozen cell (returns empty object for non-frozen columns). */ +export function getFrozenCellStyle(columns: Column[], index: number): CSSProperties { + if (!isFrozenColumn(columns[index])) { + return {}; + } + return { + position: 'sticky', + left: getFrozenLeftOffset(columns, index), + zIndex: 1, + }; +} diff --git a/libs/ui/src/lib/data-table/grid/core/useJetstreamTable.ts b/libs/ui/src/lib/data-table/grid/core/useJetstreamTable.ts new file mode 100644 index 000000000..a7dab74ed --- /dev/null +++ b/libs/ui/src/lib/data-table/grid/core/useJetstreamTable.ts @@ -0,0 +1,441 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { useNonInitialEffect } from '@jetstream/shared/ui-utils'; +import { + ColumnFiltersState, + ColumnOrderState, + ColumnSizingState, + ExpandedState, + GroupingState, + RowSelectionState, + SortingState, + Table, + getCoreRowModel, + getExpandedRowModel, + getFilteredRowModel, + getGroupedRowModel, + getSortedRowModel, + useReactTable, +} from '@tanstack/react-table'; +import isNil from 'lodash/isNil'; +import uniqueId from 'lodash/uniqueId'; +import { useCallback, useEffect, useImperativeHandle, useMemo, useReducer, useRef, useState } from 'react'; +import { buildColumnDefs } from '../buildColumnDefs'; +import { ColumnFilterValue, makeGlobalFilterFn } from '../filterFns'; +import { EMPTY_FIELD, NON_DATA_COLUMN_KEYS } from '../grid-constants'; +import { computeFilterSetValues, hasFilterApplied, isFilterActive, resetFilter } from '../grid-filters'; +import { getSearchTextByRow, getSortedFilteredLeafRows } from '../grid-row-utils'; +import { + ColumnWithFilter, + DataTableFilter, + DataTableRef, + DefaultColumnOptions, + FILTER_SET_TYPES, + RowWithKey, + SortColumn, +} from '../grid-types'; + +export interface UseJetstreamTableOptions { + data: TRow[]; + columns: ColumnWithFilter[]; + getRowKey: (row: TRow) => string; + ref?: React.Ref>; + initialSortColumns?: SortColumn[]; + quickFilterText?: string | null; + includeQuickFilter?: boolean; + /** Rows that always pass filters (e.g. group/category headers). MUST be referentially stable: TanStack + * caches the filtered-row-model on the filter *value* (not on this fn's identity), so an unstable + * predicate whose captured state changed under an active quick filter wouldn't re-evaluate bypassed + * rows until the search text changes. Pass a module-level or memoized function. */ + rowAlwaysVisible?: (row: TRow) => boolean; + ignoreRowInSetFilter?: (row: TRow) => boolean; + defaultColumnOptions?: DefaultColumnOptions; + enableRowSelection?: boolean; + enableMultiSort?: boolean; + rowSelection?: RowSelectionState; + onRowSelectionChange?: (selection: RowSelectionState) => void; + onReorderColumns?: (columns: string[], columnOrder: number[]) => void; + onSortedAndFilteredRowsChange?: (rows: readonly TRow[]) => void; + /** Fired when the sort changes (legacy DataTree persists sort to storage via this). */ + onSortColumnsChange?: (sortColumns: SortColumn[]) => void; + /** Column keys to group rows by (creates group header rows). */ + grouping?: string[]; + /** For genuine parent→child hierarchy: return a row's child rows. */ + getSubRows?: (row: TRow, index: number) => TRow[] | undefined; + /** Controlled expanded state (TanStack). When omitted, expansion is internal. */ + expanded?: ExpandedState; + onExpandedChange?: (expanded: ExpandedState) => void; + /** Initial expanded state when uncontrolled — `true` expands all groups/rows. */ + defaultExpanded?: ExpandedState | boolean; +} + +export interface UseJetstreamTableResult { + table: Table; + gridId: string; + columns: ColumnWithFilter[]; + /** Ordered, visible author-facing columns kept in sync with the TanStack column order. */ + orderedColumns: ColumnWithFilter[]; + filters: Record; + filterSetValues: Record; + updateFilter: (columnKey: string, filter: DataTableFilter) => void; + /** Mirror of the legacy ADD_MODIFIED_VALUE_TO_SET_FILTER — keep edited values visible under a SET filter. */ + registerEditedValues: (columnKey: string, values: unknown[]) => void; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Filter reducer (ported from the legacy useDataTable reducer to preserve behavior) +// ───────────────────────────────────────────────────────────────────────────── + +interface FilterState { + hasFilters: boolean; + columnMap: Map>; + filters: Record; + filterSetValues: Record; +} + +type FilterAction = + | { type: 'INIT'; payload: { columns: ColumnWithFilter[]; data: any[]; ignoreRowInSetFilter?: (row: any) => boolean } } + | { type: 'ADD_EDITED_VALUES'; payload: { columnKey: string; values: unknown[] } } + | { type: 'UPDATE_FILTER'; payload: { columnKey: string; filter: DataTableFilter } }; + +function filterReducer(state: FilterState, action: FilterAction): FilterState { + switch (action.type) { + case 'INIT': { + const { columns, data, ignoreRowInSetFilter } = action.payload; + const columnMap = new Map(columns.map((column) => [column.key, column])); + + // Retain existing filter values when only the data changed (column count is the cheap proxy used by the legacy grid). + const hasFilters = state.hasFilters && columnMap.size === state.columnMap.size; + const filters: Record = hasFilters + ? structuredClone(state.filters) + : columns.reduce((acc: Record, column) => { + if (Array.isArray(column.filters)) { + acc[column.key] = column.filters.map((filterType) => resetFilter(filterType, [])); + } + return acc; + }, {}); + + const distinctSetValues = computeFilterSetValues(columns, data, ignoreRowInSetFilter); + + // Default SET filters to "all selected" unless the user previously customized the selection. + Object.keys(filters).forEach((columnKey) => { + const setFilter = filters[columnKey]?.find(({ type }) => FILTER_SET_TYPES.has(type)); + const allValues = distinctSetValues[columnKey]; + if (!setFilter || !allValues) { + return; + } + const prevAll = state.filterSetValues?.[columnKey]; + if (!hasFilters || !setFilter.value || !prevAll || setFilter.value.length === prevAll.length) { + (setFilter as { value: string[] }).value = allValues; + } + }); + + return { hasFilters, columnMap, filters, filterSetValues: distinctSetValues }; + } + case 'ADD_EDITED_VALUES': { + const { columnKey, values } = action.payload; + if (!state.filters[columnKey]) { + return state; + } + const newValues = values.map((value) => (value === '' || isNil(value) ? EMPTY_FIELD : String(value))); + const filterSetValues = { + ...state.filterSetValues, + [columnKey]: Array.from(new Set([...(state.filterSetValues[columnKey] || []), ...newValues])), + }; + const columnFilter = state.filters[columnKey].map((item) => + item.type !== 'SET' ? item : { ...item, value: Array.from(new Set(item.value.concat(newValues))) }, + ); + return { ...state, filterSetValues, filters: { ...state.filters, [columnKey]: columnFilter } }; + } + case 'UPDATE_FILTER': { + const { columnKey, filter } = action.payload; + const filters = { + ...state.filters, + [columnKey]: state.filters[columnKey].map((currentFilter) => (currentFilter.type === filter.type ? filter : currentFilter)), + }; + return { ...state, hasFilters: hasFilterApplied(filters, state.filterSetValues), filters }; + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Hook +// ───────────────────────────────────────────────────────────────────────────── + +export function useJetstreamTable(options: UseJetstreamTableOptions): UseJetstreamTableResult { + const { + data, + columns, + getRowKey, + ref, + initialSortColumns, + quickFilterText, + includeQuickFilter, + rowAlwaysVisible, + ignoreRowInSetFilter, + defaultColumnOptions, + enableRowSelection, + enableMultiSort = false, + rowSelection: controlledRowSelection, + onRowSelectionChange, + onReorderColumns, + onSortedAndFilteredRowsChange, + onSortColumnsChange, + grouping, + getSubRows, + expanded: controlledExpanded, + onExpandedChange, + defaultExpanded, + } = options; + + const [gridId] = useState(() => uniqueId('grid-')); + + // Hold getRowKey in a ref so an unstable (inline) callback identity does not force expensive work + // (the search index rebuild) on every render. The wrapper is referentially stable. + const getRowKeyRef = useRef(getRowKey); + getRowKeyRef.current = getRowKey; + const stableGetRowKey = useCallback((row: TRow) => getRowKeyRef.current(row), []); + + const columnByKey = useMemo(() => new Map(columns.map((column) => [column.key, column])), [columns]); + + // Our global-search index aggregates EVERY column's text per row, so the quick filter only needs a + // single carrier column to evaluate it. Designating one (the first data column) makes global + // filtering O(rows) instead of O(rows × columns) — the difference between smooth and janky at 50k+. + const globalFilterColumnId = useMemo(() => columns.find((column) => !NON_DATA_COLUMN_KEYS.has(column.key))?.key, [columns]); + // Key the column-def memo on defaultColumnOptions CONTENT (not identity) so an inline `{...}` prop + // does not rebuild the entire TanStack column model on every parent render. + const defaultColumnOptionsKey = JSON.stringify(defaultColumnOptions ?? null); + const columnDefs = useMemo( + () => buildColumnDefs(columns, defaultColumnOptions), + // eslint-disable-next-line react-hooks/exhaustive-deps + [columns, defaultColumnOptionsKey], + ); + + const [sorting, setSorting] = useState(() => + (initialSortColumns || []).map((sortColumn) => ({ id: sortColumn.columnKey, desc: sortColumn.direction === 'DESC' })), + ); + const [columnOrder, setColumnOrder] = useState(() => columns.map((column) => column.key)); + const [columnSizing, setColumnSizing] = useState({}); + const [internalRowSelection, setInternalRowSelection] = useState({}); + const rowSelection = controlledRowSelection ?? internalRowSelection; + + const groupingState: GroupingState = useMemo(() => grouping ?? [], [grouping]); + const isHierarchical = !!getSubRows || groupingState.length > 0; + const [internalExpanded, setInternalExpanded] = useState(() => + defaultExpanded === true ? true : typeof defaultExpanded === 'object' ? defaultExpanded : {}, + ); + const expanded = controlledExpanded ?? internalExpanded; + + const [{ filters, filterSetValues }, dispatch] = useReducer(filterReducer, { + hasFilters: false, + columnMap: new Map(), + filters: {}, + filterSetValues: {}, + }); + + // Initialize / refresh filters + distinct set values when columns or data change. + useEffect(() => { + dispatch({ type: 'INIT', payload: { columns, data, ignoreRowInSetFilter } }); + }, [columns, data, ignoreRowInSetFilter]); + + // Reset column order when the column set changes. + useNonInitialEffect(() => { + setColumnOrder(columns.map((column) => column.key)); + }, [columns]); + + const updateFilter = useCallback((columnKey: string, filter: DataTableFilter) => { + dispatch({ type: 'UPDATE_FILTER', payload: { columnKey, filter } }); + }, []); + + const registerEditedValues = useCallback((columnKey: string, values: unknown[]) => { + dispatch({ type: 'ADD_EDITED_VALUES', payload: { columnKey, values } }); + }, []); + + // Defer the (O(rows × columns)) global-search index until the quick filter is actually used. Building + // it during initial render froze the main thread for seconds on large query results even though the + // user had typed nothing. This flag latches true on first non-empty quick filter and never flips back. + const [quickFilterEngaged, setQuickFilterEngaged] = useState(false); + useEffect(() => { + if (!quickFilterEngaged && includeQuickFilter && !!quickFilterText) { + setQuickFilterEngaged(true); + } + }, [quickFilterEngaged, includeQuickFilter, quickFilterText]); + + // Precompute the global-search index, but only once the quick filter has been engaged. Depends on + // data/columns/quickFilterEngaged ONLY — NOT on quickFilterText or getRowKey identity — so typing in + // the search box never rebuilds the index. While it stays empty (filter never used), globalFilter is + // also empty so makeGlobalFilterFn short-circuits to "show all rows". + const rowFilterText = useMemo(() => { + if (!includeQuickFilter || !quickFilterEngaged || !Array.isArray(data) || !data.length || !columns.length) { + return {} as Record; + } + return getSearchTextByRow(data, columns, stableGetRowKey, getSubRows); + }, [includeQuickFilter, quickFilterEngaged, data, columns, stableGetRowKey, getSubRows]); + + // Derive TanStack columnFilters from our filter map (only columns whose filters actually narrow). + const columnFilters = useMemo(() => { + const result: ColumnFiltersState = []; + Object.entries(filters).forEach(([columnKey, columnFilters]) => { + const totalSetValues = filterSetValues[columnKey]?.length ?? 0; + const active = columnFilters.some((filter) => isFilterActive(filter, totalSetValues)); + const column = columnByKey.get(columnKey); + if (!active || !column) { + return; + } + const value: ColumnFilterValue = { filters: columnFilters, column, totalSetValues, rowAlwaysVisible }; + result.push({ id: columnKey, value }); + }); + return result; + }, [filters, filterSetValues, columnByKey, rowAlwaysVisible]); + + // The value embeds the (lazily built) search index purely so its identity busts TanStack's + // filtered-model cache when the index materializes — see GlobalFilterValue. + const globalFilter = useMemo( + () => (includeQuickFilter && quickFilterText ? { text: quickFilterText, rowFilterText } : undefined), + [includeQuickFilter, quickFilterText, rowFilterText], + ); + const globalFilterFn = useMemo( + () => makeGlobalFilterFn(rowFilterText, stableGetRowKey, rowAlwaysVisible), + [rowFilterText, stableGetRowKey, rowAlwaysVisible], + ); + + const handleSortingChange = useCallback( + (updater: SortingState | ((old: SortingState) => SortingState)) => { + // Resolve outside the setState updater — updaters must be pure (StrictMode double-invokes them, + // which would fire onSortColumnsChange twice). + const next = typeof updater === 'function' ? updater(sorting) : updater; + if (onSortColumnsChange) { + onSortColumnsChange(next.map((sort) => ({ columnKey: sort.id, direction: sort.desc ? 'DESC' : 'ASC' }))); + } + setSorting(next); + }, + [sorting, onSortColumnsChange], + ); + + const handleExpandedChange = useCallback( + (updater: ExpandedState | ((old: ExpandedState) => ExpandedState)) => { + const next = typeof updater === 'function' ? updater(expanded) : updater; + if (onExpandedChange) { + onExpandedChange(next); + } + if (controlledExpanded === undefined) { + setInternalExpanded(next); + } + }, + [expanded, onExpandedChange, controlledExpanded], + ); + + const handleRowSelectionChange = useCallback( + (updater: RowSelectionState | ((old: RowSelectionState) => RowSelectionState)) => { + const next = typeof updater === 'function' ? updater(rowSelection) : updater; + if (onRowSelectionChange) { + onRowSelectionChange(next); + } + if (controlledRowSelection === undefined) { + setInternalRowSelection(next); + } + }, + [rowSelection, onRowSelectionChange, controlledRowSelection], + ); + + const table = useReactTable({ + data, + columns: columnDefs, + state: { sorting, columnFilters, globalFilter, columnOrder, columnSizing, rowSelection, grouping: groupingState, expanded }, + getRowId: (row) => stableGetRowKey(row), + enableRowSelection: enableRowSelection ?? false, + enableMultiSort, + enableSortingRemoval: true, + // Don't collapse expanded groups/rows when data changes (a bulk edit or applying a column filter + // rebuilds the row model — without this TanStack resets expansion and the children disappear). + autoResetExpanded: false, + // Defer applying widths until mouse release (SLDS behavior): during the drag only the divider + // guide line moves, then the grid re-lays-out once. Live-resizing re-renders every visible row + // per mousemove, which lags on wide/tall tables. + columnResizeMode: 'onEnd', + // Keep declared column order (don't auto-move grouped columns to the front); we render group rows ourselves. + groupedColumnMode: false, + getSubRows, + // For getSubRows trees, filter from leaves up so a match on a child (e.g. a flow version) keeps its + // ancestor rows visible, instead of being dropped because the parent didn't match. + filterFromLeafRows: !!getSubRows, + globalFilterFn, + // Evaluate the quick filter on a single carrier column (see globalFilterColumnId). Falls back to + // TanStack's default (string columns) if there is no data column. + getColumnCanGlobalFilter: globalFilterColumnId ? (column) => column.id === globalFilterColumnId : undefined, + onSortingChange: handleSortingChange, + onColumnOrderChange: setColumnOrder, + onColumnSizingChange: setColumnSizing, + onRowSelectionChange: handleRowSelectionChange, + onExpandedChange: handleExpandedChange, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + // Only wire grouping/expansion row models when actually used, to keep flat tables lean. + ...(groupingState.length > 0 ? { getGroupedRowModel: getGroupedRowModel() } : {}), + ...(isHierarchical ? { getExpandedRowModel: getExpandedRowModel() } : {}), + meta: { gridId }, + }); + + const orderedColumns = useMemo(() => { + const ordered = columnOrder.map((key) => columnByKey.get(key)).filter((column): column is ColumnWithFilter => !!column); + // Include any columns missing from columnOrder (defensive) preserving their declared order. + if (ordered.length !== columns.length) { + const seen = new Set(ordered.map((column) => column.key)); + columns.forEach((column) => { + if (!seen.has(column.key)) { + ordered.push(column); + } + }); + } + return ordered; + }, [columnOrder, columnByKey, columns]); + + // Notify subscribers of the filtered + sorted DATA rows (collapse-independent; group rows excluded). + const sortedFlatRows = table.getSortedRowModel().flatRows; + useEffect(() => { + if (onSortedAndFilteredRowsChange) { + onSortedAndFilteredRowsChange(getSortedFilteredLeafRows(table).map((row) => row.original)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sortedFlatRows, onSortedAndFilteredRowsChange]); + + // Fire the reorder callback with data-column keys + the PERMUTATION of their original indexes — + // consumers (e.g. QueryResults) use the index permutation to reorder parsedQuery.fields, so an + // identity array would silently break "reorder columns updates the SOQL query". + useNonInitialEffect(() => { + if (!onReorderColumns) { + return; + } + const originalIndexByKey = new Map( + columns.filter((column) => !NON_DATA_COLUMN_KEYS.has(column.key)).map((column, index) => [column.key, index]), + ); + const dataColumns = orderedColumns.filter((column) => !NON_DATA_COLUMN_KEYS.has(column.key)).map((column) => column.key); + onReorderColumns( + dataColumns, + dataColumns.map((key) => originalIndexByKey.get(key) ?? -1).filter((index) => index >= 0), + ); + }, [columnOrder]); + + useImperativeHandle( + ref, + (): DataTableRef => ({ + hasSortApplied: () => sorting.length > 0, + getFilteredAndSortedRows: () => getSortedFilteredLeafRows(table).map((row) => row.original), + hasReorderedColumns: () => columnOrder.some((key, index) => columns[index]?.key !== key), + getCurrentColumns: () => orderedColumns.filter((column) => !NON_DATA_COLUMN_KEYS.has(column.key)), + getCurrentColumnNames: () => orderedColumns.filter((column) => !NON_DATA_COLUMN_KEYS.has(column.key)).map((column) => column.key), + }), + [table, sorting.length, columnOrder, columns, orderedColumns], + ); + + return { + table, + gridId, + columns, + orderedColumns, + filters, + filterSetValues, + updateFilter, + registerEditedValues, + }; +} diff --git a/libs/ui/src/lib/data-table/grid/data-table-grid.css b/libs/ui/src/lib/data-table/grid/data-table-grid.css new file mode 100644 index 000000000..e3e8dbc2d --- /dev/null +++ b/libs/ui/src/lib/data-table/grid/data-table-grid.css @@ -0,0 +1,361 @@ +/** + * Jetstream Data Table (v2) — structural styles for the div-based ARIA grid. + * Uses SLDS design tokens so it tracks light/dark/system themes. Structural class names use a + * `jgrid-` prefix (SLDS `slds-table*` classes target real elements and don't apply here). + */ + +.jgrid-root { + block-size: 100%; + display: flex; + flex-direction: column; + min-block-size: 0; + --jgrid-border-color: var(--slds-g-color-border-1, #e5e5e5); + --jgrid-surface: var(--slds-g-color-surface-container-1, #fff); + --jgrid-surface-alt: var(--slds-g-color-surface-container-2, #f3f3f3); + --jgrid-on-surface: var(--slds-g-color-on-surface-1, #181818); + --jgrid-row-hover: var(--slds-g-color-surface-container-2, #f3f3f3); + --jgrid-row-selected: var(--slds-g-color-palette-blue-95, #eef4ff); + color: var(--jgrid-on-surface); + font-size: 0.8125rem; +} + +.jgrid-scroller { + flex: 1 1 auto; + overflow: auto; + position: relative; + background-color: var(--jgrid-surface); + border: 1px solid var(--jgrid-border-color); + border-radius: var(--slds-g-radius-border-2, 0); + contain: strict; +} + +.jgrid { + position: relative; +} + +/* Header */ +.jgrid-header { + position: sticky; + inset-block-start: 0; + z-index: 2; + background-color: var(--jgrid-surface-alt); +} + +.jgrid-header-row { + display: grid; + border-block-end: 1px solid var(--jgrid-border-color); +} + +/* NOT overflow-clipped (matching SLDS table headers) so the resize divider's full-height guide line + and the drag indicator can escape the cell. Label truncation is handled by the inner elements + (`.slds-truncate` / the sort button's own overflow). */ +.jgrid-header-cell { + position: relative; + display: flex; + align-items: center; + gap: 0.25rem; + min-block-size: 2rem; + padding-inline: 0.5rem; + border-inline-end: 1px solid var(--jgrid-border-color); + font-weight: 700; + white-space: nowrap; + background-color: var(--jgrid-surface-alt); +} + +.jgrid-header-label { + display: inline-block; + max-inline-size: 100%; +} + +.jgrid-header-sort-button { + display: inline-flex; + align-items: center; + gap: 0.25rem; + overflow: hidden; + cursor: pointer; + flex: 1 1 auto; + min-inline-size: 0; +} + +/* Pin the filter trigger to the right edge of the header cell regardless of whether the column is + sortable (a sortable column's button already grows; this also right-aligns it for non-sortable ones). */ +.jgrid-header-filter-slot { + margin-inline-start: auto; + display: inline-flex; + align-items: center; +} + +.jgrid-header-sortable:hover { + background-color: var(--jgrid-row-hover); +} + +.jgrid-sort-priority { + font-size: 0.6875rem; + font-weight: 400; +} + +/* Column resize divider, mirroring SLDS `.slds-resizable__divider`: a 1px line at the column edge + that widens and turns the accent color on hover/drag (::before), plus a 1px guide line spanning the + full table height (::after, clipped by the scroller) so the column boundary is visible across all + rows while hovering or dragging. With resize mode 'onEnd', the whole handle is translated to follow + the cursor during a drag (see HeaderCell) and the columns re-layout once on release. */ +.jgrid-header-resize-handle { + position: absolute; + inset-block: 0; + inset-inline-end: 0; + inline-size: 8px; + cursor: col-resize; + touch-action: none; + user-select: none; + z-index: 1; +} +/* Lift above frozen header cells (z-index: 1) while the translated handle drags across them. */ +.jgrid-header-resize-handle.jgrid-resizing { + z-index: 2; +} +.jgrid-header-resize-handle::before { + content: ''; + position: absolute; + inset-block: 0; + inset-inline-end: 0; + inline-size: 1px; + background-color: var(--slds-s-table-resize-color, var(--jgrid-border-color)); +} +.jgrid-header-resize-handle:hover::before, +.jgrid-header-resize-handle.jgrid-resizing::before { + inline-size: var(--slds-g-sizing-2, 0.25rem); + background-color: var(--slds-s-table-resize-color-active, var(--slds-g-color-accent-2, #0250d9)); +} +.jgrid-header-resize-handle::after { + content: ''; + position: absolute; + inset-block-start: 0; + inset-inline-end: 0; + inline-size: 1px; + block-size: 100vh; + background-color: var(--slds-s-table-resize-color-active, var(--slds-g-color-accent-2, #0250d9)); + opacity: 0; +} +.jgrid-header-resize-handle:hover::after, +.jgrid-header-resize-handle.jgrid-resizing::after { + opacity: 1; +} + +/* Column reorder (drag-and-drop). The whole header cell is the drag source/drop target; a grab cursor + advertises it, the source dims while in flight, and a 2px brand-colored insertion line on the leading + or trailing edge (::after, layout-neutral) previews where the dropped column will land. */ +.jgrid-header-draggable { + cursor: grab; +} +.jgrid-header-dragging { + opacity: 0.5; +} +.jgrid-drop-before::after, +.jgrid-drop-after::after { + content: ''; + position: absolute; + inset-block: 0; + inline-size: 2px; + background-color: var(--slds-g-color-brand-base-60, #1b96ff); + z-index: 2; + pointer-events: none; +} +.jgrid-drop-before::after { + inset-inline-start: 0; +} +.jgrid-drop-after::after { + inset-inline-end: 0; +} + +/* Body + rows */ +.jgrid-body { + position: relative; + inline-size: 100%; +} + +.jgrid-row { + position: absolute; + inset-block-start: 0; + inset-inline-start: 0; + inline-size: 100%; + display: grid; + /* Row height is pinned inline from the virtualizer. `border-box` folds the bottom border into that + height so rows tile exactly against the virtualizer's offsets, and the single implicit track is + forced to fill it (minmax(0, 1fr)) so over-tall cell content clips inside the cell rather than + stretching the row. We deliberately DON'T set overflow on the row: an overflow-clipped row would + become the containing block for its sticky frozen cells and break column pinning. */ + grid-auto-rows: minmax(0, 1fr); + box-sizing: border-box; + border-block-end: 1px solid var(--jgrid-border-color); + background-color: var(--jgrid-surface); + will-change: transform; +} + +.jgrid-row:hover { + background-color: var(--jgrid-row-hover); +} + +.jgrid-row-selected, +.jgrid-row-selected:hover { + background-color: var(--jgrid-row-selected); +} + +.jgrid-row.save-error { + background-color: var(--slds-g-color-error-base-90, #ffdede); +} + +/* Cells */ +.jgrid-cell { + display: flex; + align-items: center; + min-inline-size: 0; + padding-inline: 0.5rem; + border-inline-end: 1px solid var(--jgrid-border-color); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + background-color: inherit; + /* Native text selection is disabled so drag-to-select a cell range doesn't select text; + use Ctrl/Cmd+C (single cell or range) to copy instead. */ + user-select: none; +} + +.jgrid-header-cell { + user-select: none; +} + +/* Rectangular cell-range selection highlight */ +.jgrid-cell-range { + background-color: var(--slds-g-color-palette-blue-90, #d8e6fe); +} + +/* + * Frozen (sticky-left) cells must stay OPAQUE so scrolled non-frozen content slides under them. + * Do NOT set `background-color: inherit` here — body cells already inherit the (opaque) row color via + * `.jgrid-cell`, and header cells must keep their own opaque `.jgrid-header-cell` background. Setting + * `inherit` here would override the header background and make frozen headers transparent. + */ +.jgrid-cell-frozen { + box-shadow: 1px 0 0 var(--jgrid-border-color); + z-index: 1; +} + +/* Roving tabindex focus ring (navigation mode) — body cells and the keyboard-navigable header row */ +.jgrid-cell:focus, +.jgrid-cell:focus-visible, +.jgrid-header-cell:focus, +.jgrid-header-cell:focus-visible { + outline: none; + box-shadow: var(--slds-g-shadow-insetinverse-focus-1, inset 0 0 0 2px #0176d3); +} + +/* Round the table's four corner cells to match the scroller's radius. The focus ring is an INSET + box-shadow, so without a matching corner radius it draws a square ring just inside the rounded table + edge. The sticky header owns the top corners; the last data row owns the bottom corners. (Right/last + corners only round once that column is scrolled into view, which is when they actually sit at the edge.) */ +.jgrid-header-cell.jgrid-cell-col-first { + border-start-start-radius: var(--slds-g-radius-border-2, 0); +} +.jgrid-header-cell.jgrid-cell-col-last { + border-start-end-radius: var(--slds-g-radius-border-2, 0); +} +.jgrid-row-last .jgrid-cell.jgrid-cell-col-first { + border-end-start-radius: var(--slds-g-radius-border-2, 0); +} +.jgrid-row-last .jgrid-cell.jgrid-cell-col-last { + border-end-end-radius: var(--slds-g-radius-border-2, 0); +} + +/* Inline-edit dirty + error states (parity with legacy class names) */ +.jgrid-cell.slds-is-edited { + font-weight: 600; + background-color: var(--slds-g-color-warning-base-90, #faffbd); +} +.jgrid-cell.active-item-error { + outline: 2px solid var(--slds-g-color-error-base-50, red); +} +.jgrid-cell.copied { + background-color: var(--slds-g-color-warning-base-90, #faffbd); +} + +.jgrid-cell-select { + inline-size: 100%; +} + +/* Group + summary rows */ +.jgrid-group-row { + position: absolute; + inset-block-start: 0; + inset-inline-start: 0; + inline-size: 100%; + display: grid; + /* Same pinned-height model as `.jgrid-row` — see note there. */ + grid-auto-rows: minmax(0, 1fr); + box-sizing: border-box; + align-items: stretch; + /* Top + bottom borders so the full-width toggle row reads as a distinct band (a single full-width + cell otherwise only shows the row's bottom border). */ + border-block-start: 1px solid var(--jgrid-border-color); + border-block-end: 1px solid var(--jgrid-border-color); + background-color: var(--jgrid-surface-alt); +} + +.jgrid-group-cell { + background-color: var(--jgrid-surface-alt); +} + +.jgrid-group-toggle { + inline-size: 100%; + cursor: pointer; + padding-inline: 0.25rem; +} + +/* Blue, link-like group label so it's obviously clickable (matches the deploy/compare toggle). */ +.jgrid-group-toggle-label { + font-weight: 700; + color: var(--slds-g-color-palette-blue-50, #0176d3); +} +.jgrid-group-toggle:hover .jgrid-group-toggle-label { + text-decoration: underline; +} + +/* Tree (getSubRows) expander affordance — depth indent + chevron/spacer, rendered inside a data cell + via the shared `TreeExpander`. The spacer keeps leaf-row content aligned with expandable siblings. */ +.jgrid-tree-expander { + min-inline-size: 0; +} + +.jgrid-tree-toggle { + flex: 0 0 auto; + margin-inline-end: 0.25rem; +} + +.jgrid-tree-toggle-spacer { + flex: 0 0 auto; + /* Match the chevron button's footprint so labels line up across leaf + expandable rows. */ + inline-size: 1.5rem; + margin-inline-end: 0.25rem; +} + +.jgrid-tree-expander-content { + min-inline-size: 0; +} + +/* Summary rows live inside the sticky header block */ +.jgrid-summary-row { + display: grid; + border-block-start: 1px solid var(--jgrid-border-color); + background-color: var(--jgrid-surface-alt); +} + +.jgrid-summary-cell { + background-color: var(--jgrid-surface-alt); + font-weight: 600; +} + +.jgrid-body-empty { + position: static; +} +.jgrid-empty-cell { + grid-column: 1 / -1; +} diff --git a/libs/ui/src/lib/data-table/grid/editors/CellEditors.tsx b/libs/ui/src/lib/data-table/grid/editors/CellEditors.tsx new file mode 100644 index 000000000..92587bc0a --- /dev/null +++ b/libs/ui/src/lib/data-table/grid/editors/CellEditors.tsx @@ -0,0 +1,200 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { ListItem, SalesforceOrgUi } from '@jetstream/types'; +import { formatISO } from 'date-fns/formatISO'; +import { parseISO } from 'date-fns/parseISO'; +import isString from 'lodash/isString'; +import { useContext, useEffect, useRef, useState } from 'react'; +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 { GridGenericContext } from '../grid-context'; +import { DataTableEditorProps } from '../grid-types'; + +/** + * Inline cell editors, ported to the new `DataTableEditorProps` contract. Each returns just the edit + * control — the popover chrome + positioning is provided by EditorHost (floating-ui anchored to the + * live cell), replacing the legacy `aria-rowindex`/`aria-colindex` DOM-query positioning. + */ + +function autoFocusAndSelect(input: HTMLInputElement | null) { + input?.focus(); + input?.select(); +} + +function withTouched(row: TRow, columnKey: string): Set { + const touched = new Set((row as any)._touchedColumns || []); + touched.add(columnKey); + return touched; +} + +function editLabel(column: { name: unknown; key: string }): string { + return `Edit ${isString(column.name) ? column.name : column.key}`; +} + +export function EditorText({ row, column, onRowChange, onClose }: DataTableEditorProps) { + return ( + + onRowChange({ ...row, [column.key]: event.target.value, _touchedColumns: withTouched(row, column.key) })} + onBlur={() => onClose(true, true)} + /> + + ); +} + +export function EditorBoolean({ row, column, onRowChange, onClose }: DataTableEditorProps) { + const value = ((row as any)[column.key] as boolean) || false; + const id = `edit-${column.key}-checkbox`; + return ( +
+ +
+
+ onRowChange({ ...row, [column.key]: event.target.checked, _touchedColumns: withTouched(row, column.key) })} + onBlur={() => onClose(true, true)} + /> + +
+
+
+ ); +} + +export function EditorDate({ row, column, onRowChange, onClose }: DataTableEditorProps) { + const currentValue = (row as any)[column.key] as string; + const currentDate = currentValue ? parseISO(currentValue) : undefined; + return ( + { + // setTimeout avoids a React flushSync-during-render warning from the date picker. + setTimeout(() => { + onRowChange( + { ...row, [column.key]: value ? formatISO(value, { representation: 'date' }) : null, _touchedColumns: withTouched(row, column.key) }, + true, + ); + }); + }} + /> + ); +} + +const BLANK_LIST_ITEM: ListItem = { id: '_BLANK_', label: '--None--', value: '' }; + +export function editorDropdown({ values: providedValues, isMultiSelect }: { values: ListItem[]; isMultiSelect: boolean }) { + return function EditorDropdown({ row, column, onRowChange, onClose }: DataTableEditorProps) { + const allValues = useRef(new Set([BLANK_LIST_ITEM.value, ...providedValues.map((item) => item.value)])); + const [values, setValues] = useState(() => [BLANK_LIST_ITEM, ...providedValues]); + const selectedItemId = (row as any)[column.key] as string; + + // Ensure an inactive/selected value still shows as an option (runs once). + useEffect(() => { + if (isMultiSelect) { + const selectedItemIds = selectedItemId ? selectedItemId.split(';') : []; + const missingItems = selectedItemIds.filter((itemId) => !allValues.current.has(itemId)); + if (missingItems.length) { + missingItems.forEach((itemId) => allValues.current.add(itemId)); + setValues((prev) => [...prev, ...missingItems.map((itemId) => ({ id: itemId, label: itemId, value: itemId }))]); + } + } else if (selectedItemId && !allValues.current.has(selectedItemId)) { + allValues.current.add(selectedItemId); + setValues((prev) => [...prev, { 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) => + onRowChange( + { + ...row, + [column.key]: (items || []) + .map((item) => item.value) + .filter(Boolean) + .join(';'), + _touchedColumns: withTouched(row, column.key), + }, + false, + ) + } + /> + ); + } + + return ( + + onRowChange({ ...row, [column.key]: item.value === '' ? null : item.value, _touchedColumns: withTouched(row, column.key) }, true) + } + /> + ); + }; +} + +export function editorRecordLookup({ sobjects }: { sobjects: string[] }) { + return function EditorRecordLookup(props: DataTableEditorProps) { + const { row, column, onRowChange } = props; + const currentValue = (row as any)[column.key] as string; + const { org } = useContext(GridGenericContext) as { org: SalesforceOrgUi }; + const [selectedSobject, setSelectedSobject] = useState(sobjects[0]); + + if (!org || !selectedSobject) { + return ; + } + + return ( + onRowChange({ ...row, [column.key]: value || null, _touchedColumns: withTouched(row, column.key) }, false)} + onObjectChange={setSelectedSobject} + /> + ); + }; +} diff --git a/libs/ui/src/lib/data-table/grid/editors/EditorHost.tsx b/libs/ui/src/lib/data-table/grid/editors/EditorHost.tsx new file mode 100644 index 000000000..d730968f2 --- /dev/null +++ b/libs/ui/src/lib/data-table/grid/editors/EditorHost.tsx @@ -0,0 +1,130 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { FloatingPortal, autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/react'; +import type { Table } from '@tanstack/react-table'; +import { useState } from 'react'; +import { OutsideClickHandler } from '../../../utils/OutsideClickHandler'; +import { ActiveCell } from '../components/GridRow'; +import { ColumnWithFilter } from '../grid-types'; + +export interface EditorHostProps { + editingCell: ActiveCell; + table: Table; + /** Returns the grid root so the anchor cell can be located + scoped. */ + getRootElement: () => HTMLElement | null; + /** Apply an edited row and let the consumer persist it. */ + onCommitRow: (updatedRow: TRow, rowId: string, column: ColumnWithFilter) => void; + /** Close the editor; `commit` is advisory, `focusCell` returns focus to the cell. */ + onClose: (commit?: boolean, focusCell?: boolean) => void; +} + +/** + * Renders the active column's `renderEditCell` in a popover anchored to the live cell via floating-ui. + * This replaces the legacy `document.querySelector('[aria-rowindex][aria-colindex]')` positioning, + * eliminating the filtered-index reconciliation that approach required. + * + * Edits accumulate on a DRAFT row (mirroring react-data-grid's internal editor row): editors call + * `onRowChange(row)` per change, but the consumer's `onRowsChange` only fires once when the edit + * commits (Enter/Tab/blur/outside click) — never per keystroke — and Escape discards the draft. + * GridContainer keys this component by cell so the draft resets when editing moves to another cell. + */ +export function EditorHost({ editingCell, table, getRootElement, onCommitRow, onClose }: EditorHostProps) { + const rows = table.getRowModel().rows; + const columns = table.getVisibleLeafColumns(); + const rowIndex = rows.findIndex((row) => row.id === editingCell.rowId); + const colIndex = columns.findIndex((column) => column.id === editingCell.columnId); + const row = rows[rowIndex]; + const column = columns[colIndex]; + const authorColumn = column?.columnDef.meta?.jetstream?.column as ColumnWithFilter | undefined; + + const [draftRow, setDraftRow] = useState(null); + + const cellEl = + getRootElement()?.querySelector( + `[data-row-id="${CSS.escape(editingCell.rowId)}"][data-col-id="${CSS.escape(editingCell.columnId)}"]`, + ) ?? null; + + const { refs, floatingStyles } = useFloating({ + open: true, + placement: 'bottom-start', + strategy: 'fixed', + whileElementsMounted: autoUpdate, + elements: { reference: cellEl ?? undefined }, + // Overlay the editor on top of the cell (negative offset == cell height). + middleware: [offset(({ rects }) => -rects.reference.height), flip(), shift({ padding: 4 })], + }); + + if (!row || !authorColumn?.renderEditCell || !cellEl) { + return null; + } + + const minWidth = Math.max(cellEl.offsetWidth, 200); + + // Name the editor dialog so screen readers announce the column being edited instead of a bare "dialog". + const editorColumnName = typeof authorColumn.name === 'string' ? authorColumn.name : undefined; + + const handleRowChange = (updatedRow: TRow, commit?: boolean) => { + if (commit) { + onCommitRow(updatedRow, editingCell.rowId, authorColumn); + onClose(true, true); + } else { + setDraftRow(updatedRow); + } + }; + + const handleClose = (commit?: boolean, focusCell?: boolean) => { + if (commit && draftRow !== null) { + onCommitRow(draftRow, editingCell.rowId, authorColumn); + } + onClose(commit, focusCell); + }; + + return ( + +
{ + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + handleClose(false, true); + } else if (event.key === 'Tab') { + event.preventDefault(); + event.stopPropagation(); + handleClose(true, true); + } else if (event.key === 'Enter' && !event.defaultPrevented) { + // Comboboxes/pickers own Enter (it selects an option and they commit through onRowChange); + // for plain inputs Enter commits the draft and closes, matching the legacy grid. + const target = event.target as HTMLElement; + if (!target.closest('[role="combobox"], [role="listbox"], .slds-datepicker')) { + event.preventDefault(); + handleClose(true, true); + } + } + }} + > + {/* Clicking anywhere outside the editor dismisses it (focusCell=false so the click's own target + keeps focus — e.g. another cell). A dirty draft commits on dismiss — the outside mousedown + unmounts the editor before its input's blur handler can run, so this is the blur-commit path. + Inline editor dropdowns render inside this subtree, so selecting an option does not count as + an outside click. */} + handleClose(draftRow !== null ? true : (authorColumn.editorOptions?.commitOnOutsideClick ?? false), false)} + > + {authorColumn.renderEditCell({ + row: draftRow ?? row.original, + column: authorColumn, + rowIndex, + colIndex, + onRowChange: handleRowChange, + onClose: handleClose, + })} + +
+
+ ); +} diff --git a/libs/ui/src/lib/data-table/grid/filterFns.ts b/libs/ui/src/lib/data-table/grid/filterFns.ts new file mode 100644 index 000000000..21689f57f --- /dev/null +++ b/libs/ui/src/lib/data-table/grid/filterFns.ts @@ -0,0 +1,77 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { FilterFn, Row } from '@tanstack/react-table'; +import { filterRecord, getFilterValue, isFilterActive } from './grid-filters'; +import { ColumnWithFilter, DataTableFilter } from './grid-types'; + +/** + * Custom TanStack filter functions. The legacy table stored filters as `Record` + * and applied `filters.filter(isFilterActive).every(f => filterRecord(f, getValue(row)))`. We relocate + * that exact predicate composition into a per-column TanStack `filterFn`. + * + * A column's filter value (the entry in TanStack `columnFilters`) carries everything the predicate + * needs: the active filters, the column (so `getValue` can run), and the count of distinct SET values + * (so `isFilterActive` can tell whether a SET filter is actually narrowing). + */ +export interface ColumnFilterValue { + filters: DataTableFilter[]; + column: ColumnWithFilter; + totalSetValues: number; + /** When provided and true for a row, the row bypasses all column filters (legacy `rowAlwaysVisible`). */ + rowAlwaysVisible?: (row: TRow) => boolean; +} + +export const jetstreamColumnFilterFn: FilterFn = (row: Row, _columnId, filterValue: ColumnFilterValue) => { + if (!filterValue || !Array.isArray(filterValue.filters)) { + return true; + } + const { filters, column, totalSetValues, rowAlwaysVisible } = filterValue; + if (rowAlwaysVisible && rowAlwaysVisible(row.original)) { + return true; + } + const activeFilters = filters.filter((filter) => isFilterActive(filter, totalSetValues)); + if (activeFilters.length === 0) { + return true; + } + const value = getFilterValue(column, row.original); + return activeFilters.every((filter) => filterRecord(filter, value)); +}; + +// TanStack requires `resolveFilterValue` / `autoRemove` to be undefined for object filter values; this +// keeps the value object intact between renders. +jetstreamColumnFilterFn.autoRemove = (filterValue: ColumnFilterValue) => !filterValue || filterValue.filters.length === 0; + +/** + * The global-filter STATE value. Carries the search index alongside the text: TanStack's filtered-row + *-model cache keys on the globalFilter VALUE (not the filterFn identity), so when the lazily built + * index materializes after the first filter event, the value's identity change is what forces a + * refilter — with a plain string the table would stay stuck on the "filtered with an empty index" + * (i.e. zero-row) result until the user typed another character. + */ +export interface GlobalFilterValue { + text: string; + rowFilterText: Record; +} + +/** + * Build a global (quick search) filter function bound to a precomputed per-row search index. Returns + * true when the row's concatenated lowercase text contains the needle. Ignores `columnId` (the same + * answer holds for every column of a row), so the first column short-circuits. + */ +export function makeGlobalFilterFn( + rowFilterText: Record, + getRowKey: (row: TRow) => string, + rowAlwaysVisible?: (row: TRow) => boolean, +): FilterFn { + return (row, _columnId, filterValue: GlobalFilterValue | string) => { + const needle = typeof filterValue === 'string' ? filterValue : (filterValue?.text ?? ''); + if (!needle) { + return true; + } + if (rowAlwaysVisible && rowAlwaysVisible(row.original)) { + return true; + } + const key = getRowKey(row.original); + const text = key ? rowFilterText[key] : undefined; + return !!text && text.includes(String(needle).toLowerCase()); + }; +} diff --git a/libs/ui/src/lib/data-table/grid/filters/HeaderFilters.tsx b/libs/ui/src/lib/data-table/grid/filters/HeaderFilters.tsx new file mode 100644 index 000000000..1902cace0 --- /dev/null +++ b/libs/ui/src/lib/data-table/grid/filters/HeaderFilters.tsx @@ -0,0 +1,452 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { css } from '@emotion/react'; +import { useDebounce } from '@jetstream/shared/ui-utils'; +import { multiWordStringFilter } from '@jetstream/shared/utils'; +import { ListItem } 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 { Fragment, memo, useContext, useEffect, useRef, useState } from 'react'; +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 { Popover, PopoverRef } from '../../../popover/Popover'; +import Icon from '../../../widgets/Icon'; +import { GridFilterContext } from '../grid-context'; +import { isFilterActive, resetFilter } from '../grid-filters'; +import { + DataTableBooleanSetFilter, + DataTableDateFilter, + DataTableFilter, + DataTableNumberFilter, + DataTableSetFilter, + DataTableTextFilter, + DataTableTimeFilter, +} from '../grid-types'; + +type Comparator = 'EQUALS' | 'GREATER_THAN' | 'LESS_THAN'; +const COMPARATOR_ITEMS: ListItem[] = [ + { id: 'EQUALS', label: 'Equals', value: 'EQUALS' }, + { id: 'GREATER_THAN', label: 'Greater Than', value: 'GREATER_THAN' }, + { id: 'LESS_THAN', label: 'Less Than', value: 'LESS_THAN' }, +]; + +interface UpdateFilterFn { + (column: string, filter: DataTableFilter): void; +} + +/** + * Header filter popover trigger. Rendered by HeaderCell for any column that declares `filters`. + * Reads the active filters / distinct set values from GridFilterContext and renders the appropriate + * filter UIs (one per declared filter type) inside a popover. + */ +/** + * Filter for a summary/header cell that doesn't need sort — renders a label plus the filter popover. + * Used by columns that span multiple summary columns (e.g. permission manager bulk-action header). + */ +export const SummaryFilterRenderer = memo(({ columnKey, label }: { columnKey: string; label: string }) => { + // `flex: 1` so this fills the (flex) summary cell — otherwise `align-spread` has no room to work and the + // filter trigger sits next to the label instead of pinned to the column's right edge. + return ( +
+
{label}
+ +
+ ); +}); + +export const HeaderFilterButton = memo(({ columnKey, columnName }: { columnKey: string; columnName?: string }) => { + const { filters: allFilters, filterSetValues, updateFilter } = useContext(GridFilterContext); + const filters = allFilters[columnKey]; + 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 ; + case 'DATE': + return ; + case 'TIME': + return ; + case 'BOOLEAN_SET': + case 'SET': + return ( + + ); + default: + return null; + } + } + + function handleReset() { + filters.forEach((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, index) => ( + + {index > 0 &&
} +
{getFilter(filter, index === 0)}
+
+ ))} +
+ } + buttonProps={{ + className: 'slds-button slds-button_icon', + // Icon-only trigger needs an accessible name; the active state is conveyed in text (not color + // alone) so screen-reader users can tell a filter is applied. + 'aria-label': `Filter${columnName ? ` ${columnName}` : ''}${active ? ' (active)' : ''}`, + onClick: (ev) => ev.stopPropagation(), + }} + > + +
+
+ ); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Text +// ───────────────────────────────────────────────────────────────────────────── + +interface HeaderTextFilterProps { + columnKey: string; + filter: DataTableTextFilter; + autoFocus?: boolean; + updateFilter: UpdateFilterFn; +} + +export const HeaderTextFilter = memo(({ columnKey, filter, autoFocus = false, updateFilter }: HeaderTextFilterProps) => { + const [value, setValue] = useState(filter.value); + const debouncedValue = useDebounce(value, 300); + // Hold the latest filter in a ref so the effect only re-fires when the debounced text value changes — + // not whenever the parent rebuilds the filters map and hands us a new `filter` object identity. + const filterRef = useRef(filter); + filterRef.current = filter; + + useEffect(() => { + const currentFilter = filterRef.current; + if (currentFilter.value !== debouncedValue) { + updateFilter(columnKey, { ...currentFilter, value: debouncedValue }); + } + }, [updateFilter, debouncedValue, columnKey]); + + return ( + setValue('')}> + setValue(ev.target.value)} + autoFocus={autoFocus} + /> + + ); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Number (previously unimplemented) +// ───────────────────────────────────────────────────────────────────────────── + +interface HeaderNumberFilterProps { + columnKey: string; + filter: DataTableNumberFilter; + autoFocus?: boolean; + updateFilter: UpdateFilterFn; +} + +export const HeaderNumberFilter = memo(({ columnKey, filter, autoFocus = false, updateFilter }: HeaderNumberFilterProps) => { + const [value, setValue] = useState(filter.value ?? ''); + const debouncedValue = useDebounce(value, 300); + const [selectedComparator, setSelectedComparator] = useState(filter.comparator); + // See HeaderTextFilter — scope the effect to the debounced value, not the `filter` object identity. + const filterRef = useRef(filter); + filterRef.current = filter; + + useEffect(() => { + const nextValue = debouncedValue === '' ? null : debouncedValue; + const currentFilter = filterRef.current; + if (currentFilter.value !== nextValue) { + updateFilter(columnKey, { ...currentFilter, value: nextValue }); + } + }, [updateFilter, debouncedValue, columnKey]); + + function handleComparatorChange(comparator: Comparator) { + setSelectedComparator(comparator); + if (filter.comparator !== comparator) { + updateFilter(columnKey, { ...filter, comparator }); + } + } + + return ( +
+ []) => handleComparatorChange(items[0].value)} + /> + setValue('')} + > + setValue(ev.target.value)} + autoFocus={autoFocus} + /> + +
+ ); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Set / Boolean Set (virtualized searchable checkbox list) +// ───────────────────────────────────────────────────────────────────────────── + +interface HeaderSetFilterProps { + columnKey: string; + filter: DataTableSetFilter | DataTableBooleanSetFilter; + values: string[]; + updateFilter: UpdateFilterFn; +} + +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(() => { + setVisibleItems(searchTerm ? values.filter(multiWordStringFilter(searchTerm)) : values); + }, [searchTerm, values]); + + useEffect(() => { + const everySelected = visibleItems.every((item) => selectedValues.has(item)); + setIndeterminate(!everySelected && visibleItems.some((item) => selectedValues.has(item))); + setAllItemsSelected(everySelected); + }, [selectedValues, visibleItems]); + + const rowVirtualizer = useVirtualizer({ + count: visibleItems.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 20.33, + overscan: 50, + }); + + function handleSelectAll(checked: boolean) { + const newSet = new Set(selectedValues); + visibleItems.forEach((item) => (checked ? newSet.add(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)} + /> +
+ ))} +
+
+ + )} +
+ ); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Date / Time +// ───────────────────────────────────────────────────────────────────────────── + +interface HeaderDateFilterProps { + columnKey: string; + filter: DataTableDateFilter; + updateFilter: UpdateFilterFn; +} + +export const HeaderDateFilter = memo(({ columnKey, filter, updateFilter }: HeaderDateFilterProps) => { + const [value, setValue] = useState(() => (filter.value ? parseISO(filter.value) : null)); + const [selectedComparator, setSelectedComparator] = useState(() => filter.comparator); + + function handleComparatorChange(comparator: Comparator) { + setSelectedComparator(comparator); + if (filter.comparator !== comparator) { + updateFilter(columnKey, { ...filter, comparator }); + } + } + + function handleDateChange(nextValue: Date | null) { + setValue(nextValue); + updateFilter(columnKey, { ...filter, value: nextValue ? formatISO(nextValue) : null }); + } + + return ( +
+ []) => handleComparatorChange(items[0].value)} + /> + +
+ ); +}); + +interface HeaderTimeFilterProps { + columnKey: string; + filter: DataTableTimeFilter; + updateFilter: UpdateFilterFn; +} + +export const HeaderTimeFilter = memo(({ columnKey, filter, updateFilter }: HeaderTimeFilterProps) => { + const [value, setValue] = useState(() => filter.value); + const [selectedComparator, setSelectedComparator] = useState(() => filter.comparator); + + function handleComparatorChange(comparator: Comparator) { + setSelectedComparator(comparator); + if (filter.comparator !== comparator) { + updateFilter(columnKey, { ...filter, comparator }); + } + } + + function handleTimeChange(nextValue: string) { + setValue(nextValue); + updateFilter(columnKey, { ...filter, value: nextValue }); + } + + return ( +
+ []) => handleComparatorChange(items[0].value)} + /> + +
+ ); +}); diff --git a/libs/ui/src/lib/data-table/grid/grid-clipboard.ts b/libs/ui/src/lib/data-table/grid/grid-clipboard.ts new file mode 100644 index 000000000..ddd7eae9a --- /dev/null +++ b/libs/ui/src/lib/data-table/grid/grid-clipboard.ts @@ -0,0 +1,151 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { copyRecordsToClipboard } from '@jetstream/shared/ui-utils'; +import { ContextAction, ContextMenuActionData, RowWithKey } from './grid-types'; + +/** + * Clipboard copy helpers for the table context menu. Ported verbatim from the legacy data-table-utils + * (no react-data-grid dependency). `copyGenericTableDataToClipboard` works with plain row data; + * `copySalesforceRecordTableDataToClipboard` assumes a `_record` indirection on each row. + */ + +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' | 'csv' = '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; + case 'COPY_TABLE_CSV': + recordsToCopy = rows; + format = 'csv'; + break; + default: + break; + } + writeRecords(recordsToCopy, fieldsToCopy, format, includeHeader); +} + +export function copySalesforceRecordTableDataToClipboard( + action: ContextAction, + fields: string[], + { row, rows, column, columns }: ContextMenuActionData, +) { + let includeHeader = true; + let recordsToCopy: unknown[] = []; + const records = rows.map((row) => (row as any)._record); + const fieldsSet = new Set(fields); + // Prefer columns order over fields to account for reordering. + let fieldsToCopy = columns.map((column) => column.key).filter((field) => fieldsSet.has(field)); + let format: 'plain' | 'excel' | 'json' | 'csv' = 'plain'; + + switch (action) { + case 'COPY_CELL': + includeHeader = false; + fieldsToCopy = [column.key]; + recordsToCopy = [(row as any)._record]; + break; + case 'COPY_ROW_EXCEL': + format = 'excel'; + recordsToCopy = [(row as any)._record]; + break; + case 'COPY_ROW_JSON': + recordsToCopy = [(row as any)._record]; + format = 'json'; + break; + case 'COPY_COL': + fieldsToCopy = fieldsToCopy.filter((field) => field === column.key); + recordsToCopy = records.map((row) => ({ [column.key]: row[column.key] })); + format = 'excel'; + break; + case 'COPY_COL_JSON': + fieldsToCopy = fieldsToCopy.filter((field) => field === column.key); + recordsToCopy = records.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 = records.map((row) => ({ [column.key]: row[column.key] })); + format = 'plain'; + break; + case 'COPY_TABLE': + recordsToCopy = records; + break; + case 'COPY_TABLE_JSON': + recordsToCopy = records; + format = 'json'; + break; + case 'COPY_TABLE_CSV': + recordsToCopy = records; + format = 'csv'; + break; + default: + break; + } + writeRecords(recordsToCopy, fieldsToCopy, format, includeHeader); +} + +function writeRecords( + recordsToCopy: unknown[], + fieldsToCopy: string[], + format: 'plain' | 'excel' | 'json' | 'csv', + includeHeader: boolean, +) { + if (!recordsToCopy.length) { + return; + } + if (format === 'json') { + const filteredRecords = recordsToCopy.map((record) => + fieldsToCopy.reduce>((output, field) => { + output[field] = (record as Record)[field]; + return output; + }, {}), + ); + copyRecordsToClipboard(filteredRecords, 'json'); + } else if (format === 'csv') { + copyRecordsToClipboard(recordsToCopy, 'csv', fieldsToCopy, includeHeader); + } else { + copyRecordsToClipboard(recordsToCopy, 'excel', fieldsToCopy, includeHeader); + } +} diff --git a/libs/ui/src/lib/data-table/grid/grid-column-utils.tsx b/libs/ui/src/lib/data-table/grid/grid-column-utils.tsx new file mode 100644 index 000000000..434e29f66 --- /dev/null +++ b/libs/ui/src/lib/data-table/grid/grid-column-utils.tsx @@ -0,0 +1,390 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { pluralizeFromNumber } from '@jetstream/shared/utils'; +import { Field, Maybe, QueryResults, QueryResultsColumn } from '@jetstream/types'; +import { FieldSubquery, getField, getFlattenedFields, isFieldSubquery } from '@jetstreamapp/soql-parser-js'; +import { + dataTableAddressValueFormatter, + dataTableDateFormatter, + dataTableLocationFormatter, + dataTableTimeFormatter, +} from '../data-table-formatters'; +import { EditorBoolean, EditorDate, EditorText, editorDropdown, editorRecordLookup } from './editors/CellEditors'; +import { ACTION_COLUMN_KEY, SELECT_COLUMN_KEY } from './grid-constants'; +import { ColumnType, ColumnWithFilter, FilterType, RowWithKey, SalesforceQueryColumnDefinition } from './grid-types'; +import { + ActionRenderer, + BooleanRenderer, + ComplexDataRenderer, + GenericRenderer, + IdLinkRenderer, + NameLinkRenderer, + SelectColumn, + TextOrIdLinkRenderer, +} from './renderers/CellRenderers'; +import { SubqueryRenderer } from './renderers/SubqueryRenderer'; + +type Mutable = { -readonly [Key in keyof Type]: Type[Key] }; + +/** + * Get columns for a generic table when the data is user-provided and column types are unknown. + */ +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, + }; + if (type) { + updateColumnFromType(column, type); + } + return { ...column, ...columnProps } as ColumnWithFilter; + }); +} + +/** + * Produce table columns from a Salesforce query (+ field metadata). + */ +export function getColumnDefinitions( + results: QueryResults, + isTooling: boolean, + fieldMetadata?: Maybe>, + fieldMetadataSubquery?: Maybe>>, +): SalesforceQueryColumnDefinition { + const includeRecordActions = + !isTooling && results.queryResults.records.length + ? !!(results.queryResults.records[0]?.Id || results.queryResults.records[0]?.attributes.url) + : false; + const output: SalesforceQueryColumnDefinition = { parentColumns: [], subqueryColumns: {} }; + + const subqueryRelationshipNames = new Set( + results.parsedQuery?.fields?.filter(isFieldSubquery).map((f) => f.subquery.relationshipName.toLowerCase()) || [], + ); + + let queryColumnsByPath: Record = {}; + if (results.columns?.columns) { + queryColumnsByPath = results.columns.columns.reduce( + (out, curr) => { + out[curr.columnFullPath.toLowerCase()] = curr; + 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('.'), + } as QueryResultsColumn; + }); + } + return out; + }, + {} as Record, + ); + } + + 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)); + } + + const parentColumns: ColumnWithFilter[] = getFlattenedFields(results.parsedQuery || {}).map((field) => + getQueryResultColumn({ field, queryColumnsByPath, isSubquery: subqueryRelationshipNames.has(field.toLowerCase()), fieldMetadata }), + ); + + 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; + + 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; +} + +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'], + }; + + 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); + 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'); + } + + 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'; +} + +export function setColumnFromType(key: string, fieldType: ColumnType, defaultProps?: Partial>>) { + const column: Partial>> = { ...defaultProps, key }; + updateColumnFromType(column as Mutable>, fieldType); + return column; +} + +export function updateColumnFromType(column: Mutable>, fieldType: ColumnType) { + column.filters = ['TEXT', 'SET']; + switch (fieldType) { + case 'text': + column.renderCell = GenericRenderer; + break; + case 'number': + break; + case 'subquery': + column.filters = ['SET']; + column.renderCell = SubqueryRenderer; + column.getValue = ({ column, row }) => { + const results = (row as any)[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 as any)[column.key]); + column.getValue = ({ column, row }) => dataTableLocationFormatter((row as any)[column.key]); + break; + case 'date': + column.filters = ['DATE', 'SET']; + column.renderCell = ({ column, row }) => dataTableDateFormatter((row as any)[column.key]); + column.getValue = ({ column, row }) => dataTableDateFormatter((row as any)[column.key]); + break; + case 'time': + column.filters = ['TIME', 'SET']; + column.renderCell = ({ column, row }) => dataTableTimeFormatter((row as any)[column.key]); + column.getValue = ({ column, row }) => dataTableTimeFormatter((row as any)[column.key]); + break; + case 'boolean': + column.filters = ['BOOLEAN_SET']; + column.renderCell = BooleanRenderer; + column.width = 100; + break; + case 'address': + column.renderCell = ({ column, row }) => dataTableAddressValueFormatter((row as any)[column.key]); + column.getValue = ({ column, row }) => dataTableAddressValueFormatter((row as any)[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; + } +} + +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 = EditorBoolean; + } else if (type === 'date' || apexType === 'Date' || type === 'datetime' || apexType === 'Datetime') { + column.editable = true; + column.editorOptions = { commitOnOutsideClick: false, displayCellContent: true }; + column.renderEditCell = EditorDate; + } else if (field?.picklistValues && (type === 'picklist' || type === 'multipicklist')) { + column.editable = true; + column.editorOptions = { commitOnOutsideClick: false, displayCellContent: true }; + column.renderEditCell = editorDropdown({ + 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, + })), + }); + } else if (type === 'reference' && field.referenceTo?.length && field.referenceTo?.length > 0) { + column.editable = true; + column.editorOptions = { commitOnOutsideClick: false, displayCellContent: true }; + column.renderEditCell = editorRecordLookup({ sobjects: field.referenceTo }); + } else { + column.editable = true; + column.editorOptions = { commitOnOutsideClick: false, displayCellContent: true }; + column.renderEditCell = EditorText; + } +} + +/** + * Compute a new column-order key array by moving `sourceId` to sit before/after `targetId`. Operates on + * the full order (including non-data keys like select/action) so the caller can hand it straight to + * `table.setColumnOrder`. Returns the input unchanged when the move is a no-op or either id is missing. + */ +export function reorderColumnOrder(order: string[], sourceId: string, targetId: string, side: 'left' | 'right'): string[] { + if (sourceId === targetId) { + return order; + } + const sourceIndex = order.indexOf(sourceId); + const targetIndex = order.indexOf(targetId); + if (sourceIndex === -1 || targetIndex === -1) { + return order; + } + + const next = order.slice(); + next.splice(sourceIndex, 1); + // Recompute the target index against the post-removal array, then offset for a right-side drop. + const targetIndexAfterRemoval = next.indexOf(targetId); + const insertIndex = side === 'right' ? targetIndexAfterRemoval + 1 : targetIndexAfterRemoval; + next.splice(insertIndex, 0, sourceId); + + // No-op guard: if the resulting order matches the original, return the original reference. + if (next.every((key, index) => key === order[index])) { + return order; + } + return next; +} + +export function addFieldLabelToColumn(columnDefinitions: ColumnWithFilter[], fieldMetadata: Record) { + if (fieldMetadata) { + 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; +} diff --git a/libs/ui/src/lib/data-table/grid/grid-constants.ts b/libs/ui/src/lib/data-table/grid/grid-constants.ts new file mode 100644 index 000000000..87c23a327 --- /dev/null +++ b/libs/ui/src/lib/data-table/grid/grid-constants.ts @@ -0,0 +1,54 @@ +import { ContextMenuItem } from '@jetstream/types'; +import { ContextAction } from './grid-types'; + +/** Placeholder used in SET filters to represent null/empty cell values. */ +export const EMPTY_FIELD = '-BLANK-'; + +/** + * Sentinel `rowId` for the column header row in the keyboard-navigation active-cell model. The header + * isn't part of `table.getRowModel().rows`, so it's represented by this id — ArrowUp from the first body + * row moves here (letting the keyboard reach select-all / header filters), ArrowDown returns to the body. + */ +export const HEADER_ROW_ID = '__jgrid_header__'; + +/** + * Sentinel `rowId`s for the pinned summary rows in the keyboard-navigation model. Like the header, the + * summary rows aren't in `table.getRowModel().rows`, so each is addressed by an index-suffixed id. They + * sit between the header and the body: ArrowDown from the header steps into the first summary row and + * down through them into the body; ArrowUp from the first body row steps back up through them. + */ +export const SUMMARY_ROW_ID_PREFIX = '__jgrid_summary__'; +export const getSummaryRowId = (index: number): string => `${SUMMARY_ROW_ID_PREFIX}${index}`; +export const isSummaryRowId = (rowId: string): boolean => rowId.startsWith(SUMMARY_ROW_ID_PREFIX); +export const getSummaryRowIndex = (rowId: string): number => Number.parseInt(rowId.slice(SUMMARY_ROW_ID_PREFIX.length), 10); + +export const ACTION_COLUMN_KEY = '_actions'; +export const RECORD_ERROR_COLUMN_KEY = '_saveError'; + +/** + * Row-selection column key. Matches the value react-data-grid used (`'select-row'`) so any persisted + * column orders / references created by the legacy grid remain valid after the migration. + */ +export const SELECT_COLUMN_KEY = 'select-row'; + +/** Columns that are not "data" columns (excluded from reorder persistence, copy, the ref column list). */ +export const NON_DATA_COLUMN_KEYS = new Set([SELECT_COLUMN_KEY, ACTION_COLUMN_KEY]); + +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 (CSV)', value: 'COPY_TABLE_CSV' }, + { label: 'Copy table to clipboard (JSON)', value: 'COPY_TABLE_JSON' }, +]; + +/** Default fixed row height (px) for non-wrapped rows; also the virtualizer seed estimate. */ +export const DEFAULT_ROW_HEIGHT = 28.5; +export const DEFAULT_HEADER_ROW_HEIGHT = 35; +export const DEFAULT_SUMMARY_ROW_HEIGHT = 34; +export const DEFAULT_COLUMN_WIDTH = 200; +export const DEFAULT_MIN_COLUMN_WIDTH = 50; diff --git a/libs/ui/src/lib/data-table/grid/grid-context.tsx b/libs/ui/src/lib/data-table/grid/grid-context.tsx new file mode 100644 index 000000000..663b2f441 --- /dev/null +++ b/libs/ui/src/lib/data-table/grid/grid-context.tsx @@ -0,0 +1,43 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { NOOP } from '@jetstream/shared/utils'; +import type { Table } from '@tanstack/react-table'; +import { createContext, useContext } from 'react'; +import { ColumnWithFilter, FilterContextProps, RowWithKey, SelectedRowsContext, SubqueryContext } from './grid-types'; + +/** Filter state for header filter UIs + renderers that need the active filters. */ +export const GridFilterContext = createContext({ + filterSetValues: {}, + filters: {}, + updateFilter: NOOP, +}); + +/** Subquery modal configuration + callbacks. */ +export const GridSubqueryContext = createContext(undefined); + +/** Selected row tracking for renderers that branch on selection. */ +export const GridSelectedContext = createContext({ selectedRowIds: new Set() }); + +/** Arbitrary data bag passed by the consumer (org, onRecordAction, defaultApiVersion, rows, columns, ...). */ +export const GridGenericContext = createContext>({}); + +/** + * Shared grid runtime: the TanStack table instance + a few config values renderers/cells need without + * threading props through every layer. + */ +export interface GridRuntime { + table: Table; + gridId: string; + getRowKey: (row: TRow) => string; + /** Ordered, visible author-facing columns (kept in sync with TanStack column order). */ + columns: ColumnWithFilter[]; +} + +export const GridRuntimeContext = createContext(undefined); + +export function useGridRuntime(): GridRuntime { + const runtime = useContext(GridRuntimeContext); + if (!runtime) { + throw new Error('useGridRuntime must be used within a GridContainer'); + } + return runtime as unknown as GridRuntime; +} diff --git a/libs/ui/src/lib/data-table/grid/grid-filters.ts b/libs/ui/src/lib/data-table/grid/grid-filters.ts new file mode 100644 index 000000000..e184f8a49 --- /dev/null +++ b/libs/ui/src/lib/data-table/grid/grid-filters.ts @@ -0,0 +1,219 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { DATE_FORMATS } from '@jetstream/shared/constants'; +import { ensureBoolean, orderValues } from '@jetstream/shared/utils'; +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 isString from 'lodash/isString'; +import { EMPTY_FIELD } from './grid-constants'; +import { ColumnWithFilter, DataTableFilter, FILTER_SET_TYPES, FilterType } from './grid-types'; + +/** + * Pure filtering logic shared by the table's custom TanStack `filterFn`s and the header filter UIs. + * Ported verbatim (behavior-preserving) from the legacy `data-table-utils.tsx` so existing tables + * filter identically. + */ + +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; + 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; + } + return value === ensureBoolean(filter.value[0]); + } + case 'SET': { + const includeNulls = filter.value.includes(EMPTY_FIELD); + return (includeNulls && isNil(value)) || (!isNil(value) && filter.value.includes(String(value))); + } + default: + return false; + } +} + +/** + * True if any filter in the map is actually narrowing results (used to short-circuit work and to + * decide whether to retain filter state across data changes). + */ +export function hasFilterApplied(filters: Record, filterSetValues: Record): boolean { + return Object.entries(filters).some(([key, columnFilters]) => + columnFilters.some((filter) => { + switch (filter.type) { + case 'SET': + return filter.value.length < (filterSetValues[key]?.length || 0); + case 'BOOLEAN_SET': + return filter.value.length < 2; // true/false + case 'DATE': + case 'NUMBER': + case 'TEXT': + case 'TIME': + return !!filter.value; + default: + return false; + } + }), + ); +} + +/** + * Resolve the value used for filtering/grouping a cell — the column's `getValue` if present, else the + * raw row property. + */ +export function getFilterValue(column: ColumnWithFilter, row: TRow): unknown { + if (column.getValue) { + return column.getValue({ row, column }); + } + return (row as Record)[column.key]; +} + +/** + * Compute the distinct selectable values for each SET / BOOLEAN_SET column. BOOLEAN_SET is always + * `['True', 'False']`; SET columns derive their distinct values from the data (null → EMPTY_FIELD). + */ +export function computeFilterSetValues( + columns: ColumnWithFilter[], + data: TRow[], + ignoreRowInSetFilter?: (row: TRow) => boolean, +): Record { + const columnsByKey = new Map(columns.map((column) => [column.key, column])); + return columns.reduce((acc: Record, column) => { + const setFilterType = column.filters?.find((type) => FILTER_SET_TYPES.has(type)); + if (!setFilterType) { + return acc; + } + if (setFilterType === 'BOOLEAN_SET') { + acc[column.key] = ['True', 'False']; + return acc; + } + const resolvedColumn = columnsByKey.get(column.key); + if (!resolvedColumn) { + return acc; + } + acc[column.key] = orderValues( + Array.from( + new Set( + data + .filter((row) => (ignoreRowInSetFilter ? !ignoreRowInSetFilter(row) : true)) + .map((row) => { + const rowValue = getFilterValue(resolvedColumn, row); + return isNil(rowValue) ? EMPTY_FIELD : String(rowValue); + }), + ), + ), + ); + return acc; + }, {}); +} diff --git a/libs/ui/src/lib/data-table/grid/grid-row-utils.ts b/libs/ui/src/lib/data-table/grid/grid-row-utils.ts new file mode 100644 index 000000000..ac3c7b87f --- /dev/null +++ b/libs/ui/src/lib/data-table/grid/grid-row-utils.ts @@ -0,0 +1,204 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { logger } from '@jetstream/shared/client-logger'; +import { RECORD_PREFIX_MAP } from '@jetstream/shared/constants'; +import { getIdFromRecordUrl } from '@jetstream/shared/utils'; +import type { Row, Table } from '@tanstack/react-table'; +import isNil from 'lodash/isNil'; +import isObject from 'lodash/isObject'; +import isString from 'lodash/isString'; +import uniqueId from 'lodash/uniqueId'; +import { ColumnType, ColumnWithFilter } from './grid-types'; + +/** + * The filtered + sorted DATA rows, independent of group expansion state: collapsed groups' leaves are + * included and synthetic group header rows are excluded. This is "the rows" every consumer-facing + * surface (onRowsChange, context menu data, getFilteredAndSortedRows, the generic context) exposes — + * the legacy grid filtered/sorted outside react-data-grid, so consumers always saw the full flat list. + * + * Two TanStack quirks make the raw flatRows unusable directly: group rows carry the first leaf's + * `original` (they'd appear as duplicate data rows), and with no sort applied getSortedRowModel() + * passes through the GROUPED model, whose flatRows contain every leaf twice (pushed once during + * recursion and once per-group). Hence the getIsGrouped filter AND the id de-dupe. + */ +export function getSortedFilteredLeafRows(table: Table): Row[] { + const seenRowIds = new Set(); + const leafRows: Row[] = []; + for (const row of table.getSortedRowModel().flatRows) { + if (!row.getIsGrouped() && !seenRowIds.has(row.id)) { + seenRowIds.add(row.id); + leafRows.push(row); + } + } + return leafRows; +} + +const SFDC_EMPTY_ID = '000000000000000AAA'; + +// TanStack Table calls getRowId on every render to re-resolve its row model. Without this cache, rows +// that have no natural id (AggregateResults, blank Salesforce ids) would be assigned a fresh +// `uniqueId('row-id')` on every render, breaking selection/expansion/filter persistence. Caching by +// object identity gives the same row the same id for as long as the consumer keeps the reference. +const generatedIdCache = new WeakMap(); + +function getOrGenerateId(data: object): string { + let cached = generatedIdCache.get(data); + if (!cached) { + cached = uniqueId('row-id'); + generatedIdCache.set(data, cached); + } + return cached; +} + +/** + * Derive a stable unique key for a row. Prefers an explicit `_key`/`key`, then a Salesforce record + * url/Id, and finally falls back to a generated id (for AggregateResults and blank ids) — cached per + * row object so the same row keeps the same id across renders. + */ +export function getRowId(data: any): string { + if (data?._key) { + return data._key; + } + if (data?.key) { + return data.key; + } + if (data && typeof data === 'object' && data.attributes?.type === 'AggregateResult') { + return getOrGenerateId(data); + } + const nodeId = data?.attributes?.url || data?.Id || data?.id; + if (!nodeId || (isString(nodeId) && nodeId.endsWith(SFDC_EMPTY_ID)) || data?.Id === SFDC_EMPTY_ID) { + return data && typeof data === 'object' ? getOrGenerateId(data) : uniqueId('row-id'); + } + return nodeId; +} + +/** + * Build a per-row lowercase search index ({ rowKey: concatenatedText }) used by the global quick + * filter. Computed once per data/column change so the quick filter stays cheap on large datasets. + */ +export function getSearchTextByRow( + rows: T[], + columns: ColumnWithFilter[], + getRowKey: (row: T) => string, + // For `getSubRows` trees, `rows` holds only the roots — recurse so every descendant (e.g. Automation + // Control's automation items + flow versions) is indexed, or the quick filter can only match roots. + getSubRows?: (row: T, index: number) => T[] | undefined, +): Record { + const output: Record = {}; + const indexRows = (list: T[]) => { + if (!Array.isArray(list)) { + return; + } + list.forEach((row, index) => { + 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()}`; + } + } + }); + } + const children = getSubRows?.(row, index); + if (children?.length) { + indexRows(children); + } + }); + }; + indexRows(rows); + return output; +} + +/** Heuristic column type from a raw value (used for generic/unknown data tables). */ +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'; +} + +/** "Parent Record: Name (Id)" tagline for the subquery modal. */ +export function getSubqueryModalTagline(parentRecord: any): string | undefined { + 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 + } finally { + if (recordName || recordId) { + currModalTagline = 'Parent Record: '; + if (recordName) { + currModalTagline += recordName; + } + if (recordName && recordId) { + currModalTagline += ` (${recordId})`; + } else if (recordId) { + currModalTagline += recordId; + } + } + } + return currModalTagline; +} + +/** + * Build a Salesforce redirect URL for a record id, handling special object types (Group, RecordType, + * Profile, PermissionSet) whose URLs differ from the standard `/{id}` form. + */ +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}` }; + } +} diff --git a/libs/ui/src/lib/data-table/grid/grid-types.ts b/libs/ui/src/lib/data-table/grid/grid-types.ts new file mode 100644 index 000000000..bbdc82acb --- /dev/null +++ b/libs/ui/src/lib/data-table/grid/grid-types.ts @@ -0,0 +1,365 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { ContextMenuItem, Maybe, SalesforceOrgUi } from '@jetstream/types'; +import type { Header, Row, RowData, Table } from '@tanstack/react-table'; +import { ReactNode } from 'react'; + +/** + * Jetstream Data Table — type definitions. + * + * This module intentionally has NO dependency on `react-data-grid`. It is the rewritten replacement + * for `data-table-types.ts` and defines the public, author-facing column/row/filter shapes plus the + * render/edit prop contracts that replace react-data-grid's `RenderCellProps` / `RenderEditCellProps`. + */ + +// ───────────────────────────────────────────────────────────────────────────── +// Rows +// ───────────────────────────────────────────────────────────────────────────── + +export type RowWithKey = Record & { _key: string }; + +export type RowSalesforceRecordWithKey = RowWithKey & { + _action: (row: RowWithKey, action: 'view' | 'edit' | 'clone' | 'delete' | 'undelete' | 'apex') => void; + _idx: number; + _record: Record; + _touchedColumns: Set; + _saveError?: Maybe; +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Column + filter type discriminators (unchanged from the legacy implementation) +// ───────────────────────────────────────────────────────────────────────────── + +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 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[]; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Sort +// ───────────────────────────────────────────────────────────────────────────── + +export type SortDirection = 'ASC' | 'DESC'; + +/** Replacement for react-data-grid's `SortColumn`. Same shape so call sites are source-compatible. */ +export interface SortColumn { + readonly columnKey: string; + readonly direction: SortDirection; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Render / edit prop contracts (replace react-data-grid `RenderCellProps`/`RenderEditCellProps`) +// ───────────────────────────────────────────────────────────────────────────── + +export interface DataTableCellProps { + /** Convenience alias for `tanstackRow.original` */ + row: TRow; + column: ColumnWithFilter; + /** Accessor value for this cell (post `getValue`/`accessorFn`) */ + value: unknown; + /** Index within the current filtered + sorted (+ flattened) row model */ + rowIndex: number; + /** rdg-compat alias of rowIndex */ + rowIdx: number; + /** Escape hatch to the underlying TanStack row */ + tanstackRow: Row; + /** Tree (getSubRows) sugar — depth of this row in the tree (0 = root). */ + depth: number; + /** Tree (getSubRows) sugar — true when this row has child rows that can be expanded/collapsed. */ + canExpand: boolean; + /** Tree (getSubRows) sugar — current expanded state (false when the row cannot expand). */ + isExpanded: boolean; + /** Tree (getSubRows) sugar — toggle this row's expanded state. No-op when the row cannot expand. */ + toggleExpanded: () => void; + isEditing: boolean; + startEdit: () => void; + /** Commit an updated row; mirrors the old `onRowChange(row, true)` */ + commitEdit: (updatedRow: TRow, options?: { closeAndFocus?: boolean }) => void; + cancelEdit: () => void; +} + +export interface DataTableHeaderProps { + column: ColumnWithFilter; + sortDirection?: SortDirection; + /** Sort priority (1-based) when multi-column sorting is active */ + priority?: number; + header: Header; + /** Children-as-function pattern used by `FilterRenderer` */ + children?: ReactNode; +} + +export interface DataTableGroupCellProps { + /** The grouping value for this group header row */ + groupKey: unknown; + /** Leaf rows belonging to this group */ + childRows: TRow[]; + isExpanded: boolean; + toggleGroup: () => void; + column: ColumnWithFilter; + /** Underlying TanStack grouped row */ + tanstackRow: Row; +} + +export interface DataTableSummaryCellProps { + row: TSummaryRow; + column: ColumnWithFilter; +} + +export interface DataTableEditorProps { + row: TRow; + column: ColumnWithFilter; + rowIndex: number; + colIndex: number; + /** Commit a row change; pass `true` to also commit (close) the editor. Mirrors rdg `onRowChange`. */ + onRowChange: (row: TRow, commitChanges?: boolean) => void; + /** Close the editor. Mirrors rdg `onClose(commitChanges?, shouldFocusCell?)`. */ + onClose: (commitChanges?: boolean, shouldFocusCell?: boolean) => void; +} + +export interface ColumnEditorOptions { + /** When true, clicking outside the editor commits + closes. When false, it is ignored. */ + commitOnOutsideClick?: boolean; + /** When true, the underlying cell content remains visible behind/around the popover editor */ + displayCellContent?: boolean; +} + +/** Context passed to a column's `colSpan` resolver (discriminated so `row` is present for ROW/SUMMARY). + * GROUP is the group-header row; its `row` is the group's first child (representative), or undefined for + * an empty group. Resolving GROUP separately lets a column span in the header without affecting data rows. */ +export type ColSpanArgs = + | { type: 'HEADER'; row?: undefined } + | { type: 'ROW'; row: TRow } + | { type: 'SUMMARY'; row: TRow } + | { type: 'GROUP'; row?: TRow }; + +// ───────────────────────────────────────────────────────────────────────────── +// The public, author-facing column definition (detached from react-data-grid `Column`) +// ───────────────────────────────────────────────────────────────────────────── + +export interface ColumnWithFilter { + /** Unique column id; maps to TanStack `ColumnDef.id` / accessor. */ + key: string; + /** Header label / content. */ + name: string | ReactNode; + + // sizing + width?: number | string; + minWidth?: number; + maxWidth?: number; + resizable?: boolean; + + // behavior + sortable?: boolean; + draggable?: boolean; + /** Pin to the left (sticky) — used for the actions column. */ + frozen?: boolean; + /** Column id this column is grouped under, or a flag to allow grouping. */ + cellClass?: string | ((row: TRow) => string | null | undefined); + headerCellClass?: string; + summaryCellClass?: string | ((row: TSummaryRow) => string | null | undefined); + + /** Data accessor used for sorting, filtering, global search, and copy. */ + getValue?: (params: { row: TRow; column: ColumnWithFilter }) => string | null; + /** Filter UIs this column supports. */ + filters?: FilterType[]; + + // rendering + renderCell?: (props: DataTableCellProps) => ReactNode; + renderHeaderCell?: (props: DataTableHeaderProps) => ReactNode; + renderGroupCell?: (props: DataTableGroupCellProps) => ReactNode; + renderSummaryCell?: (props: DataTableSummaryCellProps) => ReactNode; + colSpan?: (args: ColSpanArgs) => number | undefined; + + // editing + editable?: boolean | ((row: TRow) => boolean); + renderEditCell?: (props: DataTableEditorProps) => ReactNode; + editorOptions?: ColumnEditorOptions; +} + +export type DefaultColumnOptions = Partial< + Pick, 'minWidth' | 'maxWidth' | 'width' | 'resizable' | 'sortable' | 'draggable'> +>; + +export interface SalesforceQueryColumnDefinition { + parentColumns: ColumnWithFilter[]; + subqueryColumns: Record[]>; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Imperative ref API (unchanged signatures) +// ───────────────────────────────────────────────────────────────────────────── + +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[]; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Contexts shared with renderers / filters (unchanged shapes) +// ───────────────────────────────────────────────────────────────────────────── + +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; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Context menu +// ───────────────────────────────────────────────────────────────────────────── + +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' + | 'COPY_TABLE_CSV'; + +export type ContextMenuActionData = { + row: T; + rows: T[]; + rowIdx: number; + column: ColumnWithFilter; + columns: ColumnWithFilter[]; +}; + +/** + * Context-menu items: either a static list, or a builder evaluated against the right-clicked cell so the + * menu can be cell/column/group-aware (e.g. "Copy column (Apex Classes)"). Returning `[]` suppresses the + * custom menu for that cell (the native browser menu is allowed through). Builders run for data-row + * right-clicks; column-header right-clicks use the static list (filtered to column-scoped actions). + */ +export type ContextMenuItems = ContextMenuItem[] | ((data: ContextMenuActionData) => ContextMenuItem[]); + +// ───────────────────────────────────────────────────────────────────────────── +// Internal: meta carried on TanStack `ColumnDef.meta` so presentational components can +// reach author intent. `cellKind` replaces the legacy `dataTableRenderFnMap` identity trick. +// ───────────────────────────────────────────────────────────────────────────── + +export type CellKind = 'data' | 'select' | 'action' | 'rowheader'; + +export interface JetstreamColumnMeta { + /** The original author-facing column, passed back to renderers/editors. */ + column: ColumnWithFilter; + filters?: FilterType[]; + frozen?: boolean; + cellKind: CellKind; + cellClass?: ColumnWithFilter['cellClass']; + colSpan?: ColumnWithFilter['colSpan']; + renderGroupCell?: ColumnWithFilter['renderGroupCell']; + renderSummaryCell?: ColumnWithFilter['renderSummaryCell']; + editor?: ColumnWithFilter['renderEditCell']; + editable?: ColumnWithFilter['editable']; + editorOptions?: ColumnEditorOptions; +} + +declare module '@tanstack/react-table' { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface ColumnMeta { + jetstream?: JetstreamColumnMeta; + } + // Expose our table on the cell/header context without per-call casts + // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-object-type + interface TableMeta { + gridId?: string; + } +} + +export type { Header, Row, Table }; diff --git a/libs/ui/src/lib/data-table/grid/keyboard/useGridKeyboardNavigation.ts b/libs/ui/src/lib/data-table/grid/keyboard/useGridKeyboardNavigation.ts new file mode 100644 index 000000000..0f919fbb0 --- /dev/null +++ b/libs/ui/src/lib/data-table/grid/keyboard/useGridKeyboardNavigation.ts @@ -0,0 +1,994 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { copyRecordsToClipboard } from '@jetstream/shared/ui-utils'; +import type { Column, Row, Table } from '@tanstack/react-table'; +import { FocusEvent as ReactFocusEvent, KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useRef, useState } from 'react'; +import { ActiveCell } from '../components/GridRow'; +import { getSummaryRowId, getSummaryRowIndex, HEADER_ROW_ID, isSummaryRowId } from '../grid-constants'; +import { ColSpanArgs } from '../grid-types'; + +export type GridMode = 'navigation' | 'actionable'; + +/** Rows to jump on PageUp/PageDown (approximate viewport page). */ +const PAGE_SIZE = 12; + +/** + * Interactive controls inside a cell that Space/Enter can "activate" (and that Tab cycles in actionable + * mode). Deliberately does NOT exclude `tabindex="-1"` — in-cell controls are removed from the page tab + * order (the grid is a single tab stop), so they are reached only via this keyboard model, not Tab. + * `[aria-haspopup]` matches floating-ui popover triggers (useRole adds `aria-haspopup="dialog"`). + */ +const ACTIVATABLE_SELECTOR = + 'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [role="button"], [aria-haspopup]'; + +/** A rectangular cell-selection in display-index space (inclusive bounds). */ +export interface SelectionRange { + minRow: number; + maxRow: number; + minCol: number; + maxCol: number; +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +/** + * Column indexes at which a row starts a (possibly multi-column) rendered cell — i.e. the navigable + * positions for that row. Cells honor colSpan (GROUP for group headers, ROW for data rows), so a + * spanned-over column has no DOM cell to focus; nav must step between, and snap onto, these owners. + */ +function getRowSegmentStarts(row: Row, columns: Column[]): number[] { + const grouped = row.getIsGrouped(); + if (grouped) { + // When no column supplies a group cell, GridGroupRow renders ONE full-width header at the first + // column (the fallback) — its only navigable position is column 0. + const anyGroupCell = columns.some((column) => column.columnDef.meta?.jetstream?.renderGroupCell); + if (!anyGroupCell) { + return [0]; + } + } + const representative = grouped ? row.getLeafRows()[0]?.original : row.original; + const colSpanArgs: ColSpanArgs = grouped ? { type: 'GROUP', row: representative } : { type: 'ROW', row: representative as TRow }; + const starts: number[] = []; + let index = 0; + while (index < columns.length) { + starts.push(index); + const span = Math.max(1, columns[index].columnDef.meta?.jetstream?.colSpan?.(colSpanArgs) ?? 1); + index += span; + } + return starts; +} + +/** The segment owner (start index) of the cell that covers `targetColIndex` in `row`. For a row with no + * spans this is `targetColIndex` itself; for a spanned cell it's the column that renders it. */ +function resolveColumnStart(row: Row, columns: Column[], targetColIndex: number): number { + const starts = getRowSegmentStarts(row, columns); + let owner = starts[0] ?? 0; + for (const start of starts) { + if (start <= targetColIndex) { + owner = start; + } else { + break; + } + } + return owner; +} + +function cellText(row: Row, column: Column): string { + // Synthetic group header rows have no original row data. + if (row.original === null || row.original === undefined) { + return ''; + } + const authorColumn = column.columnDef.meta?.jetstream?.column; + let value: unknown = authorColumn?.getValue + ? authorColumn.getValue({ row: row.original, column: authorColumn }) + : (row.original as Record)[column.id]; + if (value === null || value === undefined) { + return ''; + } + if (Array.isArray(value) || typeof value === 'object') { + return JSON.stringify(value); + } + return String(value); +} + +/** Display label for a column header, used when copying a selection "with header". Falls back to the + * string header / column id when the author's `name` is a ReactNode. */ +function headerText(column: Column): string { + const name = column.columnDef.meta?.jetstream?.column?.name; + if (typeof name === 'string') { + return name; + } + const header = column.columnDef.header; + if (typeof header === 'string') { + return header; + } + return column.id; +} + +export interface UseGridKeyboardNavigationOptions { + table: Table; + /** Returns the grid root element so DOM focus/copy queries are scoped to this grid instance. */ + getRootElement: () => HTMLElement | null; + /** Enter/F2 hook: if it starts editing the cell (returns true), the grid stays out of Actionable mode. */ + onRequestEdit?: (cell: ActiveCell) => boolean; + /** Return true to keep the active cell/selection when focus leaves the grid root — used when focus + * moves into the grid's own portaled UI (context menu, popover editor), which must keep acting on + * the current selection. */ + shouldRetainFocusOnBlur?: (relatedTarget: Node | null) => boolean; + /** Number of pinned summary rows. They sit between the header and the body in the navigation order so + * arrows can step into them (e.g. column filter inputs, bulk select-all/none/reset actions). */ + summaryRowCount?: number; + /** Emit a message to the grid's polite live region (e.g. "Copied 3 rows by 2 columns") so screen-reader + * users get feedback for actions that are otherwise only visual. */ + onAnnounce?: (message: string) => void; +} + +export interface GridKeyboardNavigation { + activeCell: ActiveCell | null; + anchorCell: ActiveCell | null; + mode: GridMode; + setActiveCell: (cell: ActiveCell | null) => void; + handleKeyDown: (event: ReactKeyboardEvent) => void; + /** When the grid ROOT itself receives focus (Tab-in) and no cell is active, seed the first cell. Must + * ignore focus bubbling up from descendants (header buttons, cells) or it would yank focus to (0,0). */ + handleRootFocus: (event: ReactFocusEvent) => void; + /** Whether the last active-cell change came from the mouse or keyboard — lets GridBody skip stealing + * focus back to the cell on mouse clicks (which would close popovers opened from a cell). The + * 'select-all' source additionally suppresses scroll-into-view (Ctrl+A must not jump the viewport). */ + getLastInteractionSource: () => 'mouse' | 'keyboard' | 'select-all'; + /** When focus leaves the grid entirely (e.g. Tab/Shift+Tab out), clear the active cell so the next + * focus event re-seeds and the grid root re-enters the tab order. */ + handleRootBlur: (event: ReactFocusEvent) => void; + /** Mouse down on a cell — starts a (possibly shift-extended) selection / drag. Right-click keeps an + * existing range when clicking inside it (spreadsheet behavior) so the context menu can act on it. */ + handleCellMouseDown: (rowId: string, columnId: string, shiftKey: boolean, button?: number) => void; + /** Mouse enters a cell while dragging — extends the rectangular selection. */ + handleCellMouseEnter: (rowId: string, columnId: string) => void; + /** Mouse down on a column header cell — makes it the keyboard-active cell (header row navigation). */ + handleHeaderCellMouseDown: (columnId: string) => void; + /** Mouse down on a summary cell — makes it the keyboard-active cell (summary row navigation). */ + handleSummaryCellMouseDown: (rowId: string, columnId: string) => void; + /** Copy the current selection (rectangle, or the single active cell) as TSV; optionally prepend a header row. */ + copySelection: (includeHeader?: boolean) => void; +} + +/** + * The grid's keyboard navigation / a11y state machine + rectangular cell selection. + * + * - Navigation mode (default): a single roving `tabindex=0` cell; arrows move cell-to-cell. + * - Actionable mode: entered via Enter/F2; focusables inside the cell become reachable; Esc returns. + * - Selection: an `anchorCell` + the `activeCell` (focus) define a rectangle. Shift+Arrow, Shift+Click, + * and mouse-drag extend it; a plain Arrow/Click collapses it. Ctrl/Cmd+A selects all. Ctrl/Cmd+C + * copies the rectangle as TSV (Excel/Sheets-friendly), or the single active cell when collapsed. + * + * Focus + selection are stored as logical `{ rowId, columnId }` coordinates (not DOM nodes) so they + * survive row virtualization recycling — GridBody resolves the active cell to a DOM element. + */ +export function useGridKeyboardNavigation({ + table, + getRootElement, + onRequestEdit, + shouldRetainFocusOnBlur, + summaryRowCount = 0, + onAnnounce, +}: UseGridKeyboardNavigationOptions): GridKeyboardNavigation { + const [activeCell, setActiveCellState] = useState(null); + const [anchorCell, setAnchorCell] = useState(null); + const [mode, setMode] = useState('navigation'); + const isDraggingRef = useRef(false); + // Tracks whether the most recent active-cell change was mouse- or keyboard-driven (see interface doc). + const interactionSourceRef = useRef<'mouse' | 'keyboard' | 'select-all'>('keyboard'); + // The cell an activation opened a popover/modal from — focus is returned here once the overlay closes. + const pendingReturnFocusCellRef = useRef(null); + // Live mirror of activeCell for the document focus listeners (avoids re-subscribing on every move). + const activeCellRef = useRef(activeCell); + activeCellRef.current = activeCell; + // The column the user deliberately chose (last horizontal move / click). Vertical navigation targets + // this column so passing through colSpan'd rows (group headers, "no rows" spanners) — which snap focus + // onto a span owner — doesn't permanently drag the user to that owner's column. The classic + // spreadsheet "sticky column" behavior. + const desiredColRef = useRef(null); + + // Stop drag-select when the mouse is released anywhere. + useEffect(() => { + const onMouseUp = () => { + isDraggingRef.current = false; + }; + window.addEventListener('mouseup', onMouseUp); + return () => window.removeEventListener('mouseup', onMouseUp); + }, []); + + // Apply a new active cell. `extend` keeps the existing anchor (range select); otherwise the anchor + // collapses onto the new cell. `keepDesiredCol` is set by vertical moves so they don't overwrite the + // user's sticky column with the (possibly snapped) column they're passing through. + const applySelection = useCallback( + (rowId: string, columnId: string, extend: boolean, keepDesiredCol = false) => { + if (!keepDesiredCol) { + const colIndex = table.getVisibleLeafColumns().findIndex((column) => column.id === columnId); + if (colIndex >= 0) { + desiredColRef.current = colIndex; + } + } + setActiveCellState({ rowId, columnId }); + setMode('navigation'); + setAnchorCell((prevAnchor) => (extend && prevAnchor ? prevAnchor : { rowId, columnId })); + }, + [table], + ); + + const setActiveCell = useCallback( + (cell: ActiveCell | null) => { + interactionSourceRef.current = 'keyboard'; + if (cell) { + const colIndex = table.getVisibleLeafColumns().findIndex((column) => column.id === cell.columnId); + if (colIndex >= 0) { + desiredColRef.current = colIndex; + } + } + setActiveCellState(cell); + setAnchorCell(cell); + }, + [table], + ); + + const moveTo = useCallback( + (rowIndex: number, colIndex: number, extend: boolean, keepDesiredCol = false) => { + const rows = table.getRowModel().rows; + const columns = table.getVisibleLeafColumns(); + if (!rows.length || !columns.length) { + return; + } + const nextRow = rows[clamp(rowIndex, 0, rows.length - 1)]; + const targetCol = clamp(colIndex, 0, columns.length - 1); + // Snap the target column onto the cell that actually renders it for this row (honoring colSpan: + // GROUP for group headers, ROW for data rows like a full-width "no rows found" message). Without + // this, focus targets a column hidden under a span and the move silently no-ops. + const nextColIndex = resolveColumnStart(nextRow, columns, targetCol); + applySelection(nextRow.id, columns[nextColIndex].id, extend, keepDesiredCol); + }, + [table, applySelection], + ); + + /** + * After keyboard activation opens a Popover (which, unlike Modal, has no focus manager), move focus into + * the popover's body so the keyboard lands inside it (e.g. a filter's search box). Retried across a few + * frames while the portal mounts. No-op for Modals (they manage their own focus) and for activations + * that didn't open a popover. Skips if focus already moved inside (an autofocused input). + */ + const focusOpenedPopover = useCallback(() => { + const root = getRootElement(); + let attempts = 0; + const tryFocus = () => { + const panel = Array.from(document.querySelectorAll('.slds-popover')).find((el) => !root || !el.contains(root)); + if (panel) { + if (!panel.contains(document.activeElement)) { + const body = panel.querySelector('.slds-popover__body'); + const focusable = + body?.querySelector(ACTIVATABLE_SELECTOR) ?? panel.querySelector(ACTIVATABLE_SELECTOR) ?? null; + focusable?.focus(); + } + return; + } + if (attempts++ < 6) { + requestAnimationFrame(tryFocus); + } + }; + requestAnimationFrame(tryFocus); + }, [getRootElement]); + + /** Resolve a logical cell coordinate to its DOM element (scoped to this grid). Query by data attrs + * rather than `document.activeElement` so it works even before the focus effect has landed. */ + const getCellElement = useCallback( + (cell: ActiveCell): HTMLElement | null => { + const root = getRootElement(); + return ( + root?.querySelector(`[data-row-id="${CSS.escape(cell.rowId)}"][data-col-id="${CSS.escape(cell.columnId)}"]`) ?? null + ); + }, + [getRootElement], + ); + + /** + * Activate the cell's interactive content from navigation mode (Space or Enter on a non-editable + * cell). A lone checkbox toggles; a single popover/link/button is clicked (opens the popover); a cell + * with several controls (e.g. the action cell) enters Actionable mode so Tab cycles them. A cell with + * NO interactive content does nothing — it stays in navigation mode so the cell keeps focus and + * arrows/Space keep working (entering Actionable mode there is a dead end that scrolls the page). + * `.click()` works regardless of `tabindex`, so in-cell controls stay out of the page tab order. + */ + const activateCell = useCallback( + (cell: ActiveCell) => { + const cellEl = getCellElement(cell); + if (!cellEl) { + return; + } + const checkboxes = cellEl.querySelectorAll('input[type="checkbox"]:not([disabled])'); + if (checkboxes.length === 1) { + checkboxes[0].click(); + return; + } + const controls = cellEl.querySelectorAll(ACTIVATABLE_SELECTOR); + // A lone text input / textarea / select (e.g. a column filter cell) is focused for typing via + // Actionable mode — a programmatic `.click()` wouldn't move focus into it, and Actionable mode lets + // Escape return to the cell. The mode change drives the focus effect to focus the input. + const loneTextInput = + controls.length === 1 && + controls[0].matches('input:not([type="checkbox"]):not([type="radio"]):not([type="button"]), textarea, select'); + if (loneTextInput) { + setMode('actionable'); + return; + } + if (controls.length === 1) { + // A single control is typically a popover/modal trigger — remember the cell so focus returns + // here when that overlay closes (no-op if the control acts inline and never moves focus away). + pendingReturnFocusCellRef.current = cell; + controls[0].click(); + // If it opened a popover, move focus into it (Modals manage their own focus, so this no-ops there). + focusOpenedPopover(); + return; + } + if (controls.length > 1) { + setMode('actionable'); + } + }, + [getCellElement, focusOpenedPopover], + ); + + /** + * Activate a column header control. Headers deliberately do NOT use Actionable mode (it traps focus in + * a way that's confusing in a one-row header) — instead Enter prefers sort and Space prefers the filter, + * so a column that is both sortable and filterable exposes both via the keyboard. A select-all checkbox + * is always toggled. The filter trigger opens a popover, so focus-return is armed for it. + */ + const activateHeaderCell = useCallback( + (columnId: string, prefer: 'sort' | 'filter') => { + const cellEl = getCellElement({ rowId: HEADER_ROW_ID, columnId }); + if (!cellEl) { + return; + } + const checkbox = cellEl.querySelector('input[type="checkbox"]:not([disabled])'); + if (checkbox) { + checkbox.click(); + return; + } + const filterTrigger = cellEl.querySelector( + '.jgrid-header-filter-slot button, .jgrid-header-filter-slot [aria-haspopup]', + ); + const sortButton = cellEl.querySelector('.jgrid-header-sort-button'); + const target = prefer === 'filter' ? (filterTrigger ?? sortButton) : (sortButton ?? filterTrigger); + if (!target) { + return; + } + if (target === filterTrigger) { + pendingReturnFocusCellRef.current = { rowId: HEADER_ROW_ID, columnId }; + target.click(); + // Move keyboard focus into the filter popover (e.g. its search box) once it mounts. + focusOpenedPopover(); + return; + } + target.click(); + }, + [getCellElement, focusOpenedPopover], + ); + + // Return focus to the originating cell after a popover/modal opened from the grid closes. The grid + // retains the active cell across the open (shouldRetainFocusOnBlur), but DOM focus moves into the + // overlay; when it closes and focus would fall to , pull it back to the cell so arrow navigation + // continues. Works for popovers/modals opened by mouse OR keyboard, from a body cell or the header. + useEffect(() => { + // Returns overlays (portaled popovers/modals) that are NOT an ancestor of this grid — i.e. a popover + // opened FROM the grid, excluding a modal that merely hosts the grid. + const hasForeignOverlayOpen = () => { + const root = getRootElement(); + return Array.from(document.querySelectorAll('.slds-popover, .slds-modal, [role="dialog"]')).some( + (overlay) => !root || !overlay.contains(root), + ); + }; + + // When focus moves into such an overlay, remember the active cell so we can restore it on close. + const handleFocusIn = (event: FocusEvent) => { + const target = event.target as HTMLElement | null; + const overlay = target?.closest?.('.slds-popover, .slds-modal, [role="dialog"]'); + const root = getRootElement(); + if (overlay && (!root || !overlay.contains(root)) && activeCellRef.current) { + pendingReturnFocusCellRef.current = activeCellRef.current; + } + }; + + const handleFocusOut = () => { + if (!pendingReturnFocusCellRef.current) { + return; + } + requestAnimationFrame(() => { + const cell = pendingReturnFocusCellRef.current; + if (!cell) { + return; + } + // Wait while the overlay is still up (e.g. tabbing within it). + if (hasForeignOverlayOpen()) { + return; + } + pendingReturnFocusCellRef.current = null; + const active = document.activeElement as HTMLElement | null; + const cellEl = getCellElement(cell); + // Refocus the originating cell when focus fell to , OR when the overlay's `returnFocus` put + // it back on a control INSIDE that cell (e.g. a header filter icon after Escape). Otherwise DOM + // focus sits on the in-cell control and arrow navigation can't resume — the cell is the rover. + const focusReturnedInsideCell = !!cellEl && !!active && active !== cellEl && cellEl.contains(active); + if (!active || active === document.body || focusReturnedInsideCell) { + cellEl?.focus(); + } + }); + }; + + document.addEventListener('focusin', handleFocusIn); + document.addEventListener('focusout', handleFocusOut); + return () => { + document.removeEventListener('focusin', handleFocusIn); + document.removeEventListener('focusout', handleFocusOut); + }; + }, [getCellElement, getRootElement]); + + const handleRootFocus = useCallback( + (event: ReactFocusEvent) => { + // Only seed when the grid root ITSELF received focus (Tab-in). `onFocus` bubbles, so focus landing + // on a descendant (header filter button, a cell) would otherwise seed (0,0) and yank focus/scroll + // to the far-left column — exactly the "first header click jumps left" bug. + if (event.target !== event.currentTarget || activeCell) { + return; + } + const rows = table.getRowModel().rows; + const columns = table.getVisibleLeafColumns(); + if (rows.length && columns.length) { + interactionSourceRef.current = 'keyboard'; + applySelection(rows[0].id, columns[0].id, false); + } + }, + [activeCell, table, applySelection], + ); + + // When focus leaves the grid (Tab/Shift+Tab out, click outside), drop the active cell so the grid + // root re-enters the tab order and the next focus event re-seeds via handleRootFocus. + const handleRootBlur = useCallback( + (event: ReactFocusEvent) => { + const next = event.relatedTarget as Node | null; + if (next && event.currentTarget.contains(next)) { + return; + } + if (shouldRetainFocusOnBlur?.(next)) { + return; + } + setActiveCellState(null); + setAnchorCell(null); + setMode('navigation'); + }, + [shouldRetainFocusOnBlur], + ); + + /** True when the cell falls inside the current (possibly single-cell) selection rectangle. */ + const isCellInSelection = useCallback( + (rowId: string, columnId: string): boolean => { + if (!activeCell) { + return false; + } + const anchor = anchorCell ?? activeCell; + const rows = table.getRowModel().rows; + const columns = table.getVisibleLeafColumns(); + const rowIndex = rows.findIndex((row) => row.id === rowId); + const colIndex = columns.findIndex((column) => column.id === columnId); + const activeRowIndex = rows.findIndex((row) => row.id === activeCell.rowId); + const anchorRowIndex = rows.findIndex((row) => row.id === anchor.rowId); + const activeColIndex = columns.findIndex((column) => column.id === activeCell.columnId); + const anchorColIndex = columns.findIndex((column) => column.id === anchor.columnId); + if (rowIndex < 0 || colIndex < 0 || activeRowIndex < 0 || anchorRowIndex < 0 || activeColIndex < 0 || anchorColIndex < 0) { + return false; + } + return ( + rowIndex >= Math.min(activeRowIndex, anchorRowIndex) && + rowIndex <= Math.max(activeRowIndex, anchorRowIndex) && + colIndex >= Math.min(activeColIndex, anchorColIndex) && + colIndex <= Math.max(activeColIndex, anchorColIndex) + ); + }, + [activeCell, anchorCell, table], + ); + + const handleCellMouseDown = useCallback( + (rowId: string, columnId: string, shiftKey: boolean, button = 0) => { + // Right/middle click: never start a drag. Right-click inside the current selection keeps it + // (the context menu acts on the range — spreadsheet behavior); outside it, move the selection. + if (button !== 0) { + if (button === 2 && !isCellInSelection(rowId, columnId)) { + interactionSourceRef.current = 'mouse'; + applySelection(rowId, columnId, false); + } + return; + } + interactionSourceRef.current = 'mouse'; + if (shiftKey) { + applySelection(rowId, columnId, true); + } else { + applySelection(rowId, columnId, false); + isDraggingRef.current = true; + } + }, + [applySelection, isCellInSelection], + ); + + const handleCellMouseEnter = useCallback( + (rowId: string, columnId: string) => { + if (isDraggingRef.current) { + interactionSourceRef.current = 'mouse'; + applySelection(rowId, columnId, true); + } + }, + [applySelection], + ); + + // Mouse-down on a header cell makes it the keyboard-active cell so arrow nav continues from the header. + // Source 'mouse' so GridBody doesn't steal focus from the control the user actually clicked (sort/filter). + const handleHeaderCellMouseDown = useCallback( + (columnId: string) => { + interactionSourceRef.current = 'mouse'; + applySelection(HEADER_ROW_ID, columnId, false); + }, + [applySelection], + ); + + // Mouse-down on a summary cell makes it the keyboard-active cell so arrow nav continues from there. + // Source 'mouse' so the body's focus effect doesn't steal focus from the control the user clicked. + const handleSummaryCellMouseDown = useCallback( + (rowId: string, columnId: string) => { + interactionSourceRef.current = 'mouse'; + applySelection(rowId, columnId, false); + }, + [applySelection], + ); + + const copySelection = useCallback( + (includeHeader = false) => { + const rows = table.getRowModel().rows; + const columns = table.getVisibleLeafColumns(); + if (!activeCell || !rows.length || !columns.length) { + return; + } + const anchor = anchorCell ?? activeCell; + const activeRowIndex = rows.findIndex((row) => row.id === activeCell.rowId); + const anchorRowIndex = rows.findIndex((row) => row.id === anchor.rowId); + const activeColIndex = columns.findIndex((column) => column.id === activeCell.columnId); + const anchorColIndex = columns.findIndex((column) => column.id === anchor.columnId); + if (activeRowIndex < 0 || anchorRowIndex < 0 || activeColIndex < 0 || anchorColIndex < 0) { + return; + } + const minRow = Math.min(activeRowIndex, anchorRowIndex); + const maxRow = Math.max(activeRowIndex, anchorRowIndex); + const minCol = Math.min(activeColIndex, anchorColIndex); + const maxCol = Math.max(activeColIndex, anchorColIndex); + + // Build records keyed by synthetic field names (avoids dot-notation flattening on real column ids), + // then reuse copyRecordsToClipboard which writes BOTH text/html (a table) and an escaped text/plain + // Excel string — so it pastes into Excel/Sheets as a proper grid (handles tabs/newlines/quotes in cells). + const fields: string[] = []; + for (let colIndex = minCol; colIndex <= maxCol; colIndex++) { + fields.push(`c${colIndex}`); + } + const records: Record[] = []; + // "With header": prepend the selected columns' display names as the first row (kept as data, keyed by + // the same synthetic fields, so the existing dot-notation-safe copy path is reused unchanged). + if (includeHeader) { + const headerRecord: Record = {}; + for (let colIndex = minCol; colIndex <= maxCol; colIndex++) { + headerRecord[`c${colIndex}`] = headerText(columns[colIndex]); + } + records.push(headerRecord); + } + for (let rowIndex = minRow; rowIndex <= maxRow; rowIndex++) { + // Synthetic group header rows have no cell data — skip them so pasted output stays rectangular. + if (rows[rowIndex].getIsGrouped()) { + continue; + } + const record: Record = {}; + for (let colIndex = minCol; colIndex <= maxCol; colIndex++) { + record[`c${colIndex}`] = cellText(rows[rowIndex], columns[colIndex]); + } + records.push(record); + } + void copyRecordsToClipboard(records, 'excel', fields, false); + flashCells(getRootElement(), rows, columns, minRow, maxRow, minCol, maxCol); + + const copiedRowCount = records.length - (includeHeader ? 1 : 0); + const copiedColCount = fields.length; + onAnnounce?.( + `Copied ${copiedRowCount} ${copiedRowCount === 1 ? 'row' : 'rows'} by ${copiedColCount} ${ + copiedColCount === 1 ? 'column' : 'columns' + }`, + ); + }, + [activeCell, anchorCell, table, getRootElement, onAnnounce], + ); + + const handleKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + const rows = table.getRowModel().rows; + const columns = table.getVisibleLeafColumns(); + if (!rows.length || !columns.length) { + return; + } + interactionSourceRef.current = 'keyboard'; + + const current: ActiveCell = activeCell ?? { rowId: rows[0].id, columnId: columns[0].id }; + const rowIndex = Math.max( + 0, + rows.findIndex((row) => row.id === current.rowId), + ); + const colIndex = Math.max( + 0, + columns.findIndex((column) => column.id === current.columnId), + ); + const ctrlOrMeta = event.ctrlKey || event.metaKey; + const extend = event.shiftKey; + + // Mark a key as consumed by THIS grid. Stopping propagation prevents the event from bubbling + // through the React tree to an ancestor grid — without it, a nested grid (e.g. the subquery + // modal table) would also drive the underlying page's table, since React portals propagate + // synthetic events through the component tree rather than the DOM tree. + const consume = () => { + event.preventDefault(); + event.stopPropagation(); + }; + + // ── Actionable mode: Tab/Shift+Tab and Arrow Left/Right cycle the cell's controls (they're out of + // the page tab order, so we move focus ourselves and trap it within the cell); Up/Down are swallowed + // so they don't scroll the page; Escape returns focus to the cell. Other keys are the focused + // control's own behavior (e.g. Space/Enter toggles the focused checkbox or clicks the button). ── + if (mode === 'actionable') { + if (event.key === 'Escape') { + consume(); + setMode('navigation'); + // Pull focus off the in-cell control back onto the cell so navigation resumes from here. + if (activeCell) { + getCellElement(activeCell)?.focus(); + } + return; + } + const forward = event.key === 'Tab' ? !event.shiftKey : event.key === 'ArrowRight'; + const backward = event.key === 'Tab' ? event.shiftKey : event.key === 'ArrowLeft'; + if (forward || backward) { + if (!activeCell) { + return; + } + const cellEl = getCellElement(activeCell); + const controls = cellEl ? Array.from(cellEl.querySelectorAll(ACTIVATABLE_SELECTOR)) : []; + if (controls.length > 1) { + consume(); + const currentIndex = controls.findIndex( + (control) => control === document.activeElement || control.contains(document.activeElement), + ); + const nextIndex = (currentIndex + (backward ? -1 : 1) + controls.length) % controls.length; + controls[nextIndex].focus(); + } + return; + } + // Keep Up/Down from scrolling the page while interacting with the cell's controls. + if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { + consume(); + } + return; + } + + // ── Column header row (a virtual row above the body) ── + // Left/Right move between header cells, Down/Escape return to the body, Up is swallowed (already at + // the top), and Enter/Space/F2 activate the header's controls (sort / filter popover / select-all). + if (activeCell?.rowId === HEADER_ROW_ID) { + const headerColIndex = Math.max( + 0, + columns.findIndex((column) => column.id === activeCell.columnId), + ); + switch (event.key) { + case 'ArrowDown': + consume(); + // Step into the summary rows if present (column filters / bulk actions), else the body. + // keepDesiredCol: a vertical step must not overwrite the sticky column when snapping. + if (summaryRowCount > 0) { + applySelection(getSummaryRowId(0), columns[headerColIndex].id, false, true); + } else { + moveTo(0, headerColIndex, false, true); + } + break; + case 'Escape': + consume(); + // Escape jumps straight to the data (skipping the summary rows). moveTo handles group snapping. + moveTo(0, headerColIndex, false, true); + break; + case 'ArrowUp': + consume(); + break; + case 'ArrowRight': + consume(); + applySelection(HEADER_ROW_ID, columns[clamp(headerColIndex + 1, 0, columns.length - 1)].id, false); + break; + case 'ArrowLeft': + consume(); + applySelection(HEADER_ROW_ID, columns[clamp(headerColIndex - 1, 0, columns.length - 1)].id, false); + break; + case 'Home': + consume(); + applySelection(HEADER_ROW_ID, columns[0].id, false); + break; + case 'End': + consume(); + applySelection(HEADER_ROW_ID, columns[columns.length - 1].id, false); + break; + case 'Enter': + case 'F2': + // Let Cmd/Ctrl+Enter bubble to app-level handlers (e.g. save). + if (event.key === 'Enter' && ctrlOrMeta) { + break; + } + consume(); + // Enter prefers sort (matches clicking the header); a select-all column toggles its checkbox. + activateHeaderCell(columns[headerColIndex].id, 'sort'); + break; + case ' ': + consume(); + // Space prefers the filter popover (the control that's otherwise hard to reach by keyboard). + activateHeaderCell(columns[headerColIndex].id, 'filter'); + break; + default: + break; + } + return; + } + + // ── Pinned summary rows (filters / bulk actions, between the header and the body) ── + // Left/Right move between summary cells; Up/Down step through the summary stack into the header + // (above) or the body (below); Enter/Space/F2 activate the cell's controls (a single control + // clicks, multiple controls — e.g. select-all/none/reset — enter Actionable mode so Tab cycles + // them); Escape drops to the body. Range-extend (Shift) is intentionally not supported here. + if (activeCell && isSummaryRowId(activeCell.rowId)) { + const summaryIndex = getSummaryRowIndex(activeCell.rowId); + const summaryColIndex = Math.max( + 0, + columns.findIndex((column) => column.id === activeCell.columnId), + ); + switch (event.key) { + case 'ArrowRight': + consume(); + applySelection(activeCell.rowId, columns[clamp(summaryColIndex + 1, 0, columns.length - 1)].id, false); + break; + case 'ArrowLeft': + consume(); + applySelection(activeCell.rowId, columns[clamp(summaryColIndex - 1, 0, columns.length - 1)].id, false); + break; + case 'Home': + consume(); + applySelection(activeCell.rowId, columns[0].id, false); + break; + case 'End': + consume(); + applySelection(activeCell.rowId, columns[columns.length - 1].id, false); + break; + case 'ArrowDown': + consume(); + if (summaryIndex + 1 < summaryRowCount) { + applySelection(getSummaryRowId(summaryIndex + 1), columns[summaryColIndex].id, false, true); + } else { + moveTo(0, summaryColIndex, false, true); + } + break; + case 'ArrowUp': + consume(); + if (summaryIndex > 0) { + applySelection(getSummaryRowId(summaryIndex - 1), columns[summaryColIndex].id, false, true); + } else { + applySelection(HEADER_ROW_ID, columns[summaryColIndex].id, false, true); + } + break; + case 'Escape': + consume(); + moveTo(0, summaryColIndex, false, true); + break; + case 'Enter': + case 'F2': + case ' ': + // Let Cmd/Ctrl+Enter bubble to app-level handlers (e.g. save). + if (event.key === 'Enter' && ctrlOrMeta) { + break; + } + consume(); + // Summary cells are never editable — activate their controls directly (no edit path). + activateCell(activeCell); + break; + default: + break; + } + return; + } + + // ── Navigation mode ── + // Vertical moves target the sticky desired column (not the possibly-snapped current column), so + // passing through a group header or a spanned "no rows" row doesn't drag the user sideways. + const desiredCol = clamp(desiredColRef.current ?? colIndex, 0, columns.length - 1); + switch (event.key) { + case 'ArrowDown': + consume(); + moveTo(rowIndex + 1, desiredCol, extend, true); + break; + case 'ArrowUp': + consume(); + // From the first body row, Up enters the pinned summary rows (filters / bulk actions) if any, + // otherwise the column header row — so the keyboard can reach both. A range-extend (Shift) + // stays in the body. + if (rowIndex === 0 && !extend) { + applySelection(summaryRowCount > 0 ? getSummaryRowId(summaryRowCount - 1) : HEADER_ROW_ID, columns[desiredCol].id, false, true); + } else { + moveTo(rowIndex - 1, desiredCol, extend, true); + } + break; + case 'ArrowRight': { + consume(); + const currentRow = rows[rowIndex]; + if (currentRow?.getIsGrouped()) { + // Group header rows: step to the next group cell (segment). Arrows NEVER expand/collapse — + // Enter/Space on the chevron cell does that (and on the select-all cell toggles its checkbox). + const starts = getRowSegmentStarts(currentRow, columns); + const segmentIndex = Math.max(0, starts.filter((start) => start <= colIndex).length - 1); + applySelection(currentRow.id, columns[starts[clamp(segmentIndex + 1, 0, starts.length - 1)]].id, false); + } else if (!extend && currentRow?.getCanExpand() && !currentRow.getIsExpanded()) { + // Tree (real data row with children): Right expands a collapsed row. + currentRow.toggleExpanded(); + } else { + moveTo(rowIndex, colIndex + 1, extend); + } + break; + } + case 'ArrowLeft': { + consume(); + const currentRow = rows[rowIndex]; + if (currentRow?.getIsGrouped()) { + // Group header rows: step to the previous group cell (segment); no arrow-driven collapse. + const starts = getRowSegmentStarts(currentRow, columns); + const segmentIndex = Math.max(0, starts.filter((start) => start <= colIndex).length - 1); + applySelection(currentRow.id, columns[starts[clamp(segmentIndex - 1, 0, starts.length - 1)]].id, false); + } else if (colIndex === 0 && !extend && currentRow?.getCanExpand() && currentRow.getIsExpanded()) { + // Tree: collapse an expanded row at the first column. + currentRow.toggleExpanded(); + } else if (colIndex === 0 && !extend && currentRow && currentRow.depth > 0) { + // Jump from a nested/grouped child row to its parent (group/tree header) row. + const parent = currentRow.getParentRow(); + const parentIndex = parent ? rows.findIndex((row) => row.id === parent.id) : -1; + moveTo(parentIndex >= 0 ? parentIndex : rowIndex, parentIndex >= 0 ? colIndex : colIndex - 1, extend); + } else { + moveTo(rowIndex, colIndex - 1, extend); + } + break; + } + case 'Home': + consume(); + moveTo(ctrlOrMeta ? 0 : rowIndex, 0, extend); + break; + case 'End': + consume(); + moveTo(ctrlOrMeta ? rows.length - 1 : rowIndex, columns.length - 1, extend); + break; + case 'PageDown': + consume(); + moveTo(rowIndex + PAGE_SIZE, desiredCol, extend, true); + break; + case 'PageUp': + consume(); + moveTo(rowIndex - PAGE_SIZE, desiredCol, extend, true); + break; + case 'a': + case 'A': + if (ctrlOrMeta) { + consume(); + // 'select-all' suppresses scroll-into-view/focus of the new active corner — selecting + // everything must not jump the viewport to the bottom-right of the grid. + interactionSourceRef.current = 'select-all'; + setActiveCellState({ rowId: rows[rows.length - 1].id, columnId: columns[columns.length - 1].id }); + setAnchorCell({ rowId: rows[0].id, columnId: columns[0].id }); + } + break; + case ' ': + // Always consume Space so it can never scroll the virtualized body (which would unmount the + // active row and drop focus out of the grid). Activate the cell's content only once the user + // is actually in the grid; modified Space is left to the browser / assistive tech. + consume(); + if (activeCell && !ctrlOrMeta && !event.altKey) { + activateCell(current); + } + break; + case 'Enter': + case 'F2': + // Let Cmd/Ctrl+Enter bubble to app-level handlers (e.g. "save edited records") instead of + // treating it as edit/activate. + if (event.key === 'Enter' && ctrlOrMeta) { + break; + } + consume(); + applySelection(current.rowId, current.columnId, false); + // Editable cells open their editor; otherwise activate the cell's content (toggle a checkbox / + // open a popover), falling back to Actionable mode for multi-control cells. Group header cells + // are never editable — go straight to activation (chevron cell toggles, select-all cell checks). + if (rows[rowIndex]?.getIsGrouped() || !(onRequestEdit && onRequestEdit(current))) { + activateCell(current); + } + break; + case 'c': + case 'C': + if (ctrlOrMeta) { + event.stopPropagation(); + copySelection(); + } + break; + default: + break; + } + }, + [ + activeCell, + mode, + table, + moveTo, + applySelection, + copySelection, + onRequestEdit, + activateCell, + activateHeaderCell, + getCellElement, + summaryRowCount, + ], + ); + + return { + activeCell, + anchorCell, + mode, + setActiveCell, + handleKeyDown, + handleRootFocus, + handleRootBlur, + handleCellMouseDown, + handleCellMouseEnter, + handleHeaderCellMouseDown, + handleSummaryCellMouseDown, + copySelection, + getLastInteractionSource: () => interactionSourceRef.current, + }; +} + +function flashCells( + root: HTMLElement | null, + rows: Row[], + columns: Column[], + minRow: number, + maxRow: number, + minCol: number, + maxCol: number, +) { + if (!root) { + return; + } + // Only the virtualized window is in the DOM, so walk the MOUNTED cells and test range membership — + // never querySelector per (row × col) pair, which freezes the tab on a Ctrl+A over a large result set. + const rowIdsInRange = new Set(); + for (let rowIndex = minRow; rowIndex <= maxRow; rowIndex++) { + rowIdsInRange.add(rows[rowIndex].id); + } + const colIdsInRange = new Set(); + for (let colIndex = minCol; colIndex <= maxCol; colIndex++) { + colIdsInRange.add(columns[colIndex].id); + } + const flashed: Element[] = []; + root.querySelectorAll('[data-row-id][data-col-id]').forEach((cellEl) => { + const rowId = cellEl.getAttribute('data-row-id'); + const colId = cellEl.getAttribute('data-col-id'); + if (rowId && colId && rowIdsInRange.has(rowId) && colIdsInRange.has(colId)) { + cellEl.classList.add('copied'); + flashed.push(cellEl); + } + }); + setTimeout(() => flashed.forEach((cellEl) => cellEl.classList.remove('copied')), 600); +} diff --git a/libs/ui/src/lib/data-table/grid/rdg-compat.ts b/libs/ui/src/lib/data-table/grid/rdg-compat.ts new file mode 100644 index 000000000..eb3e02b33 --- /dev/null +++ b/libs/ui/src/lib/data-table/grid/rdg-compat.ts @@ -0,0 +1,64 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * Compatibility shims replacing the `react-data-grid` types/values that feature code imported directly. + * At cutover, those imports are repointed from `'react-data-grid'` to `'@jetstream/ui'`, which re-exports + * these. The runtime renderers receive the new grid's `DataTableCellProps` etc., so these are aliases. + */ +import { + ColumnWithFilter, + DataTableCellProps, + DataTableEditorProps, + DataTableGroupCellProps, + DataTableHeaderProps, + DataTableSummaryCellProps, +} from './grid-types'; + +export { SELECT_COLUMN_KEY } from './grid-constants'; +export type { SortColumn } from './grid-types'; +export { SelectColumn } from './renderers/CellRenderers'; + +export type Column = ColumnWithFilter; +export type CalculatedColumn = ColumnWithFilter & { readonly idx?: number }; + +export type RenderCellProps = DataTableCellProps; +export type RenderGroupCellProps = DataTableGroupCellProps; +export type RenderHeaderCellProps = DataTableHeaderProps; +export type RenderSummaryCellProps = DataTableSummaryCellProps; +export type RenderEditCellProps = DataTableEditorProps; + +export interface RowsChangeData { + indexes: number[]; + column: ColumnWithFilter; +} + +export interface CellMouseArgs { + row: TRow; + column: ColumnWithFilter; + rowIdx?: number; + selectCell?: () => void; +} + +export interface CellKeyDownArgs { + row: TRow; + column: ColumnWithFilter; + rowIdx: number; + mode?: 'SELECT' | 'EDIT'; +} + +export type CellKeyboardEvent = React.KeyboardEvent & { preventGridDefault: () => void; isGridDefaultPrevented: () => boolean }; +export type CellMouseEvent = React.MouseEvent & { preventGridDefault: () => void }; + +export interface RowHeightArgs { + type: 'ROW' | 'GROUP'; + row: TRow; +} + +export interface RenderSortStatusProps { + sortDirection: 'ASC' | 'DESC' | undefined; + priority: number | undefined; +} + +export interface Renderers { + renderSortStatus?: (props: RenderSortStatusProps) => React.ReactNode; + renderCell?: (key: React.Key, props: DataTableCellProps) => React.ReactNode; +} diff --git a/libs/ui/src/lib/data-table/grid/renderers/CellRenderers.tsx b/libs/ui/src/lib/data-table/grid/renderers/CellRenderers.tsx new file mode 100644 index 000000000..da35ac4e5 --- /dev/null +++ b/libs/ui/src/lib/data-table/grid/renderers/CellRenderers.tsx @@ -0,0 +1,301 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { css } from '@emotion/react'; +import { isValidSalesforceRecordId } from '@jetstream/shared/ui-utils'; +import { getIdFromRecordUrl } from '@jetstream/shared/utils'; +import { CloneEditView, SalesforceOrgUi } from '@jetstream/types'; +import lodashGet from 'lodash/get'; +import isBoolean from 'lodash/isBoolean'; +import isFunction from 'lodash/isFunction'; +import isString from 'lodash/isString'; +import { Fragment, ReactNode, useContext, useState } from 'react'; +import Checkbox from '../../../form/checkbox/Checkbox'; +import Modal from '../../../modal/Modal'; +import { Popover } 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 { dataTableDateFormatter } from '../../data-table-formatters'; +import { ACTION_COLUMN_KEY, SELECT_COLUMN_KEY } from '../grid-constants'; +import { GridGenericContext } from '../grid-context'; +import { getRowId, getSfdcRetUrl } from '../grid-row-utils'; +import { ColumnWithFilter, DataTableCellProps, DataTableGroupCellProps, RowWithKey } from '../grid-types'; + +/** + * Cell renderers ported from the legacy DataTableRenderers to the new `DataTableCellProps` contract. + * Salesforce org/serverUrl/onRecordAction now come from GridGenericContext instead of module globals. + */ + +interface RecordActionContext { + org?: SalesforceOrgUi; + serverUrl?: string; + skipFrontdoorLogin?: boolean; + onRecordAction?: (action: CloneEditView, recordId: string, sobjectName: string) => void; +} + +export function GenericRenderer(props: DataTableCellProps): ReactNode { + const { column, row } = props; + if (!row) { + return
; + } + let value: any = row[column.key]; + if (value instanceof Date) { + value = dataTableDateFormatter(value); + } else if (isBoolean(value)) { + return ; + } else if (value && typeof value === 'object') { + return ; + } + return
{value}
; +} + +export function BooleanRenderer({ column, row }: DataTableCellProps): ReactNode { + const value = row[column.key]; + return ( + + ); +} + +export function ValueOrLoadingRenderer({ column, row }: DataTableCellProps): ReactNode { + if (!row) { + return
; + } + const value = (row as Record)[column.key]; + if (row.loading) { + return ; + } + return
{value}
; +} + +export function ComplexDataRenderer({ column, row }: DataTableCellProps): ReactNode { + const value = row[column.key]; + const [isActive, setIsActive] = useState(false); + const [jsonValue] = useState(JSON.stringify(value || '', null, 2)); + + return ( +
+ {isActive && ( + setIsActive(false)} + footer={} + > +
+            {jsonValue}
+          
+
+ )} + +
+ ); +} + +export function IdLinkRenderer({ column, row }: DataTableCellProps): ReactNode { + const { org, serverUrl, skipFrontdoorLogin, onRecordAction } = useContext(GridGenericContext) as RecordActionContext; + const recordId = row[column.key]; + const { skipFrontDoorAuth, url } = getSfdcRetUrl(row, recordId, skipFrontdoorLogin); + return ( + + ); +} + +export function NameLinkRenderer({ column, row }: DataTableCellProps): ReactNode { + const { org, serverUrl, skipFrontdoorLogin, onRecordAction } = useContext(GridGenericContext) as RecordActionContext; + const nameValue = row[column.key]; + 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}} + /> + ); +} + +export function TextOrIdLinkRenderer(props: DataTableCellProps): ReactNode { + const { column, row } = props; + const { org } = useContext(GridGenericContext) as RecordActionContext; + if (!row) { + return
; + } + const maybeSalesforceId = row[column.key]; + if (org && isString(maybeSalesforceId) && maybeSalesforceId.length === 18 && isValidSalesforceRecordId(maybeSalesforceId, false)) { + return ; + } + return GenericRenderer(props); +} + +export function ActionRenderer({ row }: DataTableCellProps): ReactNode { + if (!isFunction(row?._action)) { + return null; + } + const isDeleted = !!row.IsDeleted; + return ( + + + + + + + + + + + + {isDeleted ? ( + + + + ) : ( + + + + )} + + + + + ); +} + +export function ErrorMessageRenderer({ row }: { row: any }): ReactNode { + if (!row?._saveError) { + return null; + } + return ( + +
+
+ +
+
+

+ Save Error +

+
+
+ + } + content={ +
+

{row._saveError}

+
+ } + buttonProps={{ className: 'slds-button slds-button_icon slds-button_icon-error', tabIndex: -1 }} + > + +
+ ); +} + +/** Row-selection checkbox renderer. The built-in select column (GridCell cellKind) usually handles this; + * kept for compatibility with the spreadable SelectColumn definition. */ +export function SelectFormatter({ row, tanstackRow }: DataTableCellProps): ReactNode { + return ( + tanstackRow.toggleSelected(checked)} + /> + ); +} + +/** Group-row "select all" checkbox — selects/deselects every leaf row in the group. */ +export function SelectHeaderGroupRenderer({ childRows, tanstackRow }: DataTableGroupCellProps): ReactNode { + const allSelected = tanstackRow.getIsAllSubRowsSelected(); + const someSelected = tanstackRow.getIsSomeSelected(); + return ( + + tanstackRow.toggleSelected(checked)} + /> + + ); +} + +/** Spreadable row-selection column definition (replaces react-data-grid's `SelectColumn`). */ +export const SelectColumn: ColumnWithFilter = { + key: SELECT_COLUMN_KEY, + name: '', + width: 40, + minWidth: 40, + maxWidth: 40, + resizable: false, + sortable: false, + frozen: true, + renderCell: SelectFormatter, + renderGroupCell: SelectHeaderGroupRenderer, +}; + +export { ACTION_COLUMN_KEY, SELECT_COLUMN_KEY }; diff --git a/libs/ui/src/lib/data-table/grid/renderers/SubqueryRenderer.tsx b/libs/ui/src/lib/data-table/grid/renderers/SubqueryRenderer.tsx new file mode 100644 index 000000000..4931312f5 --- /dev/null +++ b/libs/ui/src/lib/data-table/grid/renderers/SubqueryRenderer.tsx @@ -0,0 +1,301 @@ +/* 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 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 { DataTableV2 } from '../DataTableV2'; +import { copySalesforceRecordTableDataToClipboard } from '../grid-clipboard'; +import { NON_DATA_COLUMN_KEYS, TABLE_CONTEXT_MENU_ITEMS } from '../grid-constants'; +import { GridSubqueryContext } from '../grid-context'; +import { getRowId, getSubqueryModalTagline } from '../grid-row-utils'; +import { ColumnWithFilter, ContextAction, ContextMenuActionData, DataTableCellProps, RowWithKey, SubqueryContext } from '../grid-types'; + +/** Subquery cell — shows "N Records" and opens a modal with a nested data table (load-more + export). */ +export const SubqueryRenderer = ({ column, row }: DataTableCellProps): 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); + // Guards against re-entry while a queryMore is in flight (the loading state hasn't re-rendered yet). + const isLoadingMoreRef = useRef(false); + const [queryResults, setQueryResults] = useState>(row[column.key] || {}); + + const { records, nextRecordsUrl } = queryResults; + + useEffect(() => { + isMounted.current = true; + return () => { + isMounted.current = false; + }; + }, []); + + const handleRowAction = useCallback(() => undefined, []); + + 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) { + // Guard before the try so the finally only runs for calls that actually start a load. + if (!nextRecordsUrl || isLoadingMoreRef.current) { + return; + } + isLoadingMoreRef.current = true; + setIsLoadingMore(true); + try { + const results = await queryMore(org, nextRecordsUrl, isTooling); + if (isMounted.current) { + // Functional update so the append always merges onto the latest records, not a stale closure. + setQueryResults((prev) => ({ ...results.queryResults, records: [...prev.records, ...results.queryResults.records] })); + } + } catch { + // Query errors surface via the shared data layer; just stop the spinner here. + } finally { + isLoadingMoreRef.current = false; + if (isMounted.current) { + setIsLoadingMore(false); + } + } + } + + if (!Array.isArray(records) || records.length === 0) { + return
; + } + + return ( + + {(props) => { + if (!props) { + return null; + } + const columns = props.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; + downloadModalIsActive: boolean; + 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; +} + +function ModalDataTable({ + isActive, + columnKey, + columns, + modalTagline, + queryResults, + isLoadingMore, + isTooling, + org, + downloadModalIsActive, + serverUrl, + skipFrontdoorLogin, + hasGoogleDriveAccess, + googleShowUpgradeToPro, + google_apiKey, + google_appId, + google_clientId, + onSubqueryFieldReorder, + loadMore, + openDownloadModal, + handleCloseModal, + handleCopyToClipboard, + handleRowAction, +}: ModalDataTableProps) { + const { records, done, totalSize } = queryResults; + + const { fields: initialFields, 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) => ({ + _key: getRowId(row), + _action: handleRowAction, + _record: row, + ...(columnKeys ? flattenRecord(row, columnKeys, false) : row), + })); + return { fields, rows }; + }, [columns, handleRowAction, records]); + + const [fields, setFields] = useState(initialFields); + useEffect(() => { + setFields(initialFields); + }, [initialFields]); + + const handleContextMenuAction = useCallback( + (item: ContextMenuItem, data: ContextMenuActionData) => { + copySalesforceRecordTableDataToClipboard(item.value as ContextAction, fields, data); + }, + [fields], + ); + + const handleColumnReorder = useCallback( + (reordered: string[], columnOrder: number[]) => { + setFields(reordered); + onSubqueryFieldReorder?.(columnKey, reordered, columnOrder); + }, + [columnKey, onSubqueryFieldReorder], + ); + + return ( + <> + {isActive && ( + (ev.preventDefault(), ev.stopPropagation()) }}> + + + Showing {formatNumber(records.length)} of {formatNumber(totalSize)} records + + {!done && ( + + )} + {isLoadingMore && } + +
+ + +
+ + } + > +
(ev.preventDefault(), ev.stopPropagation())}> + + { + if (action === 'view') { + appActionObservable.next({ action: 'VIEW_RECORD', payload: { recordId, objectName } }); + } else if (action === 'edit') { + appActionObservable.next({ action: 'EDIT_RECORD', payload: { recordId, objectName } }); + } + }, + }} + /> + +
+
+ )} + {downloadModalIsActive && ( + {}} + /> + )} + + ); +} diff --git a/libs/ui/src/lib/data-table/grid/renderers/TreeExpander.tsx b/libs/ui/src/lib/data-table/grid/renderers/TreeExpander.tsx new file mode 100644 index 000000000..8a7c506cd --- /dev/null +++ b/libs/ui/src/lib/data-table/grid/renderers/TreeExpander.tsx @@ -0,0 +1,70 @@ +import { css } from '@emotion/react'; +import classNames from 'classnames'; +import { ReactNode } from 'react'; +import Icon from '../../../widgets/Icon'; + +/** Rem of indentation applied per tree depth level. */ +const INDENT_PER_LEVEL_REM = 1.25; + +export interface TreeExpanderProps { + /** Row depth from `DataTableCellProps.depth` (0 = root). Drives indentation. */ + depth: number; + /** From `DataTableCellProps.canExpand` — whether to render a chevron toggle vs. a spacer. */ + canExpand: boolean; + /** From `DataTableCellProps.isExpanded`. */ + isExpanded: boolean; + /** From `DataTableCellProps.toggleExpanded`. */ + onToggle: () => void; + /** The cell content rendered to the right of the (indented) expander. */ + children: ReactNode; +} + +/** + * Standard expand/collapse affordance for a `getSubRows` tree column. Renders depth-based indentation + * plus either a chevron toggle (expandable rows) or an aligned spacer (leaf rows), so every tree table + * in the app shares one look instead of hand-rolling indentation/chevrons per feature. + * + * Compose it inside a column's `renderCell`, forwarding the tree sugar from `DataTableCellProps`: + * + * renderCell: ({ value, depth, canExpand, isExpanded, toggleExpanded }) => ( + * + * {value} + * + * ) + */ +export function TreeExpander({ depth, canExpand, isExpanded, onToggle, children }: TreeExpanderProps) { + return ( +
+ {canExpand ? ( + + ) : ( +
+ ); +} diff --git a/libs/ui/src/lib/data-table/useDataTable.tsx b/libs/ui/src/lib/data-table/useDataTable.tsx deleted file mode 100644 index 0ee1b30d1..000000000 --- a/libs/ui/src/lib/data-table/useDataTable.tsx +++ /dev/null @@ -1,575 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { IconName } from '@jetstream/icon-factory'; -import { logger } from '@jetstream/shared/client-logger'; -import { hasCtrlOrMeta, isArrowKey, isCKey, isEnterKey, isTabKey, isVKey, useNonInitialEffect } from '@jetstream/shared/ui-utils'; -import { orderObjectsBy, orderValues } from '@jetstream/shared/utils'; -import { ContextMenuItem, SalesforceOrgUi } from '@jetstream/types'; -import copyToClipboard from 'copy-to-clipboard'; -import isArray from 'lodash/isArray'; -import isNil from 'lodash/isNil'; -import isObject from 'lodash/isObject'; -import uniqueId from 'lodash/uniqueId'; -import { useCallback, useEffect, useImperativeHandle, useMemo, useReducer, useState } from 'react'; -import { - CellKeyDownArgs, - CellKeyboardEvent, - CellMouseArgs, - CellMouseEvent, - RenderSortStatusProps, - Renderers, - RowsChangeData, - SortColumn, -} from 'react-data-grid'; -import 'react-data-grid/lib/styles.css'; -import Icon from '../widgets/Icon'; -import { configIdLinkRenderer, dataTableRenderFnMap } from './DataTableRenderers'; -import './data-table-styles.css'; -import { ColumnWithFilter, ContextMenuActionData, DataTableFilter, DataTableRef, FILTER_SET_TYPES, RowWithKey } from './data-table-types'; -import { EMPTY_FIELD, NON_DATA_COLUMN_KEYS, filterRecord, getSearchTextByRow, isFilterActive, resetFilter } from './data-table-utils'; - -export interface UseDataTableProps { - data: any[]; - columns: ColumnWithFilter[]; - serverUrl?: string; - skipFrontdoorLogin?: boolean; - org?: SalesforceOrgUi; - quickFilterText?: string | null; - includeQuickFilter?: boolean; - // context?: TContext; - /** Must be stable to avoid constant re-renders */ - contextMenuItems?: ContextMenuItem[]; - initialSortColumns?: SortColumn[]; - ref: any; - /** Must be stable to avoid constant re-renders */ - contextMenuAction?: (item: ContextMenuItem, data: ContextMenuActionData) => void; - getRowKey: (row: any) => string; - rowAlwaysVisible?: (row: any) => boolean; - ignoreRowInSetFilter?: (row: any) => boolean; - onReorderColumns?: (columns: string[], columnOrder: number[]) => void; - onSortedAndFilteredRowsChange?: (rows: readonly any[]) => void; -} - -export function useDataTable({ - data, - columns: _columns, - serverUrl, - skipFrontdoorLogin, - org, - quickFilterText, - includeQuickFilter, - contextMenuItems, - initialSortColumns, - ref, - contextMenuAction, - getRowKey, - ignoreRowInSetFilter, - rowAlwaysVisible, - onReorderColumns, - onSortedAndFilteredRowsChange, -}: UseDataTableProps) { - const [gridId] = useState(() => uniqueId('grid-')); - const [columns, setColumns] = useState(_columns || []); - const [sortColumns, setSortColumns] = useState(() => initialSortColumns || []); - const [rowFilterText, setRowFilterText] = useState>({}); - const [renderers] = useState>(() => ({ renderSortStatus })); - const [columnsOrder, setColumnsOrder] = useState((): readonly number[] => columns.map((_, index) => index)); - const [contextMenuProps, setContextMenuProps] = useState<{ - rowIdx: number; - column: ColumnWithFilter; - top: number; - left: number; - element: HTMLElement; - } | null>(null); - - const reorderedColumns = useMemo(() => { - return columnsOrder.map((index) => columns[index]); - }, [columns, columnsOrder]); - - const [{ columnMap, filters, filterSetValues }, dispatch] = useReducer(reducer, { - hasFilters: false, - columnMap: new Map(), - filters: {}, - filterSetValues: {}, - }); - - useEffect(() => { - dispatch({ type: 'INIT', payload: { columns: _columns, data, ignoreRowInSetFilter } }); - }, [_columns, data, ignoreRowInSetFilter]); - - useNonInitialEffect(() => { - setColumns(_columns); - setColumnsOrder(_columns.map((_, index) => index)); - }, [_columns]); - - useNonInitialEffect(() => { - if (onReorderColumns) { - const newColumns = reorderedColumns.filter((column) => !NON_DATA_COLUMN_KEYS.has(column.key)).map(({ key }) => key); - const remainingIdx = new Set( - reorderedColumns.map((column, i) => (NON_DATA_COLUMN_KEYS.has(column.key) ? -1 : i)).filter((idx) => idx >= 0), - ); - const offset = reorderedColumns.length - newColumns.length; - onReorderColumns( - newColumns, - columnsOrder.filter((idx) => remainingIdx.has(idx)).map((index) => index - offset), - ); - } - }, [reorderedColumns, columnsOrder, onReorderColumns]); - - useEffect(() => { - if (Array.isArray(columns) && columns.length && Array.isArray(data) && data.length) { - setRowFilterText(getSearchTextByRow(data, columns, getRowKey)); - } else { - setRowFilterText({}); - } - }, [columns, data, getRowKey]); - - const updateFilter = useCallback((column: string, filter: DataTableFilter) => { - dispatch({ type: 'UPDATE_FILTER', payload: { column, filter } }); - }, []); - - const sortedRows = useMemo((): readonly RowWithKey[] => { - if (sortColumns.length === 0) { - return data; - } - - return orderObjectsBy( - data, - sortColumns.map(({ columnKey }) => columnKey) as any, - sortColumns.map(({ direction }) => (direction === 'ASC' ? 'asc' : 'desc')), - ); - }, [data, sortColumns]); - - const filteredRows = useMemo((): readonly RowWithKey[] => { - const quickFilterNeedle = includeQuickFilter && quickFilterText ? quickFilterText.toLowerCase() : null; - return sortedRows.filter((row) => { - if (rowAlwaysVisible && rowAlwaysVisible(row)) { - return true; - } - const isVisible = Object.keys(filters) - .filter( - (columnKey) => - Array.isArray(filters[columnKey]) && - filters[columnKey].length && - filters[columnKey].some((filter) => isFilterActive(filter, sortedRows.length)), - ) - .every((columnKey) => { - let rowValue = row[columnKey]; - const column = columnMap.get(columnKey); - if (column?.getValue && column) { - rowValue = column.getValue({ row, column }); - } - return filters[columnKey] - .filter((filter) => isFilterActive(filter, sortedRows.length)) - .every((filter) => { - const filterResult = filterRecord(filter, rowValue); - return filterResult; - }); - }); - // Apply global filter - const key = getRowKey(row); - if (quickFilterNeedle && key && rowFilterText[key]) { - return isVisible && rowFilterText[key].includes(quickFilterNeedle); - } - return isVisible; - }); - }, [columnMap, filters, getRowKey, includeQuickFilter, quickFilterText, rowAlwaysVisible, rowFilterText, sortedRows]); - - useEffect(() => { - onSortedAndFilteredRowsChange && onSortedAndFilteredRowsChange(filteredRows); - }, [filteredRows, onSortedAndFilteredRowsChange]); - - function handleReorderColumns(sourceKey: string, targetKey: string) { - setColumnsOrder((columnsOrder) => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const sourceColumnOrderIndex = columnsOrder.findIndex((index) => columns[index].key === sourceKey)!; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const targetColumnOrderIndex = columnsOrder.findIndex((index) => columns[index].key === targetKey)!; - const sourceColumnOrder = columnsOrder[sourceColumnOrderIndex]; - const newColumnsOrder = columnsOrder.toSpliced(sourceColumnOrderIndex, 1); - newColumnsOrder.splice(targetColumnOrderIndex, 0, sourceColumnOrder); - return newColumnsOrder; - }); - } - - /** - * Handle Enter key on IdLinkRenderer - clicks the button inside - */ - function handleIdLinkRendererKeydown(event: CellKeyboardEvent): boolean { - if (isEnterKey(event)) { - (event.target as HTMLElement)?.querySelector('button')?.click(); - event.preventGridDefault(); - return true; - } - return false; - } - - /** - * Handle Tab navigation within ActionRenderer cells - * Using keyboard navigation with tab key, focus on each button in the action cell forwards or backwards. - * the arrow keys can be used without this inner cell navigation. - */ - function handleActionRendererTabNavigation(event: CellKeyboardEvent): boolean { - if (!isTabKey(event)) { - return false; - } - - const element = event.target as HTMLElement; - const cell = element.closest('.rdg-cell') as HTMLElement; - - if (!cell) { - return false; - } - - // Check if the current focused element is a button inside the cell - const isButtonFocused = element.tagName === 'BUTTON' && cell.contains(element); - const buttons = Array.from(cell.querySelectorAll('button')); - const currentButtonIndex = buttons.indexOf(element as HTMLButtonElement); - - if (event.shiftKey) { - // Handle shift+tab (backwards navigation) - if (!isButtonFocused) { - // We're entering the cell from the right, focus the last button - event.preventGridDefault(); - const lastButton = buttons[buttons.length - 1] as HTMLButtonElement; - if (lastButton) { - setTimeout(() => lastButton.focus(), 0); - } - return true; - } else if (currentButtonIndex > 0) { - // We're on a button but not the first, prevent default to stay in cell - event.preventGridDefault(); - return true; - } - // If on first button, let grid handle navigation to previous cell - } else { - // Handle regular tab (forward navigation) - if (!isButtonFocused) { - // We're entering the cell from the left, focus the first button - event.preventGridDefault(); - const firstButton = buttons[0] as HTMLButtonElement; - if (firstButton) { - setTimeout(() => firstButton.focus(), 0); - } - return true; - } else if (currentButtonIndex < buttons.length - 1) { - // We're on a button but not the last, prevent default to stay in cell - event.preventGridDefault(); - return true; - } - // If on last button, let grid handle navigation to next cell - } - - return false; - } - - /** - * Handle custom copy to clipboard - */ - function handleCustomCopyToClipboard(args: CellKeyDownArgs, event: CellKeyboardEvent) { - if (hasCtrlOrMeta(event) && isCKey(event)) { - const column = args.column as ColumnWithFilter; - let value = (args.row as any)[column.key]; - - if (isArray(value) || isObject(value)) { - value = JSON.stringify(value); - } - - if (!isNil(value)) { - copyToClipboard(value).then((copied) => { - if (copied) { - // Flash the cell to indicate copy success - const cell = (event.target as HTMLElement).closest('.rdg-cell'); - if (cell) { - cell.classList.add('copied'); - setTimeout(() => { - cell.classList.remove('copied'); - }, 1000); - } - } - }); - } - } - } - - /** - * For columns that have edit mode, by default any keypress will enable edit mode which breaks things like ctrl+c to copy. - * Aside from some specific use-cases, we disable the event from being handled by the grid. - */ - function handleCellKeydown(args: CellKeyDownArgs, event: CellKeyboardEvent) { - try { - // Handle renderer-specific keyboard interactions - if (dataTableRenderFnMap.get(args.column.renderCell) === 'IdLinkRenderer' && handleIdLinkRendererKeydown(event)) { - return; - } - - if (dataTableRenderFnMap.get(args.column.renderCell) === 'ActionRenderer' && handleActionRendererTabNavigation(event)) { - return; - } - - // Allow certain keys to be handled by the grid - if (isArrowKey(event) || isEnterKey(event) || isTabKey(event) || (hasCtrlOrMeta(event) && isVKey(event))) { - return; - } - - // Handle custom copy to clipboard - handleCustomCopyToClipboard(args, event); - - // Prevent all other keys from triggering edit mode - event.preventGridDefault(); - } catch (ex) { - logger.warn('handleCellKeydown Error', ex); - event.preventGridDefault(); - return; - } - } - - const handleCellContextMenu = useCallback( - ({ row, column }: CellMouseArgs, event: CellMouseEvent) => { - // Avoid showing the custom context menu if Ctrl, or Meta is pressed - if (event.ctrlKey || event.metaKey) { - return; - } - if (!event.currentTarget.contains(event.target as Node)) { - return; - } - event.preventGridDefault(); - // Do not show the default context menu - event.preventDefault(); - setContextMenuProps(null); - // the second menu closes upon opening - ensure open happens in next render - setTimeout(() => { - setContextMenuProps({ - rowIdx: filteredRows.indexOf(row as any), - column: column as any, - top: event.clientY, - left: event.clientX, - element: event.currentTarget, - }); - }); - }, - [filteredRows], - ); - - const handleRowChange = useCallback((rows: any[], data: RowsChangeData) => { - dispatch({ type: 'ADD_MODIFIED_VALUE_TO_SET_FILTER', payload: { rows, data } }); - }, []); - - const handleCloseContextMenu = useCallback(() => setContextMenuProps(null), []); - - // NOTE: this is not used anywhere, so we may consider removing it. - useImperativeHandle>(ref, () => { - return { - hasSortApplied: () => sortColumns?.length > 0, - getFilteredAndSortedRows: () => filteredRows, - hasReorderedColumns: () => columnsOrder.some((idx, i) => idx !== i), - getCurrentColumns: () => columnsOrder.map((idx) => columns[idx]).filter((column) => !NON_DATA_COLUMN_KEYS.has(column.key)), - getCurrentColumnNames: () => - columnsOrder - .map((idx) => columns[idx]) - .filter((column) => !NON_DATA_COLUMN_KEYS.has(column.key)) - .map(({ key }) => key), - }; - }, [columns, columnsOrder, filteredRows, sortColumns?.length]); - - if (serverUrl && org) { - configIdLinkRenderer(serverUrl, org, skipFrontdoorLogin); - } - return { - gridId, - columns, - sortColumns, - rowFilterText, - filters, - renderers, - columnsOrder, - reorderedColumns, - filterSetValues, - filteredRows, - contextMenuProps, - setSortColumns, - updateFilter, - handleReorderColumns, - handleCellKeydown, - handleCellContextMenu: contextMenuItems && contextMenuAction ? handleCellContextMenu : undefined, - handleCloseContextMenu, - handleRowChange, - }; -} - -/** - * SUPPORTING FUNCTIONS - */ - -function renderSortStatus({ sortDirection, priority }: RenderSortStatusProps) { - const iconName: IconName = sortDirection === 'ASC' ? 'arrowup' : 'arrowdown'; - return sortDirection !== undefined ? ( - <> - - {priority} - - ) : null; -} - -interface State { - hasFilters: boolean; - columnMap: Map>; - filters: Record; - filterSetValues: Record; -} - -type Action = - | { type: 'INIT'; payload: { columns: ColumnWithFilter[]; data: any[]; ignoreRowInSetFilter?: (row: any) => boolean } } - | { type: 'ADD_MODIFIED_VALUE_TO_SET_FILTER'; payload: { rows: any[]; data: RowsChangeData } } - | { type: 'UPDATE_FILTER'; payload: { column: string; filter: DataTableFilter } }; - -// Reducer is used to limit the number of re-renders because of dependent state -function reducer(state: State, action: Action): State { - switch (action.type) { - case 'INIT': { - const { columns, data, ignoreRowInSetFilter } = action.payload; - - const columnMap = new Map(columns.map((column) => [column.key, column])); - - // If we have existing filters, we retain their values - // If the column count changed, then we will reset to avoid having to diff everything - const hasFilters = state.hasFilters && columnMap.size === state.columnMap.size; - - // Retain filter values if filters are already applied - const filters = hasFilters - ? structuredClone(state.filters) - : columns.reduce((acc: Record, column) => { - if (Array.isArray(column.filters)) { - acc[column.key] = column.filters.map((filter) => resetFilter(filter, [])); - } - return acc; - }, {}); - - // NOTICE: This function mutates filters - const filterSetValues = Object.keys(filters) - .filter((columnKey) => Array.isArray(filters[columnKey]) && filters[columnKey].some(({ type }) => FILTER_SET_TYPES.has(type))) - .reduce((acc: Record, columnKey) => { - const filter = filters[columnKey].find(({ type }) => FILTER_SET_TYPES.has(type)); - const column = columnMap.get(columnKey); - const getValueFn = columnMap.get(columnKey)?.getValue || (({ row, column }) => row[columnKey]); - if (!filter || !column) { - return acc; - } - if (filter.type === 'BOOLEAN_SET') { - acc[columnKey] = ['True', 'False']; - } else { - acc[columnKey] = orderValues( - Array.from( - new Set( - data - .filter((row) => (ignoreRowInSetFilter ? !ignoreRowInSetFilter(row) : true)) - .map((row) => { - const rowValue = getValueFn({ row, column }); - // TODO: we need some additional function to get the filter value and also compare the value when filtering - return isNil(rowValue) ? EMPTY_FIELD : String(rowValue); - }), - ), - ), - ); - } - - // Set filter default to all values as selected - // If the user had modified the set filter previously, retain the selections without auto-selecting the new set values - if ( - !hasFilters || - !filter.value || - !state?.filterSetValues?.[columnKey] || - filter.value.length === state.filterSetValues?.[columnKey].length - ) { - filter.value = acc[columnKey]; - } - - return acc; - }, {}); - - return { - ...state, - columnMap, - filters, - filterSetValues, - }; - } - case 'ADD_MODIFIED_VALUE_TO_SET_FILTER': { - const { - data: { column, indexes }, - rows, - } = action.payload; - if (!state.filters[column.key]) { - return state; - } - const newValues = indexes.map((index) => { - const value = rows[index][column.key]; - if (value === '' || value === null) { - return EMPTY_FIELD; - } - return value; - }); - // NOTE: we don't have access to every record here, so we just add the values and don't worry about removing on subsequent record change - // Calculate new list of available values - const filterSetValues = { - ...state.filterSetValues, - [column.key]: Array.from(new Set([...state.filterSetValues[column.key], ...newValues])), - }; - // ensure that current values are included in the set filter so they are retained on the page while editing - const columnFilter = [...state.filters[column.key]].map((item) => { - if (item.type !== 'SET') { - return item; - } - return { - ...item, - value: Array.from(new Set(item.value.concat(newValues))), - }; - }); - return { - ...state, - filterSetValues, - filters: { - ...state.filters, - [column.key]: columnFilter, - }, - }; - } - case 'UPDATE_FILTER': { - const { column, filter } = action.payload; - const filters = { - ...state.filters, - [column]: state.filters[column].map((currFilter) => (currFilter.type === filter.type ? filter : currFilter)), - }; - const hasFilters = hasFilterApplied(filters, state.filterSetValues); - return { - ...state, - hasFilters, - filters, - }; - } - } -} - -function hasFilterApplied(filters: Record, filterSetValues: Record) { - return Object.entries(filters).some(([key, columnFilters]) => - columnFilters.some((filter) => { - let applied = false; - switch (filter.type) { - case 'SET': - applied = filter.value.length < (filterSetValues[key]?.length || 0); - break; - case 'BOOLEAN_SET': - applied = filter.value.length < 2; // true/false - break; - case 'DATE': - case 'NUMBER': - case 'TEXT': - case 'TIME': - applied = !!filter.value; - break; - default: - return false; - } - return applied; - }), - ); -} diff --git a/libs/ui/src/lib/form/checkbox/Checkbox.tsx b/libs/ui/src/lib/form/checkbox/Checkbox.tsx index f8c5943fe..2ac61e535 100644 --- a/libs/ui/src/lib/form/checkbox/Checkbox.tsx +++ b/libs/ui/src/lib/form/checkbox/Checkbox.tsx @@ -16,6 +16,8 @@ export interface CheckboxProps { helpText?: React.ReactNode | string; disabled?: boolean; readOnly?: boolean; + /** Override the input's tab order — pass -1 to remove it from the page tab order (e.g. inside a data-table cell). */ + tabIndex?: number; hasError?: boolean; isRequired?: boolean; isStandAlone?: boolean; @@ -43,6 +45,7 @@ export const Checkbox: FunctionComponent = ({ errorMessage, disabled = false, readOnly = false, + tabIndex, hideLabel = false, isStandAlone = false, onChange, @@ -94,6 +97,7 @@ export const Checkbox: FunctionComponent = ({ checked={checked || false} disabled={readOnly || disabled} readOnly={readOnly} + tabIndex={tabIndex} aria-describedby={errorMessageId} onChange={(event) => { onChange && onChange(event.target?.checked || false); diff --git a/libs/ui/src/lib/form/context-menu/ContextMenu.tsx b/libs/ui/src/lib/form/context-menu/ContextMenu.tsx index f0a832c2f..ab6ffcebd 100644 --- a/libs/ui/src/lib/form/context-menu/ContextMenu.tsx +++ b/libs/ui/src/lib/form/context-menu/ContextMenu.tsx @@ -18,6 +18,7 @@ import isNumber from 'lodash/isNumber'; import isString from 'lodash/isString'; import React, { Fragment, FunctionComponent, KeyboardEvent, RefObject, createRef, useEffect, useRef, useState } from 'react'; import Grid from '../../grid/Grid'; +import { usePortalContext } from '../../modal/PortalContext'; import Icon from '../../widgets/Icon'; import { KeyboardShortcut, getModifierKey } from '../../widgets/KeyboardShortcut'; @@ -34,6 +35,7 @@ export interface ContextMenuProps { * It is a popover component that is positioned relative to the parentElement. */ export const ContextMenu: FunctionComponent = ({ parentElement, actionText = 'action', items, onClose, onSelected }) => { + const { isInPortal, portalRoot } = usePortalContext(); const keyBuffer = useRef(new KeyBuffer()); const [focusedItem, setFocusedItem] = useState(null); @@ -92,6 +94,33 @@ export const ContextMenu: FunctionComponent = ({ parentElement } }, [focusedItem]); + /** + * When rendered inside a modal, floating-ui's `useDismiss` outside-press detection treats clicks + * within the modal as a "third-party element injected after" the menu (because the modal marks all + * sibling elements with `data-floating-ui-inert`) and refuses to dismiss. Mirror the Popover's manual + * outside-press handler so the menu still closes on any outside click while in a modal portal. + */ + useEffect(() => { + if (!isInPortal) { + return; + } + + const handleOutsideClick = (event: MouseEvent) => { + const target = event.target as Node | null; + const floatingElement = refs.floating.current; + if (floatingElement && target && !floatingElement.contains(target)) { + onClose(); + } + }; + + // Use capture phase to ensure we catch the event before the modal handles it + document.addEventListener('mousedown', handleOutsideClick, true); + + return () => { + document.removeEventListener('mousedown', handleOutsideClick, true); + }; + }, [isInPortal, onClose, refs.floating]); + useEffect(() => { if (!isNumber(focusedItem)) { if (selectedItem) { @@ -160,7 +189,7 @@ export const ContextMenu: FunctionComponent = ({ parentElement } return ( - +
void; } @@ -34,6 +37,7 @@ export const RecordLookupPopover: FunctionComponent = returnUrl, isTooling, displayValue, + removeFromTabOrder, onRecordAction, }) => { const isMounted = useRef(true); @@ -237,7 +241,7 @@ export const RecordLookupPopover: FunctionComponent = ) : undefined } - buttonProps={{ className: 'slds-button' }} + buttonProps={{ className: 'slds-button', ...(removeFromTabOrder ? { tabIndex: -1 } : {}) }} > { diff --git a/package.json b/package.json index f4d7d8862..ad2a7d0a8 100644 --- a/package.json +++ b/package.json @@ -293,6 +293,7 @@ "@socket.io/cluster-adapter": "^0.3.0", "@socket.io/component-emitter": "3.1.2", "@socket.io/sticky": "^1.0.4", + "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.24", "axios": "^1.18.0", "bcryptjs": "^3.0.3", @@ -353,7 +354,6 @@ "postcss-import": "^12.0.1", "qrcode": "^1.5.4", "react": "^19.2.7", - "react-data-grid": "7.0.0-beta.56", "react-dom": "^19.2.7", "react-email": "^6.6.0", "react-error-boundary": "^6.1.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a0203b6c3..2fccdc9b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -325,6 +325,9 @@ importers: '@socket.io/sticky': specifier: ^1.0.4 version: 1.0.4 + '@tanstack/react-table': + specifier: ^8.21.3 + version: 8.21.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tanstack/react-virtual': specifier: ^3.13.24 version: 3.13.24(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -505,9 +508,6 @@ importers: react: specifier: ^19.2.7 version: 19.2.7 - react-data-grid: - specifier: 7.0.0-beta.56 - version: 7.0.0-beta.56(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react-dom: specifier: ^19.2.7 version: 19.2.7(react@19.2.7) @@ -4875,6 +4875,9 @@ packages: '@maxim_mazurok/gapi.client.drive-v3@0.2.20260602': resolution: {integrity: sha512-BNDVj7gVop5Q4rx0hvAfHxjhACShaQrYB1Zmd8fJeNAszGpLK3zRoggqPGtehdYW3O72fmsHmEsWV89QObuAgw==} + '@maxim_mazurok/gapi.client.drive-v3@0.2.20260615': + resolution: {integrity: sha512-4vJVLZTQDfWWRbsBBye+qDNdf9/nr4pEtXgSr8loci2MfDMsf0gBimGVCDtElsLF3q+fA7C/MNU9QNN4g8dI7w==} + '@mdn/browser-compat-data@8.0.2': resolution: {integrity: sha512-bT8u6Ll/vuV8qC9tn8cqvxoAtBpKcCwhKMW51qJRK9+G3JUtAx3lc1wjij4GJzGCfqA0keRbJUFetCgwwYaskA==} @@ -7282,12 +7285,23 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || >=4.0.0 || insiders' + '@tanstack/react-table@8.21.3': + resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} + engines: {node: '>=12'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + '@tanstack/react-virtual@3.13.24': resolution: {integrity: sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/table-core@8.21.3': + resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} + engines: {node: '>=12'} + '@tanstack/virtual-core@3.14.0': resolution: {integrity: sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==} @@ -9241,10 +9255,6 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} - clsx@2.0.0: - resolution: {integrity: sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==} - engines: {node: '>=6'} - clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -15409,12 +15419,6 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true - react-data-grid@7.0.0-beta.56: - resolution: {integrity: sha512-sET7KFAP2oMz3LlXXg4J6fb3EzHKIckVaXuV+uyx5JRHjPoEDaAc4yrDc6usVeCi//QSZvUX85S0AS/E3uiaMw==} - peerDependencies: - react: ^19.0 - react-dom: ^19.0 - react-dom@19.2.6: resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==} peerDependencies: @@ -23980,6 +23984,11 @@ snapshots: '@types/gapi.client': 1.0.8 '@types/gapi.client.discovery-v1': 0.0.4 + '@maxim_mazurok/gapi.client.drive-v3@0.2.20260615': + dependencies: + '@types/gapi.client': 1.0.8 + '@types/gapi.client.discovery-v1': 0.0.4 + '@mdn/browser-compat-data@8.0.2': {} '@mdx-js/mdx@3.1.1': @@ -26792,12 +26801,20 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.2.1 + '@tanstack/react-table@8.21.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/table-core': 8.21.3 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + '@tanstack/react-virtual@3.13.24(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@tanstack/virtual-core': 3.14.0 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) + '@tanstack/table-core@8.21.3': {} + '@tanstack/virtual-core@3.14.0': {} '@testing-library/dom@10.4.1': @@ -27031,7 +27048,7 @@ snapshots: '@types/gapi.client.drive-v3@0.0.5': dependencies: - '@maxim_mazurok/gapi.client.drive-v3': 0.2.20260602 + '@maxim_mazurok/gapi.client.drive-v3': 0.2.20260615 '@types/gapi.client@1.0.8': {} @@ -29301,8 +29318,6 @@ snapshots: clone@1.0.4: {} - clsx@2.0.0: {} - clsx@2.1.1: {} co@4.6.0: {} @@ -37127,12 +37142,6 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 - react-data-grid@7.0.0-beta.56(react-dom@19.2.7(react@19.2.7))(react@19.2.7): - dependencies: - clsx: 2.0.0 - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - react-dom@19.2.6(react@19.2.6): dependencies: react: 19.2.6