Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[TASK-858] UniversalTable component #5102

Closed
wants to merge 17 commits into from
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import KoboSelect, {
KoboSelectOption,
} from 'jsapp/js/components/common/koboSelect';
import Button from 'jsapp/js/components/common/button';
import UniversalTable from 'js/universalTable/universalTable.component';

export default function AccessLogSection() {
function renderDataTable(
Expand Down Expand Up @@ -63,7 +64,7 @@ export default function AccessLogSection() {
onChange={onItemLimitChange}
selectedOption={currentLimit}
/>
<div>{data?.map((result) => <div>{result.source_browser}</div>)}</div>
<div>{data?.map((result, index) => <div key={index}>{result.source_browser}</div>)}</div>
</div>
);
}
Expand All @@ -75,6 +76,69 @@ export default function AccessLogSection() {
queryHook={useAccessLogQuery}
renderDisplayTable={renderDataTable}
/>

<UniversalTable
columns={[
{key: 'source', label: t('Source')},
{key: 'activity', label: t('Last activity')},
{key: 'duration', label: t('Session duration'), isPinned: true},
{key: 'ip', label: t('IP Address')},
{key: 'a', label: 'a'},
{key: 'b', label: 'b'},
{key: 'c', label: 'c', size: 400},
{key: 'd', label: 'd'},
{key: 'e', label: 'e'},
{key: 'f', label: 'f'},
{key: 'g', label: 'g'},
]}
data={[
{source: 'Safari', activity: '15 minutes ago', duration: 'Your current session', ip: '123.456.789.255', a: '-', b: '-', c: '-', d: '-', e: '-', f: '-', g: '-'},
{source: 'Firefox', activity: '1 hour ago', duration: '0:10:30', ip: '123.456.789.255', a: '-', b: '-', c: '-', d: '-', e: '-', f: '-', g: '-'},
{source: 'Safari', activity: '15 minutes ago', duration: 'Your current session', ip: '123.456.789.255', a: '-', b: '-', c: '-', d: '-', e: '-', f: '-', g: '-'},
{source: 'Firefox', activity: '1 hour ago', duration: '0:10:30', ip: '123.456.789.255', a: '-', b: '-', c: '-', d: '-', e: '-', f: '-', g: '-'},
{source: 'Safari', activity: '15 minutes ago', duration: 'Your current session', ip: '123.456.789.255', a: '-', b: '-', c: '-', d: '-', e: '-', f: '-', g: '-'},
{source: 'Firefox', activity: '1 hour ago', duration: '0:10:30', ip: '123.456.789.255', a: '-', b: '-', c: '-', d: '-', e: '-', f: '-', g: '-'},
{source: 'Safari', activity: '15 minutes ago', duration: 'Your current session', ip: '123.456.789.255', a: '-', b: '-', c: '-', d: '-', e: '-', f: '-', g: '-'},
{source: 'Firefox', activity: '1 hour ago', duration: '0:10:30', ip: '123.456.789.255', a: '-', b: '-', c: '-', d: '-', e: '-', f: '-', g: '-'},
{source: 'Safari', activity: '15 minutes ago', duration: 'Your current session', ip: '123.456.789.255', a: '-', b: '-', c: '-', d: '-', e: '-', f: '-', g: '-'},
{source: 'Firefox', activity: '1 hour ago', duration: '0:10:30', ip: '123.456.789.255', a: '-', b: '-', c: '-', d: '-', e: '-', f: '-', g: '-'},
{source: 'Safari', activity: '15 minutes ago', duration: 'Your current session', ip: '123.456.789.255', a: '-', b: '-', c: '-', d: '-', e: '-', f: '-', g: '-'},
{source: 'Firefox', activity: '1 hour ago', duration: '0:10:30', ip: '123.456.789.255', a: '-', b: '-', c: '-', d: '-', e: '-', f: '-', g: '-'},
{source: 'Safari', activity: '15 minutes ago', duration: 'Your current session', ip: '123.456.789.255', a: '-', b: '-', c: '-', d: '-', e: '-', f: '-', g: '-'},
{source: 'Firefox', activity: '1 hour ago', duration: '0:10:30', ip: '123.456.789.255', a: '-', b: '-', c: '-', d: '-', e: '-', f: '-', g: '-'},
{source: 'Safari', activity: '15 minutes ago', duration: 'Your current session', ip: '123.456.789.255', a: '-', b: '-', c: '-', d: '-', e: '-', f: '-', g: '-'},
{source: 'Firefox', activity: '1 hour ago', duration: '0:10:30', ip: '123.456.789.255', a: '-', b: '-', c: '-', d: '-', e: '-', f: '-', g: '-'},
{source: 'Safari', activity: '15 minutes ago', duration: 'Your current session', ip: '123.456.789.255', a: '-', b: '-', c: '-', d: '-', e: '-', f: '-', g: '-'},
{source: 'Firefox', activity: '1 hour ago', duration: '0:10:30', ip: '123.456.789.255', a: '-', b: '-', c: '-', d: '-', e: '-', f: '-', g: '-'},
{source: 'Safari', activity: '15 minutes ago', duration: 'Your current session', ip: '123.456.789.255', a: '-', b: '-', c: '-', d: '-', e: '-', f: '-', g: '-'},
{source: 'Firefox', activity: '1 hour ago', duration: '0:10:30', ip: '123.456.789.255', a: '-', b: '-', c: '-', d: '-', e: '-', f: '-', g: '-'},
{source: 'Safari', activity: '15 minutes ago', duration: 'Your current session', ip: '123.456.789.255', a: '-', b: '-', c: '-', d: '-', e: '-', f: '-', g: '-'},
{source: 'Firefox', activity: '1 hour ago', duration: '0:10:30', ip: '123.456.789.255', a: '-', b: '-', c: '-', d: '-', e: '-', f: '-', g: '-'},
{source: 'Safari', activity: '15 minutes ago', duration: 'Your current session', ip: '123.456.789.255', a: '-', b: '-', c: '-', d: '-', e: '-', f: '-', g: '-'},
{source: 'Firefox', activity: '1 hour ago', duration: '0:10:30', ip: '123.456.789.255', a: '-', b: '-', c: '-', d: '-', e: '-', f: '-', g: '-'},
{source: 'Safari', activity: '15 minutes ago', duration: 'Your current session', ip: '123.456.789.255', a: '-', b: '-', c: '-', d: '-', e: '-', f: '-', g: '-'},
{source: 'Firefox', activity: '1 hour ago', duration: '0:10:30', ip: '123.456.789.255', a: '-', b: '-', c: '-', d: '-', e: '-', f: '-', g: '-'},
{source: 'Safari', activity: '15 minutes ago', duration: 'Your current session', ip: '123.456.789.255', a: '-', b: '-', c: '-', d: '-', e: '-', f: '-', g: '-'},
{source: 'Firefox', activity: '1 hour ago', duration: '0:10:30', ip: '123.456.789.255', a: '-', b: '-', c: '-', d: '-', e: '-', f: '-', g: '-'},
{source: 'Safari', activity: '15 minutes ago', duration: 'Your current session', ip: '123.456.789.255', a: '-', b: '-', c: '-', d: '-', e: '-', f: '-', g: '-'},
{source: 'Firefox', activity: '1 hour ago', duration: '0:10:30', ip: '123.456.789.255', a: '-', b: '-', c: '-', d: '-', e: '-', f: '-', g: '-'},
{source: 'Safari', activity: '15 minutes ago', duration: 'Your current session', ip: '123.456.789.255', a: '-', b: '-', c: '-', d: '-', e: '-', f: '-', g: '-'},
{source: 'Firefox', activity: '1 hour ago', duration: '0:10:30', ip: '123.456.789.255', a: '-', b: '-', c: '-', d: '-', e: '-', f: '-', g: '-'},
{source: 'Safari', activity: '15 minutes ago', duration: 'Your current session', ip: '123.456.789.255', a: '-', b: '-', c: '-', d: '-', e: '-', f: '-', g: '-'},
{source: 'Firefox', activity: '1 hour ago', duration: '0:10:30', ip: '123.456.789.255', a: '-', b: '-', c: '-', d: '-', e: '-', f: '-', g: '-'},
{source: 'Safari', activity: '15 minutes ago', duration: 'Your current session', ip: '123.456.789.255', a: '-', b: '-', c: '-', d: '-', e: '-', f: '-', g: '-'},
{source: 'Firefox', activity: '1 hour ago', duration: '0:10:30', ip: '123.456.789.255', a: '-', b: '-', c: '-', d: '-', e: '-', f: '-', g: '-'},
{source: 'Safari', activity: '15 minutes ago', duration: 'Your current session', ip: '123.456.789.255', a: '-', b: '-', c: '-', d: '-', e: '-', f: '-', g: '-'},
{source: 'Firefox', activity: '1 hour ago', duration: '0:10:30', ip: '123.456.789.255', a: '-', b: '-', c: '-', d: '-', e: '-', f: '-', g: '-'},
]}
pageIndex={3}
pageCount={11}
pageSize={10}
pageSizes={[10, 30, 50, 100]}
onRequestPaginationChange={(newPageInfo, oldPageInfo) => {
console.log('pagination change requested', newPageInfo, oldPageInfo);
}}
/>
</>
);
}
292 changes: 292 additions & 0 deletions jsapp/js/universalTable/universalTable.component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,292 @@
// Libraries
import React, {type ReactNode} from 'react';
import cx from 'classnames';
import {
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
type Column,
type PaginationState,
type TableOptions,
} from '@tanstack/react-table';

