diff --git a/examples/src/pages/tests/table/master-detail/api.page.tsx b/examples/src/pages/tests/table/master-detail/api.page.tsx new file mode 100644 index 00000000..96dc0eab --- /dev/null +++ b/examples/src/pages/tests/table/master-detail/api.page.tsx @@ -0,0 +1,253 @@ +import * as React from 'react'; + +import { + DataSourceData, + InfiniteTable, + InfiniteTablePropColumns, + DataSource, + RowDetailStateObject, + InfiniteTableApi, + RowDetailState, +} from '@infinite-table/infinite-react'; + +type Developer = { + id: number; + firstName: string; + lastName: string; + + city: string; + currency: string; + country: string; + preferredLanguage: string; + stack: string; + canDesign: 'yes' | 'no'; + + salary: number; +}; + +type City = { + id: number; + name: string; + country: string; +}; + +const masterColumns: InfiniteTablePropColumns = { + id: { + field: 'id', + header: 'ID', + defaultWidth: 70, + type: 'number', + renderRowDetailIcon: true, + }, + country: { field: 'country', header: 'Country' }, + city: { field: 'name', header: 'City', defaultFlex: 1 }, +}; + +const detailColumns: InfiniteTablePropColumns = { + firstName: { + field: 'firstName', + header: 'First Name', + }, + salary: { + field: 'salary', + type: 'number', + }, + + stack: { field: 'stack' }, + currency: { field: 'currency' }, + city: { field: 'city' }, +}; + +const domProps = { + style: { + height: '100%', + }, +}; + +function renderDetail() { + return ( + + data={detailDataSource} + primaryKey="id" + sortMode="remote" + filterMode="remote" + > + + columnDefaultWidth={150} + columnMinWidth={50} + columns={detailColumns} + /> + + ); +} + +export default () => { + const [rowDetailState, setRowDetailState] = React.useState< + RowDetailStateObject + >({ + collapsedRows: true as const, + expandedRows: [], + }); + + const onRowDetailStateChange = React.useCallback( + (rowDetailState: RowDetailState) => { + setRowDetailState(rowDetailState.getState()); + }, + [], + ); + + const [api, setApi] = React.useState | null>(null); + + return ( + <> +
+ +
Row detail state: {JSON.stringify(rowDetailState, null, 2)}
+
+
+ + +
+
+ + data={citiesDataSource} + primaryKey="id" + defaultSortInfo={[ + { + field: 'country', + dir: 1, + }, + { + field: 'name', + dir: 1, + }, + ]} + > + + domProps={domProps} + onReady={({ api }) => { + setApi(api); + }} + columnDefaultWidth={150} + rowDetailCache={false} + rowDetailState={rowDetailState} + onRowDetailStateChange={onRowDetailStateChange} + columnMinWidth={50} + columns={masterColumns} + rowDetailRenderer={renderDetail} + /> + + + ); +}; + +// fetch an array of cities from the server +const citiesDataSource: DataSourceData = () => { + const cityNames = new Set(); + const result: City[] = []; + return fetch(process.env.NEXT_PUBLIC_BASE_URL + `/developers1k-sql`) + .then((response) => response.json()) + .then((response) => { + response.data.forEach((data: Developer) => { + if (cityNames.has(data.city)) { + return; + } + cityNames.add(data.city); + result.push({ + name: data.city, + country: data.country, + id: result.length, + }); + }); + + return result; + }); +}; + +const detailDataSource: DataSourceData = ({ + filterValue, + sortInfo, + masterRowInfo, +}) => { + if (sortInfo && !Array.isArray(sortInfo)) { + sortInfo = [sortInfo]; + } + + if (!filterValue) { + filterValue = []; + } + if (masterRowInfo) { + // filter by master country and city + filterValue = [ + { + field: 'city', + filter: { + operator: 'eq', + type: 'string', + value: masterRowInfo.data.name, + }, + }, + { + field: 'country', + filter: { + operator: 'eq', + type: 'string', + value: masterRowInfo.data.country, + }, + }, + ...filterValue, + ]; + } + const args = [ + sortInfo + ? 'sortInfo=' + + JSON.stringify( + sortInfo.map((s) => ({ + field: s.field, + dir: s.dir, + })), + ) + : null, + + filterValue + ? 'filterBy=' + + JSON.stringify( + filterValue.map(({ field, filter }) => { + return { + field: field, + operator: filter.operator, + value: + filter.type === 'number' ? Number(filter.value) : filter.value, + }; + }), + ) + : null, + ] + .filter(Boolean) + .join('&'); + + return fetch(process.env.NEXT_PUBLIC_BASE_URL + `/developers1k-sql?` + args) + .then((r) => r.json()) + .then((data: Developer[]) => data); +}; diff --git a/examples/src/pages/tests/table/master-detail/detail-cmp.page.tsx b/examples/src/pages/tests/table/master-detail/detail-cmp.page.tsx new file mode 100644 index 00000000..bbf68080 --- /dev/null +++ b/examples/src/pages/tests/table/master-detail/detail-cmp.page.tsx @@ -0,0 +1,323 @@ +import * as React from 'react'; + +import { + DataSourceData, + InfiniteTable, + InfiniteTablePropColumns, + DataSource, + RowDetailStateObject, + InfiniteTableApi, + RowDetailState, + useMasterRowInfo, +} from '@infinite-table/infinite-react'; + +type Developer = { + id: number; + firstName: string; + lastName: string; + + city: string; + currency: string; + country: string; + preferredLanguage: string; + stack: string; + canDesign: 'yes' | 'no'; + + salary: number; +}; + +type City = { + id: number; + name: string; + country: string; +}; + +const masterColumns: InfiniteTablePropColumns = { + id: { + field: 'id', + header: 'ID', + defaultWidth: 70, + renderRowDetailIcon: true, + }, + country: { field: 'country', header: 'Country' }, + city: { field: 'name', header: 'City', defaultFlex: 1 }, +}; + +const detailColumns: InfiniteTablePropColumns = { + firstName: { + field: 'firstName', + header: 'First Name', + renderRowDetailIcon: true, + }, + salary: { + field: 'salary', + type: 'number', + }, + + stack: { field: 'stack' }, + currency: { field: 'currency' }, + city: { field: 'city' }, +}; + +const domProps = { + style: { + height: '100%', + }, +}; + +function RowDetail() { + const rowInfo = useMasterRowInfo()!; + if (!rowInfo) { + return null; + } + + return ( + + data={detailDataSource} + primaryKey="id" + sortMode="remote" + filterMode="remote" + defaultFilterValue={[]} + > + + columnDefaultWidth={150} + columnMinWidth={50} + columns={detailColumns} + getContextMenuItems={() => { + return [ + { + key: 'x', + label: 'child 1', + }, + { + key: 'y', + label: 'child 2', + }, + { + key: 'z', + label: 'child 3', + }, + { + key: 'a', + label: 'child 4', + }, + { + key: 'b', + label: 'child 5', + }, + { + key: 'c', + label: 'child 6', + }, + { + key: 'd', + label: 'child 7', + }, + { + key: 'e', + label: 'child 8', + }, + { + key: 'f', + label: 'child 9', + }, + { + key: 'g', + label: 'child 10', + }, + ]; + }} + components={{ + RowDetail, + }} + /> + + ); +} + +export default () => { + const [rowDetailState, setRowDetailState] = React.useState< + RowDetailStateObject + >({ + collapsedRows: true as const, + expandedRows: [39, 54], + }); + + const onRowDetailStateChange = React.useCallback( + (rowDetailState: RowDetailState) => { + setRowDetailState(rowDetailState.getState()); + }, + [], + ); + + const [api, setApi] = React.useState | null>(null); + + return ( + <> +
+ +
Row detail state: {JSON.stringify(rowDetailState, null, 2)}
+
+
+ + +
+
+ + debugId="x" + defaultFilterValue={[]} + data={citiesDataSource} + primaryKey="id" + filterMode="local" + defaultSortInfo={[ + { + field: 'country', + dir: 1, + }, + { + field: 'name', + dir: 1, + }, + ]} + > + + domProps={domProps} + onReady={({ api }) => { + setApi(api); + }} + columnDefaultWidth={150} + rowDetailState={rowDetailState} + onRowDetailStateChange={onRowDetailStateChange} + columnMinWidth={50} + rowDetailHeight={200} + columns={masterColumns} + getCellContextMenuItems={() => { + return [ + { + key: 'x', + label: 'Custom Menu Item', + onClick: () => { + alert('Custom Menu Item Clicked'); + }, + }, + ]; + }} + components={{ + RowDetail, + }} + /> + + + ); +}; + +// fetch an array of cities from the server +const citiesDataSource: DataSourceData = () => { + const cityNames = new Set(); + const result: City[] = []; + return fetch(process.env.NEXT_PUBLIC_BASE_URL + `/developers1k-sql`) + .then((response) => response.json()) + .then((response) => { + response.data.forEach((data: Developer) => { + if (cityNames.has(data.city)) { + return; + } + cityNames.add(data.city); + result.push({ + name: data.city, + country: data.country, + id: result.length, + }); + }); + + return result; + }); +}; + +const detailDataSource: DataSourceData = ({ + filterValue, + sortInfo, + masterRowInfo, +}) => { + if (sortInfo && !Array.isArray(sortInfo)) { + sortInfo = [sortInfo]; + } + + if (!filterValue) { + filterValue = []; + } + if (masterRowInfo) { + // filter by master country and city + filterValue = [ + { + field: 'city', + filter: { + operator: 'eq', + type: 'string', + value: masterRowInfo.data.name, + }, + }, + { + field: 'country', + filter: { + operator: 'eq', + type: 'string', + value: masterRowInfo.data.country, + }, + }, + ...filterValue, + ]; + } + const args = [ + sortInfo + ? 'sortInfo=' + + JSON.stringify( + sortInfo.map((s) => ({ + field: s.field, + dir: s.dir, + })), + ) + : null, + + filterValue + ? 'filterBy=' + + JSON.stringify( + filterValue.map(({ field, filter }) => { + return { + field: field, + operator: filter.operator, + value: + filter.type === 'number' ? Number(filter.value) : filter.value, + }; + }), + ) + : null, + ] + .filter(Boolean) + .join('&'); + + return fetch(process.env.NEXT_PUBLIC_BASE_URL + `/developers1k-sql?` + args) + .then((r) => r.json()) + .then((data: Developer[]) => data); +}; diff --git a/source/src/components/DataSource/index.tsx b/source/src/components/DataSource/index.tsx index b012ba06..521f20c9 100644 --- a/source/src/components/DataSource/index.tsx +++ b/source/src/components/DataSource/index.tsx @@ -35,6 +35,7 @@ import { DataSourceContextValue, DataSourceState, } from './types'; +import { InfiniteTableRowInfo } from '../InfiniteTable'; type DataSourceChildren = | React.ReactNode @@ -169,9 +170,19 @@ function DataSourceCmp({ function DataSource(props: DataSourceProps) { const masterContext = useMasterDetailContext(); + const isDetail = !!masterContext; + // when we are in a detail DataSource, we want to have a key + // dependent on the master row info + // since we dont want to recycle and reuse the DataSource of a detail row + // for the DataSource of another detail row (for example, when you scroll the DataGrid + // while having more details expanded) + // so making sure the key is unique for each detail row is important + // and mandatory to ensure correctness + const key = isDetail ? masterContext.masterRowInfo.id : 'master'; + return ( - - + + ); } @@ -183,6 +194,16 @@ function useRowInfoReducers() { return rowInfoReducerResults; } +function useMasterRowInfo(): InfiniteTableRowInfo | undefined { + const context = useMasterDetailContext(); + + if (!context) { + return undefined; + } + + return context.masterRowInfo as InfiniteTableRowInfo; +} + export { useDataSource, DataSource, @@ -192,6 +213,7 @@ export { multisort, defaultFilterTypes as filterTypes, useRowInfoReducers, + useMasterRowInfo, }; export * from './types'; diff --git a/source/src/components/DataSource/types.ts b/source/src/components/DataSource/types.ts index fa225ade..0f7ad512 100644 --- a/source/src/components/DataSource/types.ts +++ b/source/src/components/DataSource/types.ts @@ -27,6 +27,7 @@ import { ScrollStopInfo } from '../InfiniteTable/types/InfiniteTableProps'; import { InfiniteTablePropPivotGrandTotalColumnPosition, InfiniteTablePropPivotTotalColumnPosition, + InfiniteTableState, } from '../InfiniteTable/types/InfiniteTableState'; import { NonUndefined } from '../types/NonUndefined'; @@ -742,16 +743,18 @@ export interface DataSourceContextValue { getState: () => DataSourceState; assignState: (state: Partial>) => void; getDataSourceMasterContext: () => - | DataSourceMasterDetailContextValue + | DataSourceMasterDetailContextValue | undefined; componentState: DataSourceState; componentActions: DataSourceComponentActions; } -export interface DataSourceMasterDetailContextValue { +export interface DataSourceMasterDetailContextValue { registerDetail: (detail: DataSourceContextValue) => void; + getMasterState: () => InfiniteTableState; + getMasterDataSourceState: () => DataSourceState; shouldRestoreState: boolean; - masterRowInfo: InfiniteTableRowInfo; + masterRowInfo: InfiniteTableRowInfo; } export enum DataSourceActionType { diff --git a/source/src/components/InfiniteTable/components/InfiniteTableRow/useRegisterDetail.ts b/source/src/components/InfiniteTable/components/InfiniteTableRow/useRegisterDetail.ts index 8871fa4b..069767e5 100644 --- a/source/src/components/InfiniteTable/components/InfiniteTableRow/useRegisterDetail.ts +++ b/source/src/components/InfiniteTable/components/InfiniteTableRow/useRegisterDetail.ts @@ -15,6 +15,7 @@ import { } from '../../../../components/DataSource/state/getInitialState'; import { InfiniteTableRowInfo } from '../../types'; import { once } from '../../../../utils/DeepMap/once'; +import { useInfiniteTable } from '../../hooks/useInfiniteTable'; type UseRegisterDetailProps = { rowDetailsCache: RowDetailCache; @@ -110,10 +111,12 @@ export function useRegisterDetail(props: UseRegisterDetailProps) { componentActions: masterActions, } = useDataSourceContextValue(); + const { getState: getMasterState } = useInfiniteTable(); + const { currentRowCache, cacheCalledByRowDetailRenderer } = useCurrentRowCache(rowInfo.id, rowDetailsCache); - const masterDetailContextValue: DataSourceMasterDetailContextValue = + const masterDetailContextValue: DataSourceMasterDetailContextValue = useMemo(() => { const shouldRestoreState = getMasterDataSourceState().detailDataSourcesStateToRestore.has( @@ -182,10 +185,12 @@ export function useRegisterDetail(props: UseRegisterDetailProps) { return { registerDetail, shouldRestoreState, - } as DataSourceMasterDetailContextValue; + } as DataSourceMasterDetailContextValue; }, [rowInfo.id, currentRowCache]); - masterDetailContextValue.masterRowInfo = rowInfo as InfiniteTableRowInfo; + masterDetailContextValue.masterRowInfo = rowInfo; + masterDetailContextValue.getMasterDataSourceState = getMasterDataSourceState; + masterDetailContextValue.getMasterState = getMasterState; return { masterDetailContextValue, currentRowCache }; } diff --git a/source/src/components/InfiniteTable/hooks/useCellRendering.tsx b/source/src/components/InfiniteTable/hooks/useCellRendering.tsx index 3c2723e6..5cb59efa 100644 --- a/source/src/components/InfiniteTable/hooks/useCellRendering.tsx +++ b/source/src/components/InfiniteTable/hooks/useCellRendering.tsx @@ -19,6 +19,7 @@ import type { InfiniteTableComputedValues, InfiniteTableApi } from '../types'; import { useInfiniteTable } from './useInfiniteTable'; import { useYourBrain } from './useYourBrain'; import { InfiniteTableDetailRow } from '../components/InfiniteTableRow/InfiniteTableDetailRow'; +import { visibility } from '../utilities.css'; type CellRenderingParam = { computed: InfiniteTableComputedValues; @@ -273,7 +274,11 @@ export function useCellRendering( !isRowDetailsExpanded(rowInfo) || !computedRowSizeCacheForDetails ) { - return null; + // normally we would have returned `null` + // but if we return null, the headless renderer will lose track + // of the HTMLElement, and then when we scroll down and want + // to reuse the HTMLElement, it will be gone, and the rendering will break + return
; } const { rowDetailHeight, rowHeight } = diff --git a/source/src/components/InfiniteTable/hooks/useColumnFilterOperatorMenu.ts b/source/src/components/InfiniteTable/hooks/useColumnFilterOperatorMenu.ts index d914eb97..f9c01e13 100644 --- a/source/src/components/InfiniteTable/hooks/useColumnFilterOperatorMenu.ts +++ b/source/src/components/InfiniteTable/hooks/useColumnFilterOperatorMenu.ts @@ -1,5 +1,6 @@ import { useEffect } from 'react'; import { Rectangle } from '../../../utils/pageGeometry/Rectangle'; +import { useMasterDetailContext } from '../../DataSource/publicHooks/useDataSource'; import { useOverlay } from '../../hooks/useOverlay'; import { getFilterOperatorMenuForColumn } from '../utils/getFilterOperatorMenuForColumn'; @@ -9,13 +10,16 @@ const OFFSET = 10; export function useColumnFilterOperatorMenu() { const context = useInfiniteTable(); + const masterContext = useMasterDetailContext(); const { getState, actions } = context; const { showOverlay, portal: menuPortal, clearAll, } = useOverlay({ - portalContainer: false, + portalContainer: masterContext + ? masterContext.getMasterState().portalDOMRef.current + : false, }); useEffect(() => { diff --git a/source/src/components/InfiniteTable/hooks/useColumnMenu.ts b/source/src/components/InfiniteTable/hooks/useColumnMenu.ts index 45906d0f..af09197b 100644 --- a/source/src/components/InfiniteTable/hooks/useColumnMenu.ts +++ b/source/src/components/InfiniteTable/hooks/useColumnMenu.ts @@ -1,4 +1,5 @@ import { useEffect } from 'react'; +import { useMasterDetailContext } from '../../DataSource/publicHooks/useDataSource'; import { ShowOverlayFn, useOverlay } from '../../hooks/useOverlay'; import { MenuIconDataAttributes, @@ -67,13 +68,17 @@ function showMenuForColumn(options: { export function useColumnMenu() { const context = useInfiniteTable(); + + const masterContext = useMasterDetailContext(); const { getState, actions } = context; const { showOverlay, portal: menuPortal, clearAll, } = useOverlay({ - portalContainer: false, + portalContainer: masterContext + ? masterContext.getMasterState().portalDOMRef.current + : false, }); useEffect(() => { diff --git a/source/src/components/InfiniteTable/hooks/useContextMenu.ts b/source/src/components/InfiniteTable/hooks/useContextMenu.ts index 73ca531f..f7afa490 100644 --- a/source/src/components/InfiniteTable/hooks/useContextMenu.ts +++ b/source/src/components/InfiniteTable/hooks/useContextMenu.ts @@ -1,6 +1,7 @@ import { useEffect } from 'react'; import { AlignPositionOptions } from '../../../utils/pageGeometry/alignment'; import { Rectangle } from '../../../utils/pageGeometry/Rectangle'; +import { useMasterDetailContext } from '../../DataSource/publicHooks/useDataSource'; import { useOverlay } from '../../hooks/useOverlay'; import { getCellContextMenu, @@ -24,13 +25,16 @@ const ALIGN_POSITIONS: AlignPositionOptions['alignPosition'] = [ export function useCellContextMenu() { const context = useInfiniteTable(); + const masterContext = useMasterDetailContext(); const { getState, actions } = context; const { showOverlay, portal: menuPortal, clearAll, } = useOverlay({ - portalContainer: false, + portalContainer: masterContext + ? masterContext.getMasterState().portalDOMRef.current + : false, }); useEffect(() => { @@ -124,13 +128,16 @@ export function useCellContextMenu() { export function useTableContextMenu() { const context = useInfiniteTable(); + const masterContext = useMasterDetailContext(); const { getState, actions } = context; const { showOverlay, portal: menuPortal, clearAll, } = useOverlay({ - portalContainer: false, + portalContainer: masterContext + ? masterContext.getMasterState().portalDOMRef.current + : false, }); useEffect(() => { diff --git a/source/src/components/InfiniteTable/index.tsx b/source/src/components/InfiniteTable/index.tsx index e28a9771..cae5a12e 100644 --- a/source/src/components/InfiniteTable/index.tsx +++ b/source/src/components/InfiniteTable/index.tsx @@ -106,6 +106,8 @@ const InfiniteTableRoot = getComponentStateRoot({ export const InfiniteTableComponent = React.memo( function InfiniteTableComponent() { const context = useInfiniteTable(); + + const masterContext = useMasterDetailContext(); const { state: componentState, getComputed, computed, api } = context; const { componentState: { loading, dataArray }, @@ -224,6 +226,15 @@ export const InfiniteTableComponent = React.memo( const state = context.getState(); const target = event.target as HTMLElement; + if (!masterContext && (event as any)._from_row_detail) { + // originating from detail grid. + return; + } + + if (masterContext) { + (event as any)._from_row_detail = true; + } + const cell = selectParentUntil( target, getCellSelector(), @@ -258,6 +269,17 @@ export const InfiniteTableComponent = React.memo( state.contextMenu(param); }, []); + React.useEffect(() => { + // we can make this more elegant + // the main idea: + // if we are a detail grid, we want to use the master grid's portal + // so menus are rendered in the container of the top-most (master) grid - since we can + // have multiple levels of nesting + if (masterContext) { + portalDOMRef.current = + masterContext.getMasterState().portalDOMRef.current; + } + }, []); return (
{header ? ( @@ -354,6 +376,7 @@ function InfiniteTableContextProvider() { (globalThis as any).getState = getState; (globalThis as any).getComputed = getComputed; (globalThis as any).componentActions = componentActions; + (globalThis as any).masterBrain = componentState.brain; } const { diff --git a/source/src/components/InfiniteTable/state/getInitialState.ts b/source/src/components/InfiniteTable/state/getInitialState.ts index 5f85648a..dac72304 100644 --- a/source/src/components/InfiniteTable/state/getInitialState.ts +++ b/source/src/components/InfiniteTable/state/getInitialState.ts @@ -42,6 +42,7 @@ import { import { toMap } from '../utils/toMap'; import { computeColumnGroupsDepths } from './computeColumnGroupsDepths'; +import { getRowDetailRendererFromComponent } from './rowDetailRendererFromComponent'; function createRenderer(brain: MatrixBrain) { const renderer = new ReactHeadlessTableRenderer(brain); @@ -238,8 +239,6 @@ export const forwardProps = ( columnDefaultFilterable: 1, columnDefaultSortable: 1, - rowDetailRenderer: 1, - rowStyle: 1, cellStyle: 1, @@ -447,7 +446,20 @@ export const mapPropsToState = (params: { | RowDetailStateObject | undefined = undefined; - if (props.rowDetailRenderer) { + let rowDetailRenderer = props.rowDetailRenderer; + + const RowDetailComponent = props.components?.RowDetail; + if (!rowDetailRenderer && RowDetailComponent) { + let rowDetailRendererFromComponent = weakMap.get(RowDetailComponent); + + if (!rowDetailRendererFromComponent) { + rowDetailRendererFromComponent = + getRowDetailRendererFromComponent(RowDetailComponent); + weakMap.set(RowDetailComponent, rowDetailRendererFromComponent); + } + rowDetailRenderer = rowDetailRendererFromComponent; + } + if (rowDetailRenderer) { rowDetailState = props.rowDetailState || state.rowDetailState || @@ -486,11 +498,12 @@ export const mapPropsToState = (params: { } } - const isRowDetailEnabled = !props.rowDetailRenderer + const isRowDetailEnabled = !rowDetailRenderer ? false : props.isRowDetailEnabled || true; return { + rowDetailRenderer, rowDetailState: rowDetailState as RowDetailState | undefined, isRowDetailExpanded, isRowDetailEnabled, diff --git a/source/src/components/InfiniteTable/state/rowDetailRendererFromComponent.tsx b/source/src/components/InfiniteTable/state/rowDetailRendererFromComponent.tsx new file mode 100644 index 00000000..5f1c8b9f --- /dev/null +++ b/source/src/components/InfiniteTable/state/rowDetailRendererFromComponent.tsx @@ -0,0 +1,17 @@ +import * as React from 'react'; + +import { RowDetailCacheStorageForCurrentRow } from '../../DataSource/RowDetailCache'; +import { RowDetailCacheEntry } from '../../DataSource/state/getInitialState'; +import { NonUndefined } from '../../types/NonUndefined'; +import { InfiniteTablePropComponents, InfiniteTableRowInfo } from '../types'; + +export function getRowDetailRendererFromComponent( + RowDetail: NonUndefined, +) { + return ( + rowInfo: InfiniteTableRowInfo, + cache: RowDetailCacheStorageForCurrentRow, + ) => { + return ; + }; +} diff --git a/source/src/components/InfiniteTable/types/InfiniteTableProps.ts b/source/src/components/InfiniteTable/types/InfiniteTableProps.ts index 48ab3e6a..4cb1c03e 100644 --- a/source/src/components/InfiniteTable/types/InfiniteTableProps.ts +++ b/source/src/components/InfiniteTable/types/InfiniteTableProps.ts @@ -505,7 +505,11 @@ export type InfiniteTablePropPivotColumn< options: InfiniteTablePivotColumnGetterOptions, ) => InfiniteTablePivotColumnBase); -export type InfiniteTablePropComponents = { +export type RowDetailComponentProps = { + rowInfo: InfiniteTableRowInfo; + cache: RowDetailCacheStorageForCurrentRow; +}; +export type InfiniteTablePropComponents = { LoadMask?: ( props: LoadMaskProps & { children?: React.ReactNode | undefined }, ) => JSX.Element | null; @@ -514,6 +518,7 @@ export type InfiniteTablePropComponents = { props: MenuProps & { children?: React.ReactNode | undefined }, ) => JSX.Element | null; MenuIcon?: (props: MenuIconProps) => JSX.Element | null; + RowDetail?: (props: RowDetailComponentProps) => JSX.Element | null; }; export type ScrollStopInfo = { @@ -563,7 +568,7 @@ export interface InfiniteTableProps { pivotColumns?: InfiniteTableColumnsMap>; loadingText?: Renderable; - components?: InfiniteTablePropComponents; + components?: InfiniteTablePropComponents; viewportReservedWidth?: number; onViewportReservedWidthChange?: (viewportReservedWidth: number) => void; diff --git a/source/src/components/InfiniteTable/types/InfiniteTableState.ts b/source/src/components/InfiniteTable/types/InfiniteTableState.ts index ab1c9fe0..34e9e53e 100644 --- a/source/src/components/InfiniteTable/types/InfiniteTableState.ts +++ b/source/src/components/InfiniteTable/types/InfiniteTableState.ts @@ -152,7 +152,6 @@ export interface InfiniteTableMappedState { id: InfiniteTableProps['id']; debugId: InfiniteTableProps['debugId']; scrollTopKey: InfiniteTableProps['scrollTopKey']; - rowDetailRenderer: InfiniteTableProps['rowDetailRenderer']; multiSortBehavior: NonUndefined['multiSortBehavior']>; viewportReservedWidth: InfiniteTableProps['viewportReservedWidth']; resizableColumns: InfiniteTableProps['resizableColumns']; @@ -275,6 +274,8 @@ export interface InfiniteTableDerivedState { rowDetailState: RowDetailState | undefined; isRowDetailExpanded: InfiniteTableProps['isRowDetailExpanded'] | undefined; + rowDetailRenderer?: InfiniteTableProps['rowDetailRenderer']; + isRowDetailEnabled: | NonUndefined['isRowDetailEnabled']> | boolean; diff --git a/www/content/docs/learn/master-detail/master-detail-api-example.page.tsx b/www/content/docs/learn/master-detail/master-detail-api-example.page.tsx new file mode 100644 index 00000000..f48bb5a8 --- /dev/null +++ b/www/content/docs/learn/master-detail/master-detail-api-example.page.tsx @@ -0,0 +1,253 @@ +import * as React from 'react'; + +import { + DataSourceData, + InfiniteTable, + InfiniteTablePropColumns, + DataSource, + InfiniteTableRowInfo, + RowDetailStateObject, + InfiniteTableApi, + RowDetailState, +} from '@infinite-table/infinite-react'; + +type Developer = { + id: number; + firstName: string; + lastName: string; + + city: string; + currency: string; + country: string; + preferredLanguage: string; + stack: string; + canDesign: 'yes' | 'no'; + + salary: number; +}; + +type City = { + id: number; + name: string; + country: string; +}; + +const masterColumns: InfiniteTablePropColumns = { + id: { + field: 'id', + header: 'ID', + defaultWidth: 70, + renderRowDetailIcon: true, + }, + country: { field: 'country', header: 'Country' }, + city: { field: 'name', header: 'City', defaultFlex: 1 }, +}; + +const detailColumns: InfiniteTablePropColumns = { + firstName: { + field: 'firstName', + header: 'First Name', + }, + salary: { + field: 'salary', + type: 'number', + }, + + stack: { field: 'stack' }, + currency: { field: 'currency' }, + city: { field: 'city' }, +}; + +const domProps = { + style: { + height: '100%', + }, +}; + +function renderDetail(rowInfo: InfiniteTableRowInfo) { + console.log('rendering detail for master row', rowInfo.id); + return ( + + data={detailDataSource} + primaryKey="id" + sortMode="remote" + filterMode="remote" + > + + columnDefaultWidth={150} + columnMinWidth={50} + columns={detailColumns} + /> + + ); +} + +export default () => { + const [rowDetailState, setRowDetailState] = React.useState< + RowDetailStateObject + >({ + collapsedRows: true as const, + expandedRows: [39, 54], + }); + + const onRowDetailStateChange = React.useCallback( + (rowDetailState: RowDetailState) => { + setRowDetailState(rowDetailState.getState()); + }, + [], + ); + + const [api, setApi] = React.useState | null>(null); + + return ( + <> +
+ +
Row detail state: {JSON.stringify(rowDetailState, null, 2)}
+
+
+ + +
+
+ + data={citiesDataSource} + primaryKey="id" + defaultSortInfo={[ + { + field: 'country', + dir: 1, + }, + { + field: 'name', + dir: 1, + }, + ]} + > + + domProps={domProps} + onReady={({ api }) => { + setApi(api); + }} + columnDefaultWidth={150} + rowDetailState={rowDetailState} + onRowDetailStateChange={onRowDetailStateChange} + columnMinWidth={50} + columns={masterColumns} + rowDetailRenderer={renderDetail} + /> + + + ); +}; + +// fetch an array of cities from the server +const citiesDataSource: DataSourceData = () => { + const cityNames = new Set(); + const result: City[] = []; + return fetch(process.env.NEXT_PUBLIC_BASE_URL + `/developers1k-sql`) + .then((response) => response.json()) + .then((response) => { + response.data.forEach((data: Developer) => { + if (cityNames.has(data.city)) { + return; + } + cityNames.add(data.city); + result.push({ + name: data.city, + country: data.country, + id: result.length, + }); + }); + + return result; + }); +}; + +const detailDataSource: DataSourceData = ({ + filterValue, + sortInfo, + masterRowInfo, +}) => { + if (sortInfo && !Array.isArray(sortInfo)) { + sortInfo = [sortInfo]; + } + + if (!filterValue) { + filterValue = []; + } + if (masterRowInfo) { + // filter by master country and city + filterValue = [ + { + field: 'city', + filter: { + operator: 'eq', + type: 'string', + value: masterRowInfo.data.name, + }, + }, + { + field: 'country', + filter: { + operator: 'eq', + type: 'string', + value: masterRowInfo.data.country, + }, + }, + ...filterValue, + ]; + } + const args = [ + sortInfo + ? 'sortInfo=' + + JSON.stringify( + sortInfo.map((s) => ({ + field: s.field, + dir: s.dir, + })), + ) + : null, + + filterValue + ? 'filterBy=' + + JSON.stringify( + filterValue.map(({ field, filter }) => { + return { + field: field, + operator: filter.operator, + value: + filter.type === 'number' ? Number(filter.value) : filter.value, + }; + }), + ) + : null, + ] + .filter(Boolean) + .join('&'); + + return fetch(process.env.NEXT_PUBLIC_BASE_URL + `/developers1k-sql?` + args) + .then((r) => r.json()) + .then((data: Developer[]) => data); +}; diff --git a/www/content/docs/reference/row-detail-api/index.page.md b/www/content/docs/reference/row-detail-api/index.page.md index 91a6429c..caa8ad7d 100644 --- a/www/content/docs/reference/row-detail-api/index.page.md +++ b/www/content/docs/reference/row-detail-api/index.page.md @@ -32,12 +32,39 @@ See the [Infinite Table Column API page](/docs/reference/column-api) for the col > Collapses all row details. + + + + + +Some of the rows in the master DataGrid are expanded by default. + +You can collapse them via the Row Detail API. + + + +```ts file="$DOCS/learn/master-detail/master-detail-api-example.page.tsx" +``` + + > Expands all row details. + + + + +Click the `Expand All` button to expand all row details. + + + +```ts file="$DOCS/learn/master-detail/master-detail-api-example.page.tsx" +``` + +