Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 24 additions & 13 deletions src/components/Table/Table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useFiltering from './middlewares/filtering';
import useSearching from './middlewares/searching';
import useSelection from './middlewares/selection';
import useSorting from './middlewares/sorting';
import TableContext from './TableContext';
import type {TableContextValue} from './TableContext';
import type {TableHandle, TableMethods, TableProps} from './types';
import type {TableData, TableHandle, TableMethods, TableProps} from './types';

/**
* A composable table component that provides filtering, search, and sorting functionality.
Expand All @@ -32,12 +33,13 @@
* 1. **Filtering** - Applies dropdown filter selections
* 2. **Searching** - Applies search string filtering
* 3. **Sorting** - Sorts data by the active column
* 4. **Selection** - Applies row selection state & provides helpers for selection
*
* Each middleware transforms the data array and passes it to the next.
*
* ## Generic Type Parameters
*
* - `T` - The type of items in your data array
* - `DataType` - The type of items in your data array
* - `ColumnKey` - String literal union of valid column keys (e.g., `'name' | 'date'`)
* - `FilterKey` - String literal union of valid filter keys
*
Expand Down Expand Up @@ -129,7 +131,7 @@
* </Table>
* ```
*/
function Table<T, ColumnKey extends string = string, FilterKey extends string = string>({
function Table<DataType extends TableData, ColumnKey extends string = string, FilterKey extends string = string>({
ref,
title,
data = [],
Expand All @@ -140,28 +142,36 @@
isItemInSearch,
initialSortColumn,
children,
selectionEnabled,
...listProps
}: TableProps<T, ColumnKey, FilterKey>) {
}: TableProps<DataType, ColumnKey, FilterKey>) {
if (!columns || columns.length === 0) {
throw new Error('Table columns must be provided');
}

const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout();

const {middleware: filterMiddleware, currentFilters, methods: filterMethods} = useFiltering<T, FilterKey>({filters, isItemInFilter});
const {middleware: filterMiddleware, currentFilters, methods: filterMethods} = useFiltering<DataType, FilterKey>({filters, isItemInFilter});

const {middleware: searchMiddleware, activeSearchString, methods: searchMethods} = useSearching<T>({isItemInSearch});
const {middleware: searchMiddleware, activeSearchString, methods: searchMethods} = useSearching<DataType>({isItemInSearch});

const {middleware: sortMiddleware, activeSorting, methods: sortMethods} = useSorting<T, ColumnKey>({compareItems, initialSortColumn});
const {middleware: sortMiddleware, activeSorting, methods: sortMethods} = useSorting<DataType, ColumnKey>({compareItems, initialSortColumn});

const processedData = [filterMiddleware, searchMiddleware, sortMiddleware].reduce((acc, middleware) => middleware(acc), data);
const {middleware: selectionMiddleware, methods: selectionMethods} = useSelection<DataType>({data});

const listRef = useRef<FlashListRef<T>>(null);
// Apply the middleware
const filteredData = filterMiddleware(data);
const searchedData = searchMiddleware(filteredData);
const sortedData = sortMiddleware(searchedData);
const processedData = selectionMiddleware(sortedData);

const listRef = useRef<FlashListRef<DataType>>(null);

const tableMethods: TableMethods<ColumnKey, FilterKey> = {
...filterMethods,
...sortMethods,
...searchMethods,
...selectionMethods,
};

/**
Expand All @@ -175,9 +185,9 @@
return target[property as keyof typeof target];
}

return listRef.current?.[property as keyof FlashListRef<T>];
return listRef.current?.[property as keyof FlashListRef<DataType>];
},
}) as TableHandle<T, ColumnKey, FilterKey>;
}) as TableHandle<DataType, ColumnKey, FilterKey>;
});

const originalDataLength = data?.length ?? 0;
Expand All @@ -196,10 +206,11 @@
const isEmptyResult = processedData.length === 0 && originalDataLength > 0 && (hasSearchString || hasActiveFilters);

// eslint-disable-next-line react/jsx-no-constructed-context-values
const contextValue: TableContextValue<T, ColumnKey, FilterKey> = {
const contextValue: TableContextValue<DataType, ColumnKey, FilterKey> = {
title,
listRef,
listProps,
selectionEnabled,
processedData,
originalDataLength,
columns,
Expand All @@ -214,7 +225,7 @@
shouldUseNarrowTableLayout,
};

return <TableContext.Provider value={contextValue as unknown as TableContextValue<unknown, string>}>{children}</TableContext.Provider>;
return <TableContext.Provider value={contextValue as unknown as TableContextValue<DataType, string>}>{children}</TableContext.Provider>;

Check failure on line 228 in src/components/Table/Table.tsx

View workflow job for this annotation

GitHub Actions / typecheck

Type 'TableContextValue<DataType, string>' is not assignable to type 'TableContextValue<TableData, string, string>'.
}

export default Table;
1 change: 1 addition & 0 deletions src/components/Table/TableBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
function TableBody<T>({contentContainerStyle, ...props}: TableBodyProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {processedData: filteredAndSortedData, activeSearchString, listProps, hasActiveFilters, hasSearchString, isEmptyResult} = useTableContext<T>();

Check failure on line 50 in src/components/Table/TableBody.tsx

View workflow job for this annotation

GitHub Actions / typecheck

Type 'T' does not satisfy the constraint 'TableData'.
const {ListEmptyComponent, contentContainerStyle: listContentContainerStyle, ...restListProps} = listProps ?? {};

// Determine the message based on what caused the empty result
Expand Down Expand Up @@ -83,6 +83,7 @@
>
<FlashList<T>
data={filteredAndSortedData}
showsVerticalScrollIndicator={false}
ListEmptyComponent={isEmptyResult ? EmptyResultComponent : ListEmptyComponent}
contentContainerStyle={[filteredAndSortedData.length === 0 && styles.flex1, listContentContainerStyle, contentContainerStyle]}
keyboardShouldPersistTaps="handled"
Expand Down
25 changes: 15 additions & 10 deletions src/components/Table/TableContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,30 @@
import React, {createContext, useContext} from 'react';
import type {FilterConfig} from './middlewares/filtering';
import type {ActiveSorting} from './middlewares/sorting';
import type {SharedListProps, TableColumn, TableMethods} from './types';
import type {SharedListProps, TableColumn, TableData, TableMethods, TableRowData} from './types';

/**
* The shape of the Table context value.
* This context is provided by the `<Table>` component and consumed by its sub-components.
*
* @template T - The type of items in the table's data array.
* @template DataType - The type of items in the table's data array.
* @template ColumnKey - A string literal type representing the valid column keys.
*/
type TableContextValue<T, ColumnKey extends string = string, FilterKey extends string = string> = {
type TableContextValue<DataType extends TableData, ColumnKey extends string = string, FilterKey extends string = string> = {
/** The title of the table when shown on smaller screens. */
title?: string;

/** Reference to the underlying FlashList for programmatic control. */
listRef: React.RefObject<FlashListRef<T> | null>;
listRef: React.RefObject<FlashListRef<DataType> | null>;

/** FlashList props passed through from the Table component. */
listProps: SharedListProps<T>;
listProps: SharedListProps<DataType>;

/** Whether or not selection is enabled for the table */
selectionEnabled?: boolean;

/** The data array after filtering, searching, and sorting have been applied. */
processedData: T[];
processedData: TableRowData<DataType>[];

Check failure on line 28 in src/components/Table/TableContext.tsx

View workflow job for this annotation

GitHub Actions / ESLint check

Array type using 'T[]' is forbidden for non-simple types. Use 'Array<T>' instead

/** The original length of the data array before any processing. */
originalDataLength: number;
Expand Down Expand Up @@ -58,7 +61,7 @@
shouldUseNarrowTableLayout: boolean;
};

const defaultTableContextValue: TableContextValue<unknown, string> = {
const defaultTableContextValue: TableContextValue<TableData, string> = {
listRef: React.createRef(),
processedData: [],
originalDataLength: 0,
Expand All @@ -71,11 +74,13 @@
activeSearchString: '',
tableMethods: {} as TableMethods<string, string>,
filterConfig: undefined,
listProps: {} as SharedListProps<unknown>,
listProps: {} as SharedListProps<TableData>,
hasActiveFilters: false,
hasSearchString: false,
isEmptyResult: false,
shouldUseNarrowTableLayout: false,
handleShiftRowSelection: () => {},

Check failure on line 82 in src/components/Table/TableContext.tsx

View workflow job for this annotation

GitHub Actions / typecheck

Object literal may only specify known properties, and 'handleShiftRowSelection' does not exist in type 'TableContextValue<TableData, string, string>'.
handleRowSelection: () => {},
};

const TableContext = createContext(defaultTableContextValue);
Expand All @@ -97,14 +102,14 @@
* }
* ```
*/
function useTableContext<T, ColumnKey extends string = string>() {
function useTableContext<DataType extends TableData, ColumnKey extends string = string>() {
const context = useContext(TableContext);

if (context === defaultTableContextValue && context.activeFilters === undefined) {
throw new Error('useTableContext must be used within a Table provider');
}

return context as unknown as TableContextValue<T, ColumnKey>;
return context as unknown as TableContextValue<DataType, ColumnKey>;
}

export default TableContext;
Expand Down
44 changes: 33 additions & 11 deletions src/components/Table/TableHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, {useRef} from 'react';
import {View} from 'react-native';
import type {ViewProps} from 'react-native';
import Checkbox from '@components/Checkbox';
import Icon from '@components/Icon';
import {PressableWithFeedback} from '@components/Pressable';
import Text from '@components/Text';
Expand Down Expand Up @@ -52,7 +53,7 @@
function TableHeader<T, ColumnKey extends string = string>({style, shouldHideHeaderWhenEmptySearch = true, ...props}: TableHeaderProps) {
const theme = useTheme();
const styles = useThemeStyles();
const {columns, isEmptyResult, title, shouldUseNarrowTableLayout} = useTableContext<T, ColumnKey>();
const {columns, isEmptyResult, title, shouldUseNarrowTableLayout, selectionEnabled} = useTableContext<T, ColumnKey>();

Check failure on line 56 in src/components/Table/TableHeader.tsx

View workflow job for this annotation

GitHub Actions / typecheck

Type 'T' does not satisfy the constraint 'TableData'.

if (shouldUseNarrowTableLayout && !title) {
return null;
Expand All @@ -62,6 +63,12 @@
return null;
}

const gridTemplateColumns = columns.map((column) => (column.width ? `${column.width}px` : '1fr'));

if (selectionEnabled) {
gridTemplateColumns.unshift(`${variables.tableCheckboxColumnWidth}px`);
}

return (
<View
style={[
Expand All @@ -78,7 +85,7 @@
styles.gap3,
// Use Grid on web when available (will override flex if supported)
styles.dGrid,
!shouldUseNarrowTableLayout && {gridTemplateColumns: `repeat(${columns.length}, 1fr)`},
!shouldUseNarrowTableLayout && {gridTemplateColumns: gridTemplateColumns.join(' ')},
style,
]}
{...props}
Expand All @@ -93,15 +100,28 @@
</Text>
)}

{!shouldUseNarrowTableLayout &&
columns.map((column) => {
return (
<TableHeaderColumn
column={column}
key={column.key}
/>
);
})}
{!shouldUseNarrowTableLayout && (
<>
{selectionEnabled && (

Check failure on line 105 in src/components/Table/TableHeader.tsx

View workflow job for this annotation

GitHub Actions / ESLint check

The left side of conditional rendering should be a boolean, not "boolean | undefined"
<View style={styles.flex1}>
<Checkbox
accessibilityLabel="TODO"
isChecked={false}
onPress={() => {}}
/>
</View>
)}

{columns.map((column) => {
return (
<TableHeaderColumn
column={column}
key={column.key}
/>
);
})}
</>
)}
</View>
);
}
Expand All @@ -120,7 +140,7 @@
const {
activeSorting,
tableMethods: {updateSorting, toggleColumnSorting},
} = useTableContext<T, ColumnKey>();

Check failure on line 143 in src/components/Table/TableHeader.tsx

View workflow job for this annotation

GitHub Actions / typecheck

Type 'T' does not satisfy the constraint 'TableData'.
const isSortingByColumn = column.key === activeSorting.columnKey;
const sortIcon = activeSorting.order === 'asc' ? expensifyIcons.ArrowUpLong : expensifyIcons.ArrowDownLong;

Expand All @@ -147,13 +167,15 @@
styles.tableHeaderContentHeight,
column.styling?.flex ? {flex: column.styling.flex} : styles.flex1,
column.styling?.containerStyles,
!column.sortable && styles.cursorDefault,
];

return (
<PressableWithFeedback
accessible
accessibilityLabel={column.label}
accessibilityRole="button"
disabled={!column.sortable}
sentryLabel={CONST.SENTRY_LABEL.TABLE_HEADER.SORTABLE_COLUMN}
style={tableHeaderStyles}
onPress={() => toggleSorting(column.key)}
Expand Down
Loading
Loading