diff --git a/components/doc/common/apidoc/index.json b/components/doc/common/apidoc/index.json index 671bfa6758..9e9357e97d 100644 --- a/components/doc/common/apidoc/index.json +++ b/components/doc/common/apidoc/index.json @@ -54672,6 +54672,22 @@ "default": "", "description": "Order to sort the data by default." }, + { + "name": "stateKey", + "optional": true, + "readonly": false, + "type": "string", + "default": "", + "description": "Unique identifier of a stateful table to use in state storage." + }, + { + "name": "stateStorage", + "optional": true, + "readonly": false, + "type": "\"custom\" | \"local\" | \"session\"", + "default": "session", + "description": "Defines where a stateful table keeps its state, valid values are \"session\" for sessionStorage, \"local\" for localStorage and \"custom\"." + }, { "name": "stripedRows", "optional": true, @@ -54749,6 +54765,25 @@ "callbacks": { "description": "Defines callbacks that determine the behavior of the component based on a given condition or report the actions that the component takes.", "values": [ + { + "name": "customRestoreState", + "parameters": [], + "returnType": "undefined | object", + "description": "A function to implement custom restoreState with stateStorage=\"custom\". Need to return state object." + }, + { + "name": "customSaveState", + "parameters": [ + { + "name": "state", + "optional": false, + "type": "object", + "description": "The object to be stored." + } + ], + "returnType": "void", + "description": "A function to implement custom saveState with stateStorage=\"custom\"." + }, { "name": "onCollapse", "parameters": [ @@ -54931,6 +54966,32 @@ "returnType": "void", "description": "Callback to invoke on sort." }, + { + "name": "onStateRestore", + "parameters": [ + { + "name": "state", + "optional": false, + "type": "object", + "description": "Table state." + } + ], + "returnType": "void", + "description": "Callback to invoke table state is restored." + }, + { + "name": "onStateSave", + "parameters": [ + { + "name": "state", + "optional": false, + "type": "object", + "description": "Table state." + } + ], + "returnType": "void", + "description": "Callback to invoke table state is saved." + }, { "name": "onToggle", "parameters": [ diff --git a/components/doc/treetable/statefuldoc.js b/components/doc/treetable/statefuldoc.js new file mode 100644 index 0000000000..58e80c3b38 --- /dev/null +++ b/components/doc/treetable/statefuldoc.js @@ -0,0 +1,125 @@ +import { DocSectionCode } from '@/components/doc/common/docsectioncode'; +import { DocSectionText } from '@/components/doc/common/docsectiontext'; +import { Column } from '@/components/lib/column/Column'; +import { TreeTable } from '@/components/lib/treetable/TreeTable'; +import { useEffect, useState } from 'react'; +import { NodeService } from '../../../service/NodeService'; + +export function StatefulDoc(props) { + const [nodes, setNodes] = useState([]); + + useEffect(() => { + NodeService.getTreeTableNodes().then((data) => setNodes(data)); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const code = { + basic: ` + + + + + + `, + javascript: ` +import React, { useState, useEffect } from 'react'; +import { TreeTable } from 'primereact/treetable'; +import { Column } from 'primereact/column'; +import { NodeService } from './service/NodeService'; + +export default function StatefulDemo() { + const [nodes, setNodes] = useState([]); + + useEffect(() => { + NodeService.getTreeTableNodes().then((data) => setNodes(data)); + }, []); + + return ( +
+ + + + + +
+ ) +} + `, + typescript: ` +import React, { useState, useEffect } from 'react'; +import { TreeTable } from 'primereact/treetable'; +import { Column } from 'primereact/column'; +import { TreeNode } from 'primereact/treenode'; +import { NodeService } from './service/NodeService'; + +export default function StatefulDemo() { + const [nodes, setNodes] = useState([]); + + useEffect(() => { + NodeService.getTreeTableNodes().then((data) => setNodes(data)); + }, []); + + + + return ( +
+ + + + + +
+ ) +} + `, + data: ` +{ + key: '0', + label: 'Documents', + data: 'Documents Folder', + icon: 'pi pi-fw pi-inbox', + children: [ + { + key: '0-0', + label: 'Work', + data: 'Work Folder', + icon: 'pi pi-fw pi-cog', + children: [ + { key: '0-0-0', label: 'Expenses.doc', icon: 'pi pi-fw pi-file', data: 'Expenses Document' }, + { key: '0-0-1', label: 'Resume.doc', icon: 'pi pi-fw pi-file', data: 'Resume Document' } + ] + }, + { + key: '0-1', + label: 'Home', + data: 'Home Folder', + icon: 'pi pi-fw pi-home', + children: [{ key: '0-1-0', label: 'Invoices.txt', icon: 'pi pi-fw pi-file', data: 'Invoices for this month' }] + } + ] +}, +... +` + }; + + return ( + <> + +

Stateful table allows keeping the state such as page, sort and filtering either at local storage or session storage so that when the page is visited again, table would render the data using the last settings.

+

+ Change the state of the table e.g paginate or expand rows, navigate away and then return to this table again to test this feature. The setting is set as session with the stateStorage property so that Table retains + the state until the browser is closed. Other alternative is local referring to localStorage for an extended lifetime. +

+
+
+ + + + + +
+ + + ); +} diff --git a/components/lib/datatable/DataTable.js b/components/lib/datatable/DataTable.js index cee2cf30ff..7ec106f9c4 100644 --- a/components/lib/datatable/DataTable.js +++ b/components/lib/datatable/DataTable.js @@ -13,6 +13,7 @@ import { DataTableBase } from './DataTableBase'; import { TableBody } from './TableBody'; import { TableFooter } from './TableFooter'; import { TableHeader } from './TableHeader'; +import { getStorage } from '../../utils/utils'; export const DataTable = React.forwardRef((inProps, ref) => { const context = React.useContext(PrimeReactContext); @@ -175,24 +176,8 @@ export const DataTable = React.forwardRef((inProps, ref) => { return columns; }; - const getStorage = () => { - switch (props.stateStorage) { - case 'local': - return window.localStorage; - - case 'session': - return window.sessionStorage; - - case 'custom': - return null; - - default: - throw new Error(props.stateStorage + ' is not a valid value for the state storage, supported values are "local", "session" and "custom".'); - } - }; - const saveState = () => { - let state = {}; + const state = {}; if (props.paginator) { state.first = getFirst(); @@ -237,7 +222,7 @@ export const DataTable = React.forwardRef((inProps, ref) => { props.customSaveState(state); } } else { - const storage = getStorage(); + const storage = getStorage(props.stateStorage); if (ObjectUtils.isNotEmpty(state)) { storage.setItem(props.stateKey, JSON.stringify(state)); @@ -250,7 +235,7 @@ export const DataTable = React.forwardRef((inProps, ref) => { }; const clearState = () => { - const storage = getStorage(); + const storage = getStorage(props.stateStorage); if (storage && props.stateKey) { storage.removeItem(props.stateKey); @@ -265,7 +250,7 @@ export const DataTable = React.forwardRef((inProps, ref) => { restoredState = props.customRestoreState(); } } else { - const storage = getStorage(); + const storage = getStorage(props.stateStorage); const stateString = storage.getItem(props.stateKey); const dateFormat = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; diff --git a/components/lib/treetable/TreeTable.js b/components/lib/treetable/TreeTable.js index 3116b163f0..f921e8cb30 100644 --- a/components/lib/treetable/TreeTable.js +++ b/components/lib/treetable/TreeTable.js @@ -1,8 +1,8 @@ import * as React from 'react'; -import PrimeReact, { FilterService, PrimeReactContext } from '../api/Api'; +import PrimeReact, { FilterMatchMode, FilterService, PrimeReactContext } from '../api/Api'; import { ColumnBase } from '../column/ColumnBase'; import { useHandleStyle } from '../componentbase/ComponentBase'; -import { useEventListener, useUpdateEffect, useMergeProps } from '../hooks/Hooks'; +import { useEventListener, useUpdateEffect, useMergeProps, useMountEffect } from '../hooks/Hooks'; import { ArrowDownIcon } from '../icons/arrowdown'; import { ArrowUpIcon } from '../icons/arrowup'; import { SpinnerIcon } from '../icons/spinner'; @@ -13,6 +13,7 @@ import { TreeTableBody } from './TreeTableBody'; import { TreeTableFooter } from './TreeTableFooter'; import { TreeTableHeader } from './TreeTableHeader'; import { TreeTableScrollableView } from './TreeTableScrollableView'; +import { getStorage } from '../../utils/utils'; export const TreeTable = React.forwardRef((inProps, ref) => { const mergeProps = useMergeProps(); @@ -83,6 +84,185 @@ export const TreeTable = React.forwardRef((inProps, ref) => { } }); + const isCustomStateStorage = () => { + return props.stateStorage === 'custom'; + }; + + const isStateful = () => { + return props.stateKey != null || isCustomStateStorage(); + }; + + const saveState = () => { + let state = {}; + + if (props.paginator) { + state.first = getFirst(); + state.rows = getRows(); + } + + const sortField = getSortField(); + + if (sortField) { + state.sortField = sortField; + state.sortOrder = getSortOrder(); + } + + const multiSortMeta = getMultiSortMeta(); + + if (multiSortMeta) { + state.multiSortMeta = multiSortMeta; + } + + if (hasFilter()) { + state.filters = getFilters(); + } + + if (props.reorderableColumns) { + state.columnOrder = columnOrderState; + } + + state.expandedKeysState = expandedKeysState; + + if (props.selectionKeys && props.onSelectionChange) { + state.selectionKeys = props.selectionKeys; + } + + if (isCustomStateStorage()) { + if (props.customSaveState) { + props.customSaveState(state); + } + } else { + const storage = getStorage(props.stateStorage); + + if (ObjectUtils.isNotEmpty(state)) { + storage.setItem(props.stateKey, JSON.stringify(state)); + } + } + + if (props.onStateSave) { + props.onStateSave(state); + } + }; + + const clearState = () => { + const storage = getStorage(props.stateStorage); + + if (storage && props.stateKey) { + storage.removeItem(props.stateKey); + } + }; + + const restoreState = () => { + let restoredState = {}; + + if (isCustomStateStorage()) { + if (props.customRestoreState) { + restoredState = props.customRestoreState(); + } + } else { + const storage = getStorage(props.stateStorage); + const stateString = storage.getItem(props.stateKey); + const dateFormat = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; + + const reviver = function (key, value) { + return typeof value === 'string' && dateFormat.test(value) ? new Date(value) : value; + }; + + if (stateString) { + restoredState = JSON.parse(stateString, reviver); + } + } + + _restoreState(restoredState); + }; + + const restoreTableState = (restoredState) => { + _restoreState(restoredState); + }; + + const _restoreState = (restoredState = {}) => { + if (ObjectUtils.isNotEmpty(restoredState)) { + if (props.paginator) { + if (props.onPage) { + const getOnPageParams = (first, rows) => { + const totalRecords = getTotalRecords(processedData()); + const pageCount = Math.ceil(totalRecords / rows) || 1; + const page = Math.floor(first / rows); + + return { first, rows, page, pageCount }; + }; + + props.onPage(createEvent(getOnPageParams(restoredState.first, restoredState.rows))); + } else { + setFirstState(restoredState.first); + setRowsState(restoredState.rows); + } + } + + if (restoredState.sortField) { + if (props.onSort) { + props.onSort( + createEvent({ + sortField: restoredState.sortField, + sortOrder: restoredState.sortOrder + }) + ); + } else { + setSortFieldState(restoredState.sortField); + setSortOrderState(restoredState.sortOrder); + } + } + + if (restoredState.multiSortMeta) { + if (props.onSort) { + props.onSort( + createEvent({ + multiSortMeta: restoredState.multiSortMeta + }) + ); + } else { + setMultiSortMetaState(restoredState.multiSortMeta); + } + } + + if (restoredState.filters) { + if (props.onFilter) { + props.onFilter( + createEvent({ + filters: restoredState.filters + }) + ); + } else { + setFiltersState(cloneFilters(restoredState.filters)); + } + } + + if (props.reorderableColumns) { + setColumnOrderState(restoredState.columnOrder); + } + + if (restoredState.expandedKeysState) { + if (props.onToggle) { + props.onRowToggle({ + data: restoredState.expandedKeysState + }); + } else { + setExpandedKeysState(restoredState.expandedKeysState); + } + } + + if (restoredState.selectionKeys && props.onSelectionChange) { + props.onSelectionChange({ + value: restoredState.selectionKeys + }); + } + + if (props.onStateRestore) { + props.onStateRestore(restoredState); + } + } + }; + const onToggle = (event) => { const { originalEvent, value, navigateFocusToChild } = event; @@ -337,6 +517,41 @@ export const TreeTable = React.forwardRef((inProps, ref) => { } }; + const cloneFilters = (filters) => { + filters = filters || props.filters; + let cloned = {}; + + if (filters) { + Object.entries(filters).forEach(([prop, value]) => { + cloned[prop] = value; + }); + } else { + const columns = getColumns(); + + cloned = columns.reduce((filters, col) => { + const field = getColumnProp(col, 'filterField') || getColumnProp(col, 'field'); + const filterFunction = getColumnProp(col, 'filterFunction'); + const dataType = getColumnProp(col, 'dataType'); + const matchMode = + getColumnProp(col, 'filterMatchMode') || + ((context && context.filterMatchModeOptions[dataType]) || PrimeReact.filterMatchModeOptions[dataType] + ? (context && context.filterMatchModeOptions[dataType][0]) || PrimeReact.filterMatchModeOptions[dataType][0] + : FilterMatchMode.STARTS_WITH); + let constraint = { value: null, matchMode }; + + if (filterFunction) { + FilterService.register(`custom_${field}`, (...args) => filterFunction(...args, { column: col })); + } + + filters[field] = constraint; + + return filters; + }, {}); + } + + return cloned; + }; + const hasFilter = () => { return ObjectUtils.isNotEmpty(getFilters()); }; @@ -437,6 +652,10 @@ export const TreeTable = React.forwardRef((inProps, ref) => { delta: delta }); } + + if (isStateful()) { + saveState(); + } } resizerHelperRef.current.style.display = 'none'; @@ -893,6 +1112,18 @@ export const TreeTable = React.forwardRef((inProps, ref) => { return data; }; + useMountEffect(() => { + if (isStateful()) { + restoreState(); + } + }); + + useUpdateEffect(() => { + if (isStateful()) { + saveState(); + } + }); + useUpdateEffect(() => { if (childFocusEvent.current) { const nodeElement = childFocusEvent.current.target; @@ -908,10 +1139,26 @@ export const TreeTable = React.forwardRef((inProps, ref) => { React.useImperativeHandle(ref, () => ({ props, + clearState, filter, - getElement: () => elementRef.current + getElement: () => elementRef.current, + restoreState, + restoreTableState, + saveState })); + const createEvent = (event) => { + return { + first: getFirst(), + rows: getRows(), + sortField: getSortField(), + sortOrder: getSortOrder(), + multiSortMeta: getMultiSortMeta(), + filters: getFilters(), + ...event + }; + }; + const createTableHeader = (columns, columnGroup) => { const sortField = getSortField(); const sortOrder = getSortOrder(); diff --git a/components/lib/treetable/TreeTableBase.js b/components/lib/treetable/TreeTableBase.js index 29912dc233..d7fe38ed1b 100644 --- a/components/lib/treetable/TreeTableBase.js +++ b/components/lib/treetable/TreeTableBase.js @@ -83,6 +83,8 @@ export const TreeTableBase = ComponentBase.extend({ columnResizeMode: 'fit', contextMenuSelectionKey: null, currentPageReportTemplate: '({currentPage} of {totalPages})', + customRestoreState: null, + customSaveState: null, defaultSortOrder: 1, emptyMessage: null, expandedKeys: null, @@ -120,6 +122,8 @@ export const TreeTableBase = ComponentBase.extend({ onSelect: null, onSelectionChange: null, onSort: null, + onStateRestore: null, + onStateSave: null, onToggle: null, onUnselect: null, onValueChange: null, @@ -152,6 +156,8 @@ export const TreeTableBase = ComponentBase.extend({ sortIcon: null, sortMode: 'single', sortOrder: null, + stateKey: null, + stateStorage: null, stripedRows: false, style: null, tabIndex: 0, diff --git a/components/lib/treetable/treetable.d.ts b/components/lib/treetable/treetable.d.ts index b6dc664262..6ad7c23a65 100644 --- a/components/lib/treetable/treetable.d.ts +++ b/components/lib/treetable/treetable.d.ts @@ -842,6 +842,15 @@ export interface TreeTableProps extends Omit }); linkElement.parentNode?.insertBefore(cloneLinkElement, linkElement.nextSibling); }; + +export const getStorage = (stateStorageProp) => { + switch (stateStorageProp) { + case 'local': + return window.localStorage; + + case 'session': + return window.sessionStorage; + + case 'custom': + return null; + + default: + throw new Error(stateStorageProp + ' is not a valid value for the state storage, supported values are "local", "session" and "custom".'); + } +}; diff --git a/pages/treetable/index.js b/pages/treetable/index.js index 2eb25707ef..a937d23992 100644 --- a/pages/treetable/index.js +++ b/pages/treetable/index.js @@ -30,6 +30,7 @@ import { SingleColumnDoc } from '@/components/doc/treetable/sort/singlecolumndoc import { TemplateDoc } from '@/components/doc/treetable/templatedoc'; import { StyledDoc } from '@/components/doc/treetable/theming/styleddoc'; import { TailwindDoc } from '@/components/doc/treetable/theming/tailwinddoc'; +import { StatefulDoc } from '@/components/doc/treetable/statefuldoc'; const TreeTableDemo = () => { const docs = [ @@ -193,7 +194,11 @@ const TreeTableDemo = () => { label: 'Context Menu', component: ContextMenuDoc }, - + { + id: 'stateful', + label: 'Stateful', + component: StatefulDoc + }, { id: 'accessibility', label: 'Accessibility',