// Partial components
import Button from 'js/components/common/button';
import KoboSelect from 'js/components/common/koboSelect';

// Utilities
import {generateUuid} from 'js/utils';

// Styles
import styles from './universalTable.module.scss';

interface UniversalTableColumn {
/**
* Pairs to data object properties. It is using dot notation, so it's possible
* to match data from a nested object :ok:.
*/
key: string;
/**
* Most of the times this would be just a string, but we are open to
* anything really.
*/
label: ReactNode;
isPinned?: boolean;
/**
* This is override for the default width of a column. Use it if you need more
* space for your data, or if you display something very short.
*/
size?: number;
}

interface UniversalTableDataItem {
[key: string]: ReactNode;
}

interface UniversalTableProps {
/** A list of column definitions */
columns: UniversalTableColumn[];
data: UniversalTableDataItem[];
// PAGINATION
// To see footer with pagination you need to pass all these below:
/** Starts with `0` */
pageIndex?: number;
/** Total number of pages of data. */
pageCount?: number;
/**
* One of `pageSizes`. It is de facto the `limit` from the `offset` + `limit`
* pair used for paginatin the endpoint.
*/
pageSize?: number;
pageSizes?: number[];
/**
* A way for the table to say "user wants to change pagination". It's being
* triggered for both page size and page changes.
*/
onRequestPaginationChange?: (
newPageInfo: PaginationState,
oldPageInfo: PaginationState
) => void;
// ENDPAGINATION
}

