@vinorcola/dynamic-table 1.3.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,14 @@
1
- import type { ReactNode } from "react";
2
- import type { Primitive } from "./index.ts";
1
+ import type { FC, ReactNode } from "react";
2
+ import type { Primitive } from "./index.js";
3
3
  export interface DictionaryEntry {
4
4
  title: string;
5
5
  prepend?: ReactNode;
6
6
  }
7
+ export type DictinaryValueWrapper = FC<{
8
+ children: ReactNode;
9
+ }>;
7
10
  export default class Dictionary<Value extends Primitive> extends Map<Value, DictionaryEntry> {
8
11
  readonly unknownMessage: string;
9
- constructor(unknownMessage: string, entries?: readonly (readonly [Value, DictionaryEntry])[] | null);
12
+ readonly valueWrapper: DictinaryValueWrapper;
13
+ constructor(unknownMessage: string, entries?: readonly (readonly [Value, DictionaryEntry])[] | null, valueWrapper?: DictinaryValueWrapper);
10
14
  }
package/lib/Dictionary.js CHANGED
@@ -1,7 +1,9 @@
1
1
  export default class Dictionary extends Map {
2
2
  unknownMessage;
3
- constructor(unknownMessage, entries) {
3
+ valueWrapper;
4
+ constructor(unknownMessage, entries, valueWrapper) {
4
5
  super(entries);
5
6
  this.unknownMessage = unknownMessage;
7
+ this.valueWrapper = valueWrapper ?? ((props) => props.children);
6
8
  }
7
9
  }
@@ -0,0 +1,5 @@
1
+ import type { StateWatcherInterface } from ".";
2
+ /**
3
+ * Generates a watcher that will save states in browser's LocalStorage.
4
+ */
5
+ export default function generateLocalStorageWatcher(namespace: string): StateWatcherInterface;
@@ -0,0 +1,38 @@
1
+ function save(key, value) {
2
+ localStorage.setItem(key, JSON.stringify(value));
3
+ }
4
+ function load(key) {
5
+ return new Promise((resolve, reject) => {
6
+ try {
7
+ const storedValue = localStorage.getItem(key);
8
+ if (storedValue === null) {
9
+ return resolve(null);
10
+ }
11
+ return resolve(JSON.parse(storedValue));
12
+ }
13
+ catch (error) {
14
+ reject(error);
15
+ }
16
+ });
17
+ }
18
+ function watch(key) {
19
+ return {
20
+ onChange(filterState) {
21
+ save(key, filterState);
22
+ },
23
+ loadInitial() {
24
+ return load(key);
25
+ },
26
+ };
27
+ }
28
+ /**
29
+ * Generates a watcher that will save states in browser's LocalStorage.
30
+ */
31
+ export default function generateLocalStorageWatcher(namespace) {
32
+ return {
33
+ filterState: watch(`${namespace}-filter-state`),
34
+ sortState: watch(`${namespace}-sort-state`),
35
+ columnsMaskState: watch(`${namespace}-columns-mask-state`),
36
+ paginationState: watch(`${namespace}-pagination-state`),
37
+ };
38
+ }
@@ -0,0 +1,6 @@
1
+ import type { StateWatcherInterface } from ".";
2
+ /**
3
+ * This watcher does nothing.
4
+ */
5
+ declare const NoopWatcher: StateWatcherInterface;
6
+ export default NoopWatcher;
@@ -0,0 +1,5 @@
1
+ /**
2
+ * This watcher does nothing.
3
+ */
4
+ const NoopWatcher = {};
5
+ export default NoopWatcher;
@@ -0,0 +1,14 @@
1
+ import type { FilterState } from "../useFilterState";
2
+ import type { ColumnsMaskState } from "../useMaskableColumns";
3
+ import type { PaginationState } from "../usePagination";
4
+ import type { SortState } from "../useSortState";
5
+ export interface StateWatcherInterface {
6
+ filterState?: SpecificStateWatcher<FilterState>;
7
+ sortState?: SpecificStateWatcher<SortState>;
8
+ columnsMaskState?: SpecificStateWatcher<ColumnsMaskState>;
9
+ paginationState?: SpecificStateWatcher<PaginationState>;
10
+ }
11
+ export interface SpecificStateWatcher<State> {
12
+ onChange?(state: State): void;
13
+ loadInitial?(): Promise<State | null>;
14
+ }
@@ -0,0 +1 @@
1
+ export {};
package/lib/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
- import type { Key as ReactKey } from "react";
1
+ import { type Key as ReactKey } from "react";
2
2
  import type { ColumnDefinition } from "./ColumnDefinition.js";
3
+ import type { StateWatcherInterface } from "./StateWatcher/index.js";
3
4
  import { type FilterState } from "./useFilterState.js";
4
5
  import type { ColumnsMaskState } from "./useMaskableColumns.js";
5
6
  import { type PaginationState } from "./usePagination.js";
@@ -12,11 +13,14 @@ export type BaseItem = Record<string, any> & {
12
13
  export type ItemKey<Item extends BaseItem> = Extract<keyof Item, string>;
13
14
  export type { ColumnDefinition, ValueResolver } from "./ColumnDefinition.js";
14
15
  export { default as Dictionary, DictionaryEntry } from "./Dictionary.js";
16
+ export type { StateWatcherInterface } from "./StateWatcher/index.js";
17
+ export { default as LocalStorageWatcher } from "./StateWatcher/LocalStorageWatcher.js";
15
18
  export type { FilterState } from "./useFilterState.js";
16
19
  export type { SortDirection, SortState } from "./useSortState.js";
17
20
  export type { ColumnsMaskState } from "./useMaskableColumns.js";
18
21
  export type { PaginationState } from "./usePagination.js";
19
22
  interface BaseProps<Item extends BaseItem> {
23
+ namespace?: string;
20
24
  items: Item[];
21
25
  columns: ColumnDefinition<Item, Primitive>[];
22
26
  itemTarget?: (item: Item) => string;
@@ -24,6 +28,7 @@ interface BaseProps<Item extends BaseItem> {
24
28
  initialSortState?: SortState;
25
29
  initialColumnsMaskState?: ColumnsMaskState;
26
30
  initialPaginationState?: PaginationState;
31
+ watcher?: StateWatcherInterface;
27
32
  }
28
33
  type Props<Item extends BaseItem> = BaseProps<Item> & SelectionOptions<Item>;
29
34
  declare function DynamicTable<Item extends BaseItem>(props: Props<Item>): import("react").JSX.Element;
package/lib/index.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMemo } from "react";
2
3
  import Controller from "./Controller.js";
3
4
  import { BodyContainer, Button, Cell, ClickableCell, ColumnsPopup, ControllerContainer, FooterContainer, Header, HeaderActions, HeaderContainer, HeaderLine, HeaderStatus, ItemsPerPageSelector, Line, Loader, PageButton, PageSelector, Popup, SearchFilterPopup, SelectionCell, SelectionFilterPopup, SelectionHeader, Table, TableContainer, } from "./DefaultTheme.js";
5
+ import generateLocalStorageWatcher from "./StateWatcher/LocalStorageWatcher.js";
6
+ import NoopWatcher from "./StateWatcher/NoopWatcher.js";
4
7
  import UniquePopupProvider from "./UniquePopupProvider.js";
5
8
  import useColumns from "./useColumns.js";
6
9
  import useFilterState from "./useFilterState.js";
@@ -10,13 +13,16 @@ import usePagination from "./usePagination.js";
10
13
  import useSelection from "./useSelection.js";
11
14
  import useSortState from "./useSortState.js";
12
15
  export { default as Dictionary } from "./Dictionary.js";
16
+ export { default as LocalStorageWatcher } from "./StateWatcher/LocalStorageWatcher.js";
13
17
  export default function DynamicTable(props) {
14
18
  const columns = useColumns(props.columns);
15
19
  const items = useItems(props.items, props.itemTarget, columns, props.canSelectItem);
16
- const { columns: filteredColumns, items: filteredItems, clearFilterState, } = useFilterState(columns, items, props.initialFilterState);
17
- const { columns: sortedColumns, items: sortedItems, clearSortState, } = useSortState(filteredColumns, filteredItems, props.initialSortState);
18
- const { allColumns, columns: displayedColumns, items: displayedItems, } = useMaskableColumns(sortedColumns, sortedItems, props.initialColumnsMaskState);
19
- const { items: paginatedItems, itemsPerPage, onItemsPerPageChange, totalPages, currentPage, onCurrentPageChange, } = usePagination(displayedItems, props.initialPaginationState);
20
+ const watcher = useMemo(() => props.watcher ??
21
+ (props.namespace === undefined ? NoopWatcher : generateLocalStorageWatcher(props.namespace)), [props.namespace, props.watcher]);
22
+ const { columns: filteredColumns, items: filteredItems, clearFilterState, } = useFilterState(columns, items, props.initialFilterState, watcher.filterState);
23
+ const { columns: sortedColumns, items: sortedItems, clearSortState, } = useSortState(filteredColumns, filteredItems, props.initialSortState, watcher.sortState);
24
+ const { allColumns, columns: displayedColumns, items: displayedItems, } = useMaskableColumns(sortedColumns, sortedItems, props.initialColumnsMaskState, watcher.columnsMaskState);
25
+ const { items: paginatedItems, itemsPerPage, onItemsPerPageChange, totalPages, currentPage, onCurrentPageChange, } = usePagination(displayedItems, props.initialPaginationState, watcher.paginationState);
20
26
  const { isInSelectionMode, totalSelectedQuantity, unvisibleSelectedQuantity, isItemSelected, onItemSelectionToggle, } = useSelection(props, paginatedItems);
21
27
  return (_jsx(UniquePopupProvider, { children: _jsxs(DynamicTable.TableContainer, { children: [_jsx(Controller, { columns: allColumns, clearFilterState: clearFilterState, clearSortState: clearSortState }), _jsxs(DynamicTable.Table, { children: [_jsx(DynamicTable.HeaderContainer, { children: _jsxs(DynamicTable.HeaderLine, { children: [isInSelectionMode && (_jsx(DynamicTable.SelectionHeader, { totalSelected: totalSelectedQuantity, unvisibleSelected: unvisibleSelectedQuantity })), displayedColumns.map((column) => (_jsx(DynamicTable.Header, { column: column, children: column.title }, column.id)))] }) }), _jsx(DynamicTable.BodyContainer, { children: paginatedItems.map((item) => (_jsxs(DynamicTable.Line, { children: [isInSelectionMode &&
22
28
  (item.isSelectable ? (_jsx(DynamicTable.SelectionCell, { selected: isItemSelected(item), onToggle: onItemSelectionToggle(item) })) : (_jsx(DynamicTable.Cell, {}))), item.target !== null
@@ -1,3 +1,4 @@
1
+ import type { SpecificStateWatcher } from "./StateWatcher/index.js";
1
2
  import type { BaseItem, Primitive } from "./index.js";
2
3
  import type { InternalColumn, InternalColumns } from "./useColumns.js";
3
4
  import type { InternalItems } from "./useItems.js";
@@ -54,7 +55,7 @@ export declare function isSelectable<Item extends BaseItem, Value extends Primit
54
55
  * This hook will add filter state & filter control on each given columns, returning decorated columns. It will also
55
56
  * apply those filters on the items list, returning a filtered list.
56
57
  */
57
- export default function useFilterState<Item extends BaseItem>(columns: InternalColumns<Item>, items: InternalItems<Item>, initialFilterState?: FilterState): {
58
+ export default function useFilterState<Item extends BaseItem>(columns: InternalColumns<Item>, items: InternalItems<Item>, initialState: FilterState | undefined, watcher: SpecificStateWatcher<FilterState> | undefined): {
58
59
  columns: InternalFilterableColumn<Item, Primitive>[];
59
60
  items: import("./useItems.js").InternalItem<Item>[];
60
61
  clearFilterState: () => void;
@@ -1,6 +1,7 @@
1
1
  import { drop } from "@vinorcola/utils/object";
2
2
  import { extractSearchableText } from "@vinorcola/utils/text";
3
- import { useCallback, useMemo, useState } from "react";
3
+ import { useCallback, useMemo } from "react";
4
+ import useWatchedState from "./useWatchedState.js";
4
5
  export function isFilterable(column) {
5
6
  return ((column.searchText !== undefined && column.onSearchTextChange !== undefined) ||
6
7
  (column.hiddenValues !== undefined && column.onSelectionChange !== undefined));
@@ -17,8 +18,8 @@ export function isSelectable(column) {
17
18
  * This hook will add filter state & filter control on each given columns, returning decorated columns. It will also
18
19
  * apply those filters on the items list, returning a filtered list.
19
20
  */
20
- export default function useFilterState(columns, items, initialFilterState = {}) {
21
- const [filterState, setFilterState] = useState(initialFilterState);
21
+ export default function useFilterState(columns, items, initialState, watcher) {
22
+ const [filterState, setFilterState] = useWatchedState({}, initialState, watcher);
22
23
  return {
23
24
  columns: useMemo(() => columns.map((column) => {
24
25
  if (column.dictionary === undefined) {
package/lib/useItems.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { displayInteger } from "@vinorcola/utils/number";
3
3
  import { extractSearchableText } from "@vinorcola/utils/text";
4
- import { Fragment, useMemo } from "react";
4
+ import { useMemo } from "react";
5
5
  import ValueList from "./ValueList.js";
6
6
  export default function useItems(items, itemTarget, columns, canSelectItem) {
7
7
  return useMemo(() => items.map((item) => ({
@@ -108,7 +108,7 @@ function resolveDisplayableSingleValue(value, dictionary, nodeKey) {
108
108
  if (dictionaryEntry === undefined) {
109
109
  return dictionary.unknownMessage;
110
110
  }
111
- return dictionaryEntry.prepend !== undefined ? (_jsxs(Fragment, { children: [dictionaryEntry.prepend, dictionaryEntry.title] }, nodeKey)) : (dictionaryEntry.title);
111
+ return dictionaryEntry.prepend !== undefined ? (_jsxs(dictionary.valueWrapper, { children: [dictionaryEntry.prepend, dictionaryEntry.title] }, nodeKey)) : (dictionaryEntry.title);
112
112
  }
113
113
  if (typeof value === "boolean") {
114
114
  return value ? "true" : "false";
@@ -0,0 +1 @@
1
+ export default function useLazyInitializedState<State>(defaultState: State, initial: State | undefined, lazyInitializer: (() => Promise<State | null>) | undefined): [State, import("react").Dispatch<import("react").SetStateAction<State>>];
@@ -0,0 +1,16 @@
1
+ import { useEffect, useState } from "react";
2
+ export default function useLazyInitializedState(defaultState, initial, lazyInitializer) {
3
+ const state = useState(initial ?? defaultState);
4
+ useEffect(() => {
5
+ if (initial !== undefined) {
6
+ return;
7
+ }
8
+ lazyInitializer?.().then((initialState) => {
9
+ if (initialState === null) {
10
+ return;
11
+ }
12
+ state[1](initialState);
13
+ });
14
+ }, []); // eslint-disable-line react-hooks-configurable/exhaustive-deps
15
+ return state;
16
+ }
@@ -1,3 +1,4 @@
1
+ import type { SpecificStateWatcher } from "./StateWatcher/index.js";
1
2
  import type { BaseItem, Primitive } from "./index.js";
2
3
  import type { InternalColumn } from "./useColumns.js";
3
4
  import type { InternalItems } from "./useItems.js";
@@ -22,7 +23,7 @@ export declare function isMaskable<Item extends BaseItem, Value extends Primitiv
22
23
  * Note that the returned `columns` contains only the displayed columns, while `allColumns` contains all the columns
23
24
  * (for control purpose).
24
25
  */
25
- export default function useMaskableColumns<Item extends BaseItem>(columns: InternalColumn<Item, Primitive>[], items: InternalItems<Item>, initialHiddenColumns?: ColumnsMaskState): {
26
+ export default function useMaskableColumns<Item extends BaseItem>(columns: InternalColumn<Item, Primitive>[], items: InternalItems<Item>, initialState: ColumnsMaskState | undefined, watcher: SpecificStateWatcher<ColumnsMaskState> | undefined): {
26
27
  allColumns: InternalMaskableColumn<Item, Primitive>[];
27
28
  columns: InternalMaskableColumn<Item, Primitive>[];
28
29
  items: {
@@ -1,4 +1,5 @@
1
- import { useMemo, useState } from "react";
1
+ import { useMemo } from "react";
2
+ import useWatchedState from "./useWatchedState.js";
2
3
  export function isMaskable(column) {
3
4
  return column.displayed !== undefined && column.onDisplayToggle !== undefined;
4
5
  }
@@ -11,8 +12,8 @@ export function isMaskable(column) {
11
12
  * Note that the returned `columns` contains only the displayed columns, while `allColumns` contains all the columns
12
13
  * (for control purpose).
13
14
  */
14
- export default function useMaskableColumns(columns, items, initialHiddenColumns = []) {
15
- const [hidden, setHidden] = useState(initialHiddenColumns);
15
+ export default function useMaskableColumns(columns, items, initialState, watcher) {
16
+ const [hidden, setHidden] = useWatchedState([], initialState, watcher);
16
17
  const allColumns = useMemo(() => columns.map((column) => {
17
18
  const displayed = !hidden.includes(column.id);
18
19
  return {
@@ -1,3 +1,4 @@
1
+ import type { SpecificStateWatcher } from "./StateWatcher/index.js";
1
2
  import type { BaseItem } from "./index.js";
2
3
  import type { InternalItems } from "./useItems.js";
3
4
  /**
@@ -12,7 +13,7 @@ export interface PaginationState {
12
13
  *
13
14
  * Return pagination state & pagination control as well as items to display on the current page.
14
15
  */
15
- export default function usePagination<Item extends BaseItem>(items: InternalItems<Item>, initialPaginationState?: PaginationState): {
16
+ export default function usePagination<Item extends BaseItem>(items: InternalItems<Item>, initialState: PaginationState | undefined, watcher: SpecificStateWatcher<PaginationState> | undefined): {
16
17
  items: import("./useItems.js").InternalItem<Item>[];
17
18
  itemsPerPage: number;
18
19
  onItemsPerPageChange: (itemsPerPage: number) => void;
@@ -1,11 +1,12 @@
1
- import { useCallback, useMemo, useState } from "react";
1
+ import { useCallback, useMemo } from "react";
2
+ import useWatchedState from "./useWatchedState.js";
2
3
  /**
3
4
  * Apply pagination.
4
5
  *
5
6
  * Return pagination state & pagination control as well as items to display on the current page.
6
7
  */
7
- export default function usePagination(items, initialPaginationState = { currentPage: 1, itemsPerPage: 12 }) {
8
- const [pagination, setPagination] = useState(initialPaginationState);
8
+ export default function usePagination(items, initialState, watcher) {
9
+ const [pagination, setPagination] = useWatchedState({ currentPage: 1, itemsPerPage: 12 }, initialState, watcher);
9
10
  const totalPages = useMemo(() => Math.ceil(items.length / pagination.itemsPerPage), [items, pagination]);
10
11
  const currentPage = useMemo(() => Math.max(1, Math.min(totalPages, pagination.currentPage)), [totalPages, pagination]);
11
12
  return {
@@ -1,3 +1,4 @@
1
+ import type { SpecificStateWatcher } from "./StateWatcher/index.js";
1
2
  import type { BaseItem, Primitive } from "./index.js";
2
3
  import type { InternalColumn, InternalColumns } from "./useColumns.js";
3
4
  import type { InternalItems, InternalItem } from "./useItems.js";
@@ -34,7 +35,7 @@ export declare function isSortable<Item extends BaseItem, Value extends Primitiv
34
35
  * This hook will add sort state & sort control on each given columns, returning decorated columns. It will also apply
35
36
  * those sorts on the items list, returning a sorted list.
36
37
  */
37
- export default function useSortState<Item extends BaseItem>(columns: InternalColumns<Item>, items: InternalItems<Item>, initialSortState?: SortState): {
38
+ export default function useSortState<Item extends BaseItem>(columns: InternalColumns<Item>, items: InternalItems<Item>, initialState: SortState | undefined, watcher: SpecificStateWatcher<SortState> | undefined): {
38
39
  columns: InternalSortableColumn<Item, Primitive>[];
39
40
  items: InternalItem<Item>[];
40
41
  clearSortState: () => void;
@@ -1,5 +1,6 @@
1
1
  import { dropElement, replaceElement } from "@vinorcola/utils/list";
2
- import { useCallback, useMemo, useState } from "react";
2
+ import { useCallback, useMemo } from "react";
3
+ import useWatchedState from "./useWatchedState.js";
3
4
  export function isSortable(column) {
4
5
  return column.sorted !== undefined && column.onSortToggle !== undefined;
5
6
  }
@@ -9,8 +10,8 @@ export function isSortable(column) {
9
10
  * This hook will add sort state & sort control on each given columns, returning decorated columns. It will also apply
10
11
  * those sorts on the items list, returning a sorted list.
11
12
  */
12
- export default function useSortState(columns, items, initialSortState = []) {
13
- const [sortState, setSortState] = useState(initialSortState);
13
+ export default function useSortState(columns, items, initialState, watcher) {
14
+ const [sortState, setSortState] = useWatchedState([], initialState, watcher);
14
15
  return {
15
16
  columns: useMemo(() => columns.map((column) => {
16
17
  const columnSortStateIndex = sortState.findIndex((columnSortState) => columnSortState.columnId === column.id);
@@ -0,0 +1,3 @@
1
+ import type { Dispatch, SetStateAction } from "react";
2
+ import type { SpecificStateWatcher } from "./StateWatcher";
3
+ export default function useWatchedState<State>(defaultState: State, initial: State | undefined, watcher: SpecificStateWatcher<State> | undefined): readonly [State, Dispatch<SetStateAction<State>>];
@@ -0,0 +1,25 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+ export default function useWatchedState(defaultState, initial, watcher) {
3
+ const [state, setState] = useState(initial ?? defaultState);
4
+ useEffect(() => {
5
+ if (initial !== undefined) {
6
+ return;
7
+ }
8
+ watcher?.loadInitial?.().then((initialState) => {
9
+ if (initialState === null) {
10
+ return;
11
+ }
12
+ setState(initialState);
13
+ });
14
+ }, []); // eslint-disable-line react-hooks-configurable/exhaustive-deps
15
+ return [
16
+ state,
17
+ useCallback((updater) => {
18
+ setState((state) => {
19
+ const newState = typeof updater === "function" ? updater(state) : updater;
20
+ watcher?.onChange?.(newState);
21
+ return newState;
22
+ });
23
+ }, []), // eslint-disable-line react-hooks-configurable/exhaustive-deps
24
+ ];
25
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vinorcola/dynamic-table",
3
- "version": "1.3.0",
3
+ "version": "1.4.1",
4
4
  "description": "A table to ease the display of a filterable, sortable and paginable dataset.",
5
5
  "keywords": [
6
6
  "table",
@@ -44,6 +44,7 @@
44
44
  "devDependencies": {
45
45
  "@types/react": "^19.2.2",
46
46
  "@vinorcola/lint": "^1.0.5",
47
+ "eslint-plugin-react-hooks-configurable": "^7.1.1",
47
48
  "typescript": "^5.9.3"
48
49
  },
49
50
  "peerDependencies": {