@benjosivo/table-query 1.0.4 → 1.2.0

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.
@@ -0,0 +1,121 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { newRuleId, sanitizeFormattingRules } from './formatting.js';
3
+ /** Namespaced so a storage key can't collide with the host app's own localStorage entries. */
4
+ const STORAGE_PREFIX = 'tableQuery:formatting:';
5
+ const STORAGE_VERSION = 1;
6
+ function serialize(rules, disabled) {
7
+ return JSON.stringify({ v: STORAGE_VERSION, rules, disabled });
8
+ }
9
+ /** Inherited rules need a stable id even when the source didn't give them one. */
10
+ function withFallbackIds(rules, source) {
11
+ if (!rules || rules.length === 0)
12
+ return [];
13
+ return rules.map((r, i) => (r.id ? r : { ...r, id: `${source}:${i}` }));
14
+ }
15
+ /**
16
+ * Owns the three formatting layers and their persistence. Lives in a hook rather than in
17
+ * `DataTable` so that a host composing `useDataTable` + `Table` by hand can reuse it.
18
+ */
19
+ export function useFormattingRules({ propsRules, serverRules, storageKey, initialUserRules, onChange, }) {
20
+ const [userRules, setUserRules] = useState(() => (initialUserRules ? sanitizeFormattingRules(initialUserRules) : []));
21
+ const [disabledIds, setDisabledIds] = useState([]);
22
+ const fullKey = storageKey ? `${STORAGE_PREFIX}${storageKey}` : undefined;
23
+ // Hydration is STATE, not a ref: both effects run in the same commit on mount, so a ref
24
+ // flipped by the read effect would already read true in the write effect below — which
25
+ // would rewrite [] over the rules just loaded and fire a spurious onChange([]).
26
+ const [hydrated, setHydrated] = useState(false);
27
+ // What is already in storage, so an unchanged value never triggers a write or an onChange.
28
+ const lastWritten = useRef(null);
29
+ // Read post-mount, never in the useState initializer: with SSR the server renders no user
30
+ // rules, so reading during the first render would cause a hydration mismatch on the styles.
31
+ useEffect(() => {
32
+ // Baseline = the state as it stands before hydration, so the first write-effect pass is
33
+ // a no-op. Without it, mounting with nothing stored would fire onChange([]) and wipe the
34
+ // rules of a host that persists them server-side.
35
+ lastWritten.current = serialize(userRules, disabledIds);
36
+ if (initialUserRules || !fullKey || typeof window === 'undefined') {
37
+ setHydrated(true);
38
+ return;
39
+ }
40
+ try {
41
+ const raw = window.localStorage.getItem(fullKey);
42
+ if (raw) {
43
+ const parsed = JSON.parse(raw);
44
+ // An unknown version is ignored rather than migrated; the next write replaces it.
45
+ if (parsed && parsed.v === STORAGE_VERSION) {
46
+ const rules = sanitizeFormattingRules(parsed.rules);
47
+ const disabled = Array.isArray(parsed.disabled) ? parsed.disabled.filter((x) => typeof x === 'string') : [];
48
+ setUserRules(rules);
49
+ setDisabledIds(disabled);
50
+ lastWritten.current = serialize(rules, disabled);
51
+ }
52
+ }
53
+ }
54
+ catch {
55
+ // SSR, Safari private mode, blocked storage, corrupted JSON — all non-fatal.
56
+ }
57
+ setHydrated(true);
58
+ // eslint-disable-next-line react-hooks/exhaustive-deps
59
+ }, [fullKey]);
60
+ useEffect(() => {
61
+ if (!hydrated)
62
+ return;
63
+ const serialized = serialize(userRules, disabledIds);
64
+ // Nothing actually changed (the mount pass, or a re-render): don't write, don't notify.
65
+ if (lastWritten.current === serialized)
66
+ return;
67
+ lastWritten.current = serialized;
68
+ if (fullKey && typeof window !== 'undefined') {
69
+ try {
70
+ window.localStorage.setItem(fullKey, serialized);
71
+ }
72
+ catch {
73
+ // Quota exceeded or storage blocked: the rules still work for this session.
74
+ }
75
+ }
76
+ onChange?.(userRules);
77
+ // eslint-disable-next-line react-hooks/exhaustive-deps
78
+ }, [userRules, disabledIds, fullKey, hydrated]);
79
+ const inherited = useMemo(() => [...withFallbackIds(propsRules, 'props'), ...withFallbackIds(serverRules, 'server')], [propsRules, serverRules]);
80
+ /**
81
+ * Precedence: props -> server -> user. Evaluation order IS precedence order, so a later
82
+ * rule wins per CSS property and an earlier `stopIfTrue` can block a later layer.
83
+ */
84
+ const rules = useMemo(() => {
85
+ const disabled = new Set(disabledIds);
86
+ return [...inherited, ...userRules].filter((r) => r.enabled !== false && !(r.id && disabled.has(r.id)));
87
+ }, [inherited, userRules, disabledIds]);
88
+ const addRule = useCallback((rule) => {
89
+ setUserRules((prev) => [...prev, { ...rule, id: rule.id || newRuleId() }]);
90
+ }, []);
91
+ const updateRule = useCallback((id, patch) => {
92
+ setUserRules((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)));
93
+ }, []);
94
+ const removeRule = useCallback((id) => {
95
+ setUserRules((prev) => prev.filter((r) => r.id !== id));
96
+ }, []);
97
+ const moveRule = useCallback((id, delta) => {
98
+ setUserRules((prev) => {
99
+ const i = prev.findIndex((r) => r.id === id);
100
+ const j = i + delta;
101
+ if (i < 0 || j < 0 || j >= prev.length)
102
+ return prev;
103
+ const next = [...prev];
104
+ [next[i], next[j]] = [next[j], next[i]];
105
+ return next;
106
+ });
107
+ }, []);
108
+ const setRuleDisabled = useCallback((id, disabled) => {
109
+ setDisabledIds((prev) => {
110
+ const has = prev.includes(id);
111
+ if (disabled === has)
112
+ return prev;
113
+ return disabled ? [...prev, id] : prev.filter((x) => x !== id);
114
+ });
115
+ }, []);
116
+ const resetUserRules = useCallback(() => {
117
+ setUserRules([]);
118
+ setDisabledIds([]);
119
+ }, []);
120
+ return { rules, userRules, inherited, disabledIds, addRule, updateRule, removeRule, moveRule, setRuleDisabled, resetUserRules };
121
+ }
@@ -8,3 +8,11 @@ export declare function truncatedJSON(value: unknown, maxLen?: number): string;
8
8
  export declare function getRowId(row: Record<string, any> | undefined): any;