const columnHelper = createColumnHelper<UniversalTableDataItem>();

function getCommonClassNames(column: Column<UniversalTableDataItem>) {
return cx({
[styles.isPinned]: Boolean(column.getIsPinned()),
});
}

const DEFAULT_COLUMN_SIZE = {
size: 200, // starting column size
minSize: 100, // enforced during column resizing
maxSize: 600, // enforced during column resizing
};

export default function UniversalTable(props: UniversalTableProps) {
const columns = props.columns.map((columnDef) =>
columnHelper.accessor(columnDef.key, {
header: () => columnDef.label,
cell: (info) => info.renderValue(),
size: columnDef.size || DEFAULT_COLUMN_SIZE.size,
})
);

// We define options as separate object to make the optional pagination truly
// optional.
const options: TableOptions<UniversalTableDataItem> = {
columns: columns,
data: props.data,
getCoreRowModel: getCoreRowModel(),
columnResizeMode: 'onChange',
//override default column sizing
defaultColumn: DEFAULT_COLUMN_SIZE,
};

options.state = {};

// Set separately to not get overriden by pagination options. This is a list
// of columns that are pinned to the left side.
options.state.columnPinning = {
left: props.columns.filter((col) => col.isPinned).map((col) => col.key) || [],
};

const hasPagination = (
props.pageIndex !== undefined &&
props.pageCount !== undefined &&
props.pageSize !== undefined &&
props.pageSizes !== undefined &&
props.onRequestPaginationChange !== undefined
);

// Add pagination related options if needed
if (hasPagination) {
options.manualPagination = true;
options.pageCount = props.pageCount;
options.initialState = {
pagination: {
pageSize: props.pageSize,
pageIndex: props.pageIndex,
},
};
//update the pagination state when internal APIs mutate the pagination state
options.onPaginationChange = (updater) => {
// make sure updater is callable (to avoid typescript warning)
if (typeof updater !== 'function') {
return;
}

// The `table` below is defined before usage, but we are sure it will be
// there, given this is a callback function for it.
// eslint-disable-next-line @typescript-eslint/no-use-before-define
const oldPageInfo = table.getState().pagination;

const newPageInfo = updater(oldPageInfo);

if (props.onRequestPaginationChange) {
props.onRequestPaginationChange(newPageInfo, oldPageInfo);
}
};
}

// Here we build the headless table that we would render below
const table = useReactTable(options);

const currentPageString = String(table.getState().pagination.pageIndex + 1);
const totalPagesString = String(table.getPageCount());

return (
<div className={styles.universalTableRootContainer}>
<div className={styles.universalTableRoot}>
<div className={styles.tableContainer}>
<table className={styles.table} style={{width: table.getTotalSize()}}>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className={styles.tableRow}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
className={cx(
styles.tableHeaderCell,
getCommonClassNames(header.column)
)}
style={{width: `${header.getSize()}px`}}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}

{/*
TODO: if we ever see performance issues while resizing,
there is a way to fix that, see:
https://tanstack.com/table/latest/docs/guide/column-sizing#advanced-column-resizing-performance
*/}
<div
{...{
onDoubleClick: () => header.column.resetSize(),
onMouseDown: header.getResizeHandler(),
onTouchStart: header.getResizeHandler(),
className: cx(styles.resizer, {
[styles.isResizing]: header.column.getIsResizing(),
}),
}}
/>
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className={styles.tableRow}>
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
className={cx(
styles.tableCell,
getCommonClassNames(cell.column)
)}
style={{width: `${cell.column.getSize()}px`}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>

{hasPagination && (
<footer className={styles.tableFooter}>
<section className={styles.pagination}>
<Button
type='text'
size='s'
onClick={() => table.firstPage()}
isDisabled={!table.getCanPreviousPage()}
startIcon='angle-bar-left'
/>

<Button
type='text'
size='s'
onClick={() => table.previousPage()}
isDisabled={!table.getCanPreviousPage()}
startIcon='angle-left'
/>

<div
className={styles.paginationNumbering}
dangerouslySetInnerHTML={{
__html: t('Page ##current_page## of ##total_pages##')
.replace('##current_page##', `<strong>${currentPageString}</strong>`)
.replace('##total_pages##', `<strong>${totalPagesString}</strong>`),
}}
/>

<Button
type='text'
size='s'
onClick={() => table.nextPage()}
isDisabled={!table.getCanNextPage()}
startIcon='angle-right'
/>

<Button
type='text'
size='s'
onClick={() => table.lastPage()}
isDisabled={!table.getCanNextPage()}
startIcon='angle-bar-right'
/>
</section>

<KoboSelect
className={styles.pageSizeSelect}
name={`universal-table-select-${generateUuid()}`}
type='outline'
size='s'
options={(props.pageSizes || []).map((pageSize) => {
return {
value: String(pageSize),
label: t('##number## rows').replace('##number##', String(pageSize)),
};
})}
selectedOption={String(table.getState().pagination.pageSize)}
onChange={(newSelectedOption: string | null) => {
table.setPageSize(Number(newSelectedOption));
}}
placement='up-left'
/>
</footer>
)}
</div>
</div>
);
}
Loading