9
9
  /** Ids come from the data (numbers, strings, ...) and can be compared to values given by the parent, so normalize them. */
10
10
  export declare function selectionKey(id: any): string;
11
+ /**
12
+ * Column names of a table: the keys of the first row, falling back to the filter
13
+ * definitions when there is no data. Shared by `Table` and the formatting editor so the
14
+ * two can't drift apart.
15
+ */
16
+ export declare function columnNamesOf(items: Record<string, any>[], paramFilter?: {
17
+ nom: string;
18
+ }[]): string[];
@@ -49,3 +49,13 @@ export function getRowId(row) {
49
49
  export function selectionKey(id) {
50
50
  return String(id);
51
51
  }
52
+ /**
53
+ * Column names of a table: the keys of the first row, falling back to the filter
54
+ * definitions when there is no data. Shared by `Table` and the formatting editor so the
55
+ * two can't drift apart.
56
+ */
57
+ export function columnNamesOf(items, paramFilter) {
58
+ if (items[0])
59
+ return Object.keys(items[0]);
60
+ return paramFilter && paramFilter.length > 0 ? paramFilter.map((el) => el.nom) : [];
61
+ }
@@ -1,5 +1,5 @@
1
1
  import type { CacheDeps, ParamFilterType, ReqTableQueryOptions, TableQueryDeps } from './types.js';
2
- export type { CacheDeps, ParamFilterType, ReqTableQueryOptions, TableQueryDeps } from './types.js';
2
+ export type { CacheDeps, FormattingRuleInput, ParamFilterType, ReqTableQueryOptions, TableQueryDeps } from './types.js';
3
3
  /**
4
4
  * Creates the table-query module (router + query helpers) bound to this project's own
5
5
  * date formatting / route-wrapping / cache implementations. Nothing here is hardcoded to
@@ -11,8 +11,8 @@ export type { CacheDeps, ParamFilterType, ReqTableQueryOptions, TableQueryDeps }
11
11
  export declare function createTableQueryModule(deps: TableQueryDeps): {
12
12
  router: import("express-serve-static-core").Router;
13
13
  reqTableQuery: (opt: ReqTableQueryOptions) => Promise<{
14
- error: string;
15
- status: number;
14
+ error: string | undefined;
15
+ status: number | undefined;
16
16
  empty?: undefined;
17
17
  data?: undefined;
18
18
  } | {
@@ -52,7 +52,7 @@ export function createTableQueryModule(deps) {
52
52
  sendFiltres();
53
53
  }
54
54
  async function reqTableQuery(opt) {
55
- const { query, req, paramFilter, sort, argsQuery, keepCache = 5 * 60 * 1000 } = opt;
55
+ const { query, req, paramFilter, sort, argsQuery, formattingRules, keepCache = 5 * 60 * 1000 } = opt;
56
56
  if (!req.body && !req.query) {
57
57
  return { error: `req.query or req.body is missing`, status: 400 };
58
58
  }
@@ -69,11 +69,27 @@ export function createTableQueryModule(deps) {
69
69
  // ── Paginated items ────────────────────────────────────────────────
70
70
  const itemsSql = `${query} ${whereClause} ORDER BY ${sorting} LIMIT ${limit < 0 ? 0 : limit} OFFSET ${offset < 0 ? 0 : offset}`;
71
71
  let { payload, status, error, empty } = await getDataFromQuery(itemsSql, argsQuery, cache ? keepCache : 0, cache);
72
+ // getDataFromQuery returns { status, error } with no payload when the query fails.
73
+ if (!payload)
74
+ return { error, status };
72
75
  payload.paramFilter = paramFilter
73
76
  ? paramFilter.map((param, i) => {
74
77
  return { nom: payload.fieldsType[i].fieldName, type: param };
75
78
  })
76
79
  : [];
80
+ // Forwarded untouched — formatting is presentation, it never reaches the SQL.
81
+ // Resolved by column NAME against the very same source `paramFilter` is zipped against,
82
+ // so the two can't drift. Sent on every request (a few hundred bytes), not gated behind
83
+ // setFilter, so the client needs no extra round-trip.
84
+ if (formattingRules?.length) {
85
+ const names = new Set((payload.fieldsType ?? []).map((f) => f.fieldName));
86
+ const kept = formattingRules.filter((r) => r && typeof r.column === 'string' && names.has(r.column));
87
+ const dropped = formattingRules.length - kept.length;
88
+ // A bad column name is a presentation mistake: warn, don't take the table down.
89
+ if (dropped)
90
+ console.warn(`[table-query] ${dropped} règle(s) de mise en forme ignorée(s) : colonne inconnue.`);
91
+ payload.formattingRules = kept;
92
+ }
77
93
  if (empty)
78
94
  return { empty, data: payload };
79
95
  if (error && status)
@@ -1,5 +1,24 @@
1
1
  import type { Request, Response } from 'express';
2
2
  export type ParamFilterType = 'HIDE' | 'SLIDER' | 'DATE' | 'DATETIME' | 'UNGROUP_MULTISELECT' | 'MULTISELECT' | null | 'JSON' | 'FILE';
3
+ /**
4
+ * Same shape as the React-side `FormattingRule`, but with no dependency on @types/react:
5
+ * the server only ever forwards these rules, it never evaluates them, and a server-only
6
+ * consumer must not be forced to install React's types to compile.
7
+ */
8
+ export interface FormattingRuleInput {
9
+ id?: string;
10
+ label?: string;
11
+ /** Name of the tested column, matched against the query's own column names. */
12
+ column: string;
13
+ operator: string;
14
+ value?: unknown;
15
+ valueType?: 'auto' | 'string' | 'number' | 'date' | 'boolean';
16
+ target?: 'row' | 'cell' | string[];
17
+ style?: Record<string, string | number>;
18
+ className?: string;
19
+ stopIfTrue?: boolean;
20
+ enabled?: boolean;
21
+ }
3
22
  export interface CacheDeps {
4
23
  getSQLCache: (key: string) => Promise<any>;
5
24
  setSQLCache: (key: string, value: any) => Promise<any>;
@@ -20,6 +39,9 @@ export interface ReqTableQueryOptions {
20
39
  argsQuery?: any[];
21
40
  /** How long (ms) a cached result stays valid. Only used when `useCache` resolves to true. */
22
41
  keepCache?: number;
42
+ /** Conditional formatting rules forwarded as-is to the client in `payload.formattingRules`.
43
+ * Rules whose `column` matches no column of the query are dropped. */
44
+ formattingRules?: FormattingRuleInput[];
23
45
  /** Use the Redis cache for this call. Defaults to true if `deps.cache` was provided at
24
46
  * module creation, false otherwise. Pass `false` explicitly to always force a fresh query
25
47
  * even when the module has a cache configured (e.g. for a "live" screen). */
package/package.json CHANGED
@@ -1,61 +1,61 @@
1
- {
2
- "name": "@benjosivo/table-query",
3
- "version": "1.0.4",
4
- "description": "Table triable/filtrable/paginée : hook + composants React d'un côté (`/react`), logique SQL de pagination/tri/filtres côté serveur de l'autre (`/server`). Un projet peut n'utiliser qu'un des deux côtés.",
5
- "keywords": [],
6
- "homepage": "https://github.com/benjosivo/table-query#readme",
7
- "bugs": {
8
- "url": "https://github.com/benjosivo/table-query/issues"
9
- },
10
- "repository": {
11
- "type": "git",
12
- "url": "git+https://github.com/benjosivo/table-query.git"
13
- },
14
- "license": "ISC",
15
- "author": "benjosivo",
16
- "type": "module",
17
- "exports": {
18
- "./react": {
19
- "types": "./dist/react/index.d.ts",
20
- "default": "./dist/react/index.js"
21
- },
22
- "./server": {
23
- "types": "./dist/server/index.d.ts",
24
- "default": "./dist/server/index.js"
25
- }
26
- },
27
- "main": "index.js",
28
- "files": [
29
- "dist"
30
- ],
31
- "scripts": {
32
- "build": "tsc",
33
- "prepublishOnly": "npm run build"
34
- },
35
- "dependencies": {
36
- "@benjosivo/mysql": "^1.3.2",
37
- "express": "^5.2.1"
38
- },
39
- "devDependencies": {
40
- "@types/express": "^5.0.6",
41
- "@types/react": "^18.0.0",
42
- "typescript": "^5.4.0"
43
- },
44
- "peerDependencies": {
45
- "react": "^18.0.0 || ^19.0.0"
46
- },
47
- "peerDependenciesMeta": {
48
- "express": {
49
- "optional": true
50
- },
51
- "react": {
52
- "optional": true
53
- },
54
- "@benjosivo/mysql": {
55
- "optional": true
56
- }
57
- },
58
- "publishConfig": {
59
- "registry": "https://registry.npmjs.org"
60
- }
61
- }
1
+ {
2
+ "name": "@benjosivo/table-query",
3
+ "version": "1.2.0",
4
+ "description": "Table triable/filtrable/paginée : hook + composants React d'un côté (`/react`), logique SQL de pagination/tri/filtres côté serveur de l'autre (`/server`). Un projet peut n'utiliser qu'un des deux côtés.",
5
+ "keywords": [],
6
+ "homepage": "https://github.com/benjosivo/table-query#readme",
7
+ "bugs": {
8
+ "url": "https://github.com/benjosivo/table-query/issues"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/benjosivo/table-query.git"
13
+ },
14
+ "license": "ISC",
15
+ "author": "benjosivo",
16
+ "type": "module",
17
+ "exports": {
18
+ "./react": {
19
+ "types": "./dist/react/index.d.ts",
20
+ "default": "./dist/react/index.js"
21
+ },
22
+ "./server": {
23
+ "types": "./dist/server/index.d.ts",
24
+ "default": "./dist/server/index.js"
25
+ }
26
+ },
27
+ "main": "index.js",
28
+ "files": [
29
+ "dist"
30
+ ],
31
+ "scripts": {
32
+ "build": "tsc",
33
+ "prepublishOnly": "npm run build"
34
+ },
35
+ "dependencies": {
36
+ "@benjosivo/mysql": "^1.3.2",
37
+ "express": "^5.2.1"
38
+ },
39
+ "devDependencies": {
40
+ "@types/express": "^5.0.6",
41
+ "@types/react": "^18.0.0",
42
+ "typescript": "^5.4.0"
43
+ },
44
+ "peerDependencies": {
45
+ "react": "^18.0.0 || ^19.0.0"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "express": {
49
+ "optional": true
50
+ },
51
+ "react": {
52
+ "optional": true
53
+ },
54
+ "@benjosivo/mysql": {
55
+ "optional": true
56
+ }
57
+ },
58
+ "publishConfig": {
59
+ "registry": "https://registry.npmjs.org"
60
+ }
61
+ }