From 5c679df6ae433d23feef6b07f4a0e2ca24319fd8 Mon Sep 17 00:00:00 2001 From: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:48:56 +1200 Subject: [PATCH 01/11] Add scaffold and test coverage --- jest.config.mjs | 13 +- packages/utils/src/filters/index.ts | 85 ++++ packages/utils/src/filters/types.ts | 29 ++ packages/utils/src/index.ts | 4 + packages/utils/test/filters.editor.test.tsx | 99 ++++ packages/utils/test/filters.test.ts | 497 ++++++++++++++++++++ packages/utils/test/tsconfig.json | 22 + 7 files changed, 748 insertions(+), 1 deletion(-) create mode 100644 packages/utils/src/filters/index.ts create mode 100644 packages/utils/src/filters/types.ts create mode 100644 packages/utils/test/filters.editor.test.tsx create mode 100644 packages/utils/test/filters.test.ts create mode 100644 packages/utils/test/tsconfig.json diff --git a/jest.config.mjs b/jest.config.mjs index 78c0c30c..095ef318 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -1,19 +1,30 @@ /** @type {import('jest').Config} */ export default { testEnvironment: 'jsdom', - testMatch: ['/test/**/*.test.{ts,tsx}'], + testMatch: [ + '/test/**/*.test.{ts,tsx}', + // Workspace-package tests live under their own package (keeps the root + // `test/` dir focused on core) but run in this same shared Jest pass. + '/packages/*/test/**/*.test.{ts,tsx}', + ], setupFilesAfterEnv: ['/test/setupTests.ts'], transform: { '^.+\\.tsx?$': ['ts-jest', { tsconfig: '/test/tsconfig.json' }], }, moduleNameMapper: { '\\.css$': '/test/style-mock.js', + // The `/utils` package imports core by its package name (`json-edit-react`, + // a peer dep). In tests, resolve that to core's live source so helpers are + // exercised against the current `src/`, not a stale `build/`. + '^json-edit-react$': '/src/index.ts', }, modulePathIgnorePatterns: [ '/build', '/build_package', '/demo', '/pack-output', + // Per-package Rollup output — bundled copies would collide in the haste map. + '/packages/[^/]+/build', // Claude Code creates git worktrees here — each is a full repo copy, so its // `package.json` collides with this one in Jest's haste map ("json-edit-react // looked up in the Haste module map ... several different files"). diff --git a/packages/utils/src/filters/index.ts b/packages/utils/src/filters/index.ts new file mode 100644 index 00000000..93e097e2 --- /dev/null +++ b/packages/utils/src/filters/index.ts @@ -0,0 +1,85 @@ +import type { FilterPredicate, NodeValueType, PathPattern, Range } from './types' + +// Public types. `Range` is intentionally NOT re-exported (see types.ts). +export type { FilterPredicate, NodeValueType, PathPattern } from './types' + +// --------------------------------------------------------------------------- +// SCAFFOLD — signatures only. Implemented one at a time (#343, step 3). Each +// throws until built, so the test suite fails loudly rather than passing on a +// stub's accidental return value (no false greens). +// --------------------------------------------------------------------------- +const TODO = (name: string): never => { + throw new Error(`[@json-edit-react/utils] filters: "${name}" not implemented yet`) +} + +// --- Property builders ------------------------------------------------------ + +/** Matches when the node's own key is one of `keys` (string/number = exact on + * the stringified key; RegExp = tested against the stringified key). */ +export const byKey = (...keys: Array): FilterPredicate => + (void keys, TODO('byKey')) + +/** Matches when the node's path matches `pattern` (glob string, RegExp, or + * explicit segment array). */ +export const byPath = (pattern: PathPattern): FilterPredicate => (void pattern, TODO('byPath')) + +/** Matches when the node's depth is within `range` (root = level 0). */ +export const byLevel = (range: Range): FilterPredicate => (void range, TODO('byLevel')) + +/** Matches when a collection's child count is within `range`. Leaves (no size) + * never match. */ +export const bySize = (range: Range): FilterPredicate => (void range, TODO('bySize')) + +/** Matches when the node's value type is one of `types` (incl. `'object'` / + * `'array'`). */ +export const byType = (...types: NodeValueType[]): FilterPredicate => + (void types, TODO('byType')) + +/** Matches when the node's value equals one of `values` (RegExp is tested + * against the stringified value). */ +export const byValue = ( + ...values: Array +): FilterPredicate => (void values, TODO('byValue')) + +// --- Node-position constants ------------------------------------------------ + +/** The root node (level 0). */ +export const root: FilterPredicate = () => TODO('root') + +/** Objects and arrays. */ +export const collections: FilterPredicate = () => TODO('collections') + +/** Leaf values (everything that isn't a collection). */ +export const primitives: FilterPredicate = () => TODO('primitives') + +/** Nodes whose parent is an array. */ +export const inArray: FilterPredicate = () => TODO('inArray') + +/** Nodes whose parent is an object. */ +export const inObject: FilterPredicate = () => TODO('inObject') + +// --- Combinators ------------------------------------------------------------ + +/** True when every predicate matches. `and()` (no args) is always true. */ +export const and = (...preds: FilterPredicate[]): FilterPredicate => (void preds, TODO('and')) + +/** True when any predicate matches. `or()` (no args) is always false. */ +export const or = (...preds: FilterPredicate[]): FilterPredicate => (void preds, TODO('or')) + +/** Negates a predicate. */ +export const not = (pred: FilterPredicate): FilterPredicate => (void pred, TODO('not')) + +// --- Search bridges --------------------------------------------------------- + +/** Wraps core's `matchNode` / `matchNodeKey` against the current `searchText`. + * `mode` defaults to `'value'` — the editor's own default `searchFilter`. */ +export const matchesSearch = (mode: 'key' | 'value' | 'all' = 'value'): FilterPredicate => + (void mode, TODO('matchesSearch')) + +/** Reveals a whole record when one of its `fields` values matches `searchText`. + * `path` (a `byPath` pattern, default `'*'` = top-level items) locates the + * record layer. */ +export const matchRecord = (options: { + fields: string[] + path?: PathPattern +}): FilterPredicate => (void options, TODO('matchRecord')) diff --git a/packages/utils/src/filters/types.ts b/packages/utils/src/filters/types.ts new file mode 100644 index 00000000..2bacafe8 --- /dev/null +++ b/packages/utils/src/filters/types.ts @@ -0,0 +1,29 @@ +import type { JsonData, NodeData } from 'json-edit-react' + +/** + * The single predicate type the whole kit produces and consumes. The second + * argument is OPTIONAL — that's the load-bearing detail: a predicate of this + * shape is assignable to BOTH `FilterFunction` (the `allow*` props, called with + * one arg) AND `SearchFilterFunction` (`searchFilter`, called with two). One + * set of builders therefore serves every filter prop with no search-specific + * variants. (See the design discussion on #343.) + */ +export type FilterPredicate = (node: NodeData, searchText?: string) => boolean + +/** The JSON value kinds `byType` understands, including the two collections. */ +export type NodeValueType = 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array' + +/** + * How a path is matched: a glob string (`'users.*.email'`), a RegExp tested + * against the stringified path, or an explicit segment array — the escape hatch + * for keys that contain a literal `.`. + */ +export type PathPattern = string | RegExp | Array + +/** + * A numeric range for `byLevel` / `bySize`. A bare number means "exactly this"; + * the object form bounds one or both ends (an omitted end is unbounded). Bounds + * are inclusive. Kept internal (not re-exported) — `Range` is also a DOM global, + * so we don't want it on the package's public surface. + */ +export type Range = number | { min?: number; max?: number } diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 0b2a5459..b8fc102f 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -12,6 +12,9 @@ // - Reactive validation — useValidationState / validationStyles / // ajvAdapter, plus the useStableValue primitive they build on // https://github.com/CarlosNZ/json-edit-react/issues/357 +// - Filter-function toolkit — composable predicate builders (byKey, +// byPath, byLevel, …) + and/or/not for the allow* props and searchFilter +// https://github.com/CarlosNZ/json-edit-react/issues/343 // - JSON Schema → Filter Functions generator [planned] // https://github.com/CarlosNZ/json-edit-react/issues/285 // - Ready-made `searchFilter` helpers for common search use cases [planned] @@ -25,3 +28,4 @@ export * from './confirm-update' export * from './undo' export * from './stable-value' export * from './validation' +export * from './filters' diff --git a/packages/utils/test/filters.editor.test.tsx b/packages/utils/test/filters.editor.test.tsx new file mode 100644 index 00000000..100be595 --- /dev/null +++ b/packages/utils/test/filters.editor.test.tsx @@ -0,0 +1,99 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { JsonEditor } from 'json-edit-react' +import { and, byKey, byPath, matchesSearch, matchRecord, not } from '../src' + +// Integration layer for the filter kit: render a real and assert +// on the DOM. The unit suite (filters.test.ts) owns per-builder logic against +// synthetic NodeData; this file proves the complementary half — that the editor +// actually CONSUMES these predicates (searchFilter hides nodes, allow* gates the +// UI), that `searchText` is threaded through to them, and (since matchRecord / +// byPath lean on the editor-built `fullData`/`path`) that the real NodeData +// matches what the unit harness assumes. Deliberately small: a few high-value +// wirings, not every builder re-tested through the editor. +// +// Assertions use core Jest matchers only (toBeNull / queryByRole) — no jest-dom +// — so the file needs no extra ambient types. + +const noop = () => {} + +// A top-level array of records — the "Client list" shape, so matchRecord's +// default path ('*' = top-level items) targets each person. +const people = [ + { id: 1, name: 'Leanne Graham', username: 'Bret', email: 'leanne@example.com' }, + { id: 2, name: 'Ervin Howell', username: 'Antonette', email: 'ervin@example.com' }, +] + +describe('filter kit — wired into a live ', () => { + describe('searchFilter', () => { + it('matchesSearch filters the rendered tree (searchText threaded in)', () => { + render( + + ) + expect(screen.queryByText('"Leanne Graham"')).not.toBeNull() + expect(screen.queryByText('"Ervin Howell"')).toBeNull() + }) + + it('matchRecord reveals a whole record from the editor-built NodeData', () => { + render( + + ) + // The matching record shows in full — a field that does NOT itself + // contain the search text is still visible — and the other record is gone. + expect(screen.queryByText('"Ervin Howell"')).not.toBeNull() + expect(screen.queryByText('"ervin@example.com"')).not.toBeNull() + expect(screen.queryByText('"Leanne Graham"')).toBeNull() + }) + + it('composes a path scope with a value search', () => { + // Both emails contain "example.com"; the path scope keeps only record [1]. + render( + + ) + expect(screen.queryByText('"ervin@example.com"')).not.toBeNull() + expect(screen.queryByText('"leanne@example.com"')).toBeNull() + }) + }) + + describe('allow* props', () => { + it('allowEdit={not(byKey("id"))} gates the edit affordance per node', async () => { + const user = userEvent.setup() + render( + + ) + + const idRow = screen.getByText('"X1"').closest('.jer-component') as HTMLElement + const nameRow = screen.getByText('"Leanne"').closest('.jer-component') as HTMLElement + + // id is locked, name is editable. + expect(idRow.querySelector('[title="Edit"]')).toBeNull() + expect(nameRow.querySelector('[title="Edit"]')).not.toBeNull() + + // End-to-end: dblClick opens an input on name, but not on the locked id. + await user.dblClick(screen.getByText('"X1"')) + expect(screen.queryByRole('textbox')).toBeNull() + await user.dblClick(screen.getByText('"Leanne"')) + expect(screen.queryByRole('textbox')).not.toBeNull() + }) + }) +}) diff --git a/packages/utils/test/filters.test.ts b/packages/utils/test/filters.test.ts new file mode 100644 index 00000000..1df8dee2 --- /dev/null +++ b/packages/utils/test/filters.test.ts @@ -0,0 +1,497 @@ +import type { JsonData, NodeData } from 'json-edit-react' +import { + and, + byKey, + byLevel, + byPath, + bySize, + byType, + byValue, + collections, + inArray, + inObject, + matchesSearch, + matchRecord, + not, + or, + primitives, + root, + type FilterPredicate, +} from '../src' + +// --------------------------------------------------------------------------- +// One shared document, probed from every angle below. Read the tree alongside +// the expectations — each row notes the value type, level, and (for collections) +// child count, plus whether the parent is an array. `"schema.url"` deliberately +// contains a dot to exercise the segment-array escape hatch. +// +// (root) obj L0 size 3 +// ├─ meta obj L1 size 4 +// │ ├─ version 2 num L2 +// │ ├─ "schema.url" "https://…" str L2 ← key has a dot +// │ ├─ locked true bool L2 +// │ └─ notes null null L2 +// ├─ users arr L1 size 2 +// │ ├─ [0] obj L2 size 7 (in array) +// │ │ ├─ id 1 num L3 +// │ │ ├─ name "Leanne Graham" str L3 +// │ │ ├─ username "Bret" str L3 +// │ │ ├─ email "leanne@example.com" str L3 +// │ │ ├─ status "LOCKED" str L3 +// │ │ ├─ roles ["admin","ops"] arr L3 size 2 +// │ │ │ ├─ [0] "admin" str L4 (in array) +// │ │ │ └─ [1] "ops" str L4 (in array) +// │ │ └─ address obj L3 size 2 +// │ │ ├─ city "Gwenborough" str L4 +// │ │ └─ geo obj L4 size 2 +// │ │ ├─ lat "-37.3159" str L5 +// │ │ └─ lng "81.1496" str L5 +// │ └─ [1] obj L2 size 7 (in array) +// │ ├─ id 2 num L3 +// │ ├─ name "Ervin Howell" str L3 +// │ ├─ username "Antonette" str L3 +// │ ├─ email "ervin@example.com" str L3 +// │ ├─ status "active" str L3 +// │ ├─ roles ["viewer"] arr L3 size 1 +// │ │ └─ [0] "viewer" str L4 (in array) +// │ └─ address obj L3 size 2 +// │ ├─ city "Wisokyburgh" str L4 +// │ └─ geo obj L4 size 2 +// │ ├─ lat "-43.9509" str L5 +// │ └─ lng "-34.4618" str L5 +// └─ config obj L1 size 2 +// ├─ featureFlags obj L2 size 2 +// │ ├─ beta false bool L3 +// │ └─ experimental true bool L3 +// └─ limits obj L2 size 2 +// ├─ maxUsers 100 num L3 +// └─ maxItems 50 num L3 +// --------------------------------------------------------------------------- +const data = { + meta: { + version: 2, + 'schema.url': 'https://schema.example.com/v2', + locked: true, + notes: null, + }, + users: [ + { + id: 1, + name: 'Leanne Graham', + username: 'Bret', + email: 'leanne@example.com', + status: 'LOCKED', + roles: ['admin', 'ops'], + address: { city: 'Gwenborough', geo: { lat: '-37.3159', lng: '81.1496' } }, + }, + { + id: 2, + name: 'Ervin Howell', + username: 'Antonette', + email: 'ervin@example.com', + status: 'active', + roles: ['viewer'], + address: { city: 'Wisokyburgh', geo: { lat: '-43.9509', lng: '-34.4618' } }, + }, + ], + config: { + featureFlags: { beta: false, experimental: true }, + limits: { maxUsers: 100, maxItems: 50 }, + }, +} + +// --- Test harness (self-contained — touches only core's public types) ------- + +type Path = (string | number)[] + +const isColl = (v: unknown): v is object => v !== null && typeof v === 'object' + +const get = (obj: unknown, path: Path): unknown => + path.reduce((acc, k) => (acc == null ? acc : (acc as Record)[k]), obj) + +// A faithful NodeData for any path in `data`, built the same way the editor +// would (key, level, index, size, parentData all derived from the document). +const nodeAt = (path: Path): NodeData => { + const value = get(data, path) + const parentData = path.length === 0 ? null : (get(data, path.slice(0, -1)) ?? null) + const key = path.length === 0 ? '' : path[path.length - 1] + let index = 0 + if (Array.isArray(parentData)) index = Number(key) + else if (parentData) index = Object.keys(parentData).indexOf(String(key)) + return { + key, + path, + level: path.length, + index, + value: value as JsonData, + size: isColl(value) ? Object.keys(value).length : null, + parentData: parentData as object | null, + fullData: data as JsonData, + } +} + +// Every node path in the document, in render (pre-order) order. +const allPaths = (value: unknown, prefix: Path = []): Path[] => { + const paths: Path[] = [prefix] + if (isColl(value)) + for (const [k, v] of Object.entries(value)) { + const key = Array.isArray(value) ? Number(k) : k + paths.push(...allPaths(v, [...prefix, key])) + } + return paths +} +const PATHS = allPaths(data) + +// A readable label for a path: dotted keys, `[n]` for indices, `(root)` for []. +const label = (path: Path): string => + path.length === 0 + ? '(root)' + : path.reduce( + (s, k) => (typeof k === 'number' ? `${s}[${k}]` : s === '' ? `${k}` : `${s}.${k}`), + '' + ) + +// The paths a predicate matches, as readable labels, in document order. The +// workhorse: most assertions read as "this predicate selects exactly these +// nodes from the whole tree". +const select = (pred: FilterPredicate, searchText?: string): string[] => + PATHS.filter((path) => pred(nodeAt(path), searchText)).map(label) + +// ============================================================================= +// Property builders +// ============================================================================= + +describe('byKey', () => { + it('matches a single key, anywhere it appears in the tree', () => { + expect(select(byKey('city'))).toEqual(['users[0].address.city', 'users[1].address.city']) + }) + + it('is variadic — matches any of several keys', () => { + expect(select(byKey('id', 'name'))).toEqual([ + 'users[0].id', + 'users[0].name', + 'users[1].id', + 'users[1].name', + ]) + }) + + it('accepts a RegExp, tested against the stringified key', () => { + expect(select(byKey(/^max/))).toEqual(['config.limits.maxUsers', 'config.limits.maxItems']) + }) + + it('matches numeric array indices', () => { + expect(select(byKey(0))).toEqual(['users[0]', 'users[0].roles[0]', 'users[1].roles[0]']) + }) +}) + +describe('byPath', () => { + it('matches a fixed path; `*` fills exactly one segment (incl. indices)', () => { + expect(select(byPath('users.*.name'))).toEqual(['users[0].name', 'users[1].name']) + }) + + it('accepts both dotted and bracket index notation', () => { + expect(select(byPath('users.0.email'))).toEqual(['users[0].email']) + expect(select(byPath('users[0].email'))).toEqual(['users[0].email']) + }) + + it('supports {a,b} alternation within a segment', () => { + expect(select(byPath('users.*.{name,email}'))).toEqual([ + 'users[0].name', + 'users[0].email', + 'users[1].name', + 'users[1].email', + ]) + }) + + it('`*` matches within a segment, not just whole segments', () => { + expect(select(byPath('config.limits.max*'))).toEqual([ + 'config.limits.maxUsers', + 'config.limits.maxItems', + ]) + }) + + it('`**` matches zero-or-more segments — a subtree that INCLUDES its root', () => { + expect(select(byPath('config.**'))).toEqual([ + 'config', + 'config.featureFlags', + 'config.featureFlags.beta', + 'config.featureFlags.experimental', + 'config.limits', + 'config.limits.maxUsers', + 'config.limits.maxItems', + ]) + }) + + it('a bare name is EXACT, not a subtree', () => { + expect(select(byPath('config'))).toEqual(['config']) + }) + + it('`**` can lead, to match a tail anywhere', () => { + expect(select(byPath('**.geo.lat'))).toEqual([ + 'users[0].address.geo.lat', + 'users[1].address.geo.lat', + ]) + }) + + it('treats a dotted key as ONE segment under `*`', () => { + // `meta.*` matches all four children, including the key that contains a dot. + expect(select(byPath('meta.*'))).toEqual([ + 'meta.version', + 'meta.schema.url', + 'meta.locked', + 'meta.notes', + ]) + }) + + it('needs the segment-array form to target a key containing a dot', () => { + // The string form splits on the dot and matches nothing real… + expect(select(byPath('meta.schema.url'))).toEqual([]) + // …whereas the array form addresses the single real node. + expect(byPath(['meta', 'schema.url'])(nodeAt(['meta', 'schema.url']))).toBe(true) + }) + + it('accepts a RegExp, tested against the stringified path', () => { + expect(select(byPath(/\.geo$/))).toEqual([ + 'users[0].address.geo', + 'users[1].address.geo', + ]) + }) +}) + +describe('byLevel', () => { + it('takes a bare number for an exact level (root = 0)', () => { + expect(select(byLevel(1))).toEqual(['meta', 'users', 'config']) + }) + + it('bounds one end with the object form (omitted end = unbounded)', () => { + expect(select(byLevel({ max: 1 }))).toEqual(['(root)', 'meta', 'users', 'config']) + expect(select(byLevel({ min: 5 }))).toEqual([ + 'users[0].address.geo.lat', + 'users[0].address.geo.lng', + 'users[1].address.geo.lat', + 'users[1].address.geo.lng', + ]) + }) +}) + +describe('bySize', () => { + it('matches collections by child count', () => { + expect(select(bySize({ min: 7 }))).toEqual(['users[0]', 'users[1]']) + expect(select(bySize({ max: 1 }))).toEqual(['users[1].roles']) + expect(select(bySize(2))).toEqual([ + 'users', + 'users[0].roles', + 'users[0].address', + 'users[0].address.geo', + 'users[1].address', + 'users[1].address.geo', + 'config', + 'config.featureFlags', + 'config.limits', + ]) + }) + + it('never matches a leaf (a leaf has no size)', () => { + expect(bySize({ min: 0 })(nodeAt(['meta', 'version']))).toBe(false) + }) +}) + +describe('byType', () => { + it('matches primitive value types', () => { + expect(select(byType('boolean'))).toEqual([ + 'meta.locked', + 'config.featureFlags.beta', + 'config.featureFlags.experimental', + ]) + expect(select(byType('null'))).toEqual(['meta.notes']) + expect(select(byType('number'))).toEqual([ + 'meta.version', + 'users[0].id', + 'users[1].id', + 'config.limits.maxUsers', + 'config.limits.maxItems', + ]) + }) + + it('distinguishes object from array', () => { + expect(select(byType('array'))).toEqual(['users', 'users[0].roles', 'users[1].roles']) + }) +}) + +describe('byValue', () => { + it('matches an exact value', () => { + expect(select(byValue('LOCKED'))).toEqual(['users[0].status']) + expect(select(byValue(true))).toEqual(['meta.locked', 'config.featureFlags.experimental']) + expect(select(byValue(null))).toEqual(['meta.notes']) + }) + + it('is variadic — matches any of several values', () => { + expect(select(byValue(1, 2))).toEqual(['meta.version', 'users[0].id', 'users[1].id']) + }) + + it('accepts a RegExp for partial/pattern matching on the value', () => { + expect(select(byValue(/example\.com$/))).toEqual(['users[0].email', 'users[1].email']) + }) +}) + +// ============================================================================= +// Node-position constants +// ============================================================================= + +describe('position constants', () => { + it('root is exactly the top node', () => { + expect(select(root)).toEqual(['(root)']) + }) + + it('collections / primitives partition the tree', () => { + // Every node is one or the other, never both. + expect(select(collections)).toEqual(PATHS.filter((p) => isColl(get(data, p))).map(label)) + expect(select(primitives)).toEqual(PATHS.filter((p) => !isColl(get(data, p))).map(label)) + }) + + it('inArray matches array items only', () => { + expect(select(inArray)).toEqual([ + 'users[0]', + 'users[0].roles[0]', + 'users[0].roles[1]', + 'users[1]', + 'users[1].roles[0]', + ]) + }) + + it('inObject matches object fields (and never the root, which has no parent)', () => { + expect(inObject(nodeAt(['meta', 'version']))).toBe(true) + expect(inObject(nodeAt(['users', 0]))).toBe(false) // parent is the users array + expect(inObject(nodeAt([]))).toBe(false) // root: parentData is null + }) +}) + +// ============================================================================= +// Combinators +// ============================================================================= + +describe('and / or / not', () => { + it('and = every predicate matches', () => { + // The user-record nodes: level-2 items that live in an array. + expect(select(and(byPath('users.*'), inArray))).toEqual(['users[0]', 'users[1]']) + }) + + it('or = any predicate matches (document order, across branches)', () => { + expect(select(or(byKey('beta'), byKey('lat')))).toEqual([ + 'users[0].address.geo.lat', + 'users[1].address.geo.lat', + 'config.featureFlags.beta', + ]) + }) + + it('not negates; not(collections) is exactly primitives', () => { + expect(select(not(root))).toEqual(PATHS.slice(1).map(label)) + expect(select(not(collections))).toEqual(select(primitives)) + }) + + it('empty combinators have identity behaviour', () => { + expect(select(and())).toEqual(PATHS.map(label)) // vacuously true everywhere + expect(select(or())).toEqual([]) // vacuously false everywhere + }) + + it('accepts a hand-rolled FilterFunction alongside the builders', () => { + const big = ({ value }: NodeData) => typeof value === 'number' && value >= 100 + expect(select(and(byPath('config.**'), big))).toEqual(['config.limits.maxUsers']) + }) +}) + +// ============================================================================= +// Search bridges +// ============================================================================= + +describe('matchesSearch', () => { + it("'value' matches node values (the default)", () => { + expect(select(matchesSearch('value'), 'leanne')).toEqual(['users[0].name', 'users[0].email']) + expect(select(matchesSearch(), 'leanne')).toEqual(select(matchesSearch('value'), 'leanne')) + }) + + it("'key' matches keys (substring, like core)", () => { + expect(select(matchesSearch('key'), 'username')).toEqual([ + 'users[0].username', + 'users[1].username', + ]) + }) + + it("'all' matches key OR value", () => { + expect(select(matchesSearch('all'), 'antonette')).toEqual(['users[1].username']) + }) + + it('composes with a path scope — search only within a subtree', () => { + // "true" matches meta.locked AND config.featureFlags.experimental by value, + // but the path scope keeps only the one inside config — and the searchText + // is threaded through `and` to the inner matchesSearch. + expect(select(and(byPath('config.**'), matchesSearch('value')), 'true')).toEqual([ + 'config.featureFlags.experimental', + ]) + }) +}) + +describe('matchRecord', () => { + it('reveals a whole record matched on its key fields (path-located)', () => { + expect( + select(matchRecord({ fields: ['name', 'username'], path: 'users.*' }), 'antonette') + ).toEqual([ + 'users[1]', + 'users[1].id', + 'users[1].name', + 'users[1].username', + 'users[1].email', + 'users[1].status', + 'users[1].roles', + 'users[1].roles[0]', + 'users[1].address', + 'users[1].address.city', + 'users[1].address.geo', + 'users[1].address.geo.lat', + 'users[1].address.geo.lng', + ]) + }) + + it("defaults path to '*' (top-level items) and keys off the RECORD's field, not the node's own value", () => { + // searchText "2" — meta.version is 2, so the whole meta record shows. Note + // users[0].id is ALSO 2, but matchRecord tests the record's `version` + // field, not each node's own value, so the users records stay hidden. + expect(select(matchRecord({ fields: ['version'] }), '2')).toEqual([ + 'meta', + 'meta.version', + 'meta.schema.url', + 'meta.locked', + 'meta.notes', + ]) + }) +}) + +// ============================================================================= +// Referential stability (interning) — inline use must not churn the node memo +// ============================================================================= + +describe('interning', () => { + it('returns the same instance for equal arguments', () => { + expect(byKey('name')).toBe(byKey('name')) + expect(byPath('users.*')).toBe(byPath('users.*')) + expect(byType('string', 'number')).toBe(byType('string', 'number')) + expect(byValue('LOCKED')).toBe(byValue('LOCKED')) + expect(byLevel(2)).toBe(byLevel(2)) + expect(byLevel({ max: 2 })).toBe(byLevel({ max: 2 })) + }) + + it('distinguishes different arguments', () => { + expect(byKey('name')).not.toBe(byKey('email')) + expect(byLevel(2)).not.toBe(byLevel({ max: 2 })) + }) + + it('keys RegExp args on source + flags', () => { + expect(byValue(/x/i)).toBe(byValue(/x/i)) + expect(byKey(/x/i)).not.toBe(byKey(/x/g)) + }) + + it('combinators are stable when their (interned) children are', () => { + expect(and(byKey('a'), byKey('b'))).toBe(and(byKey('a'), byKey('b'))) + expect(not(root)).toBe(not(root)) + }) +}) diff --git a/packages/utils/test/tsconfig.json b/packages/utils/test/tsconfig.json new file mode 100644 index 00000000..d0be6368 --- /dev/null +++ b/packages/utils/test/tsconfig.json @@ -0,0 +1,22 @@ +{ + // Editor-facing config for this package's tests (mirrors core's + // test/tsconfig.json). ts-jest itself transpiles with the root + // test/tsconfig.json; this exists so the IDE treats the test files as a + // project with Jest globals + the `json-edit-react` path mapping in scope. + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "../../..", + "noEmit": true, + "moduleResolution": "bundler", + "typeRoots": ["../../../node_modules/@types"], + "types": ["jest", "node"], + // The bare 'json-edit-react' specifier (a peer dep) doesn't resolve from + // the repo-root node_modules; map it to core's source, matching the Jest + // moduleNameMapper. With no `baseUrl`, paths resolve relative to this file. + "paths": { + "json-edit-react": ["../../../src"], + "json-edit-react/*": ["../../../src/*"] + } + }, + "include": ["**/*.ts", "**/*.tsx", "../src/**/*.ts", "../src/**/*.tsx"] +} From 53d38f35ceead72a237627ca633763cbc7c92619 Mon Sep 17 00:00:00 2001 From: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:39:14 +1200 Subject: [PATCH 02/11] Implement basic and path builders --- packages/utils/src/filters/_glob.ts | 97 ++++++++++++++++ packages/utils/src/filters/_intern.ts | 53 +++++++++ packages/utils/src/filters/index.ts | 85 ++++++++++++-- packages/utils/test/glob.test.ts | 161 ++++++++++++++++++++++++++ 4 files changed, 384 insertions(+), 12 deletions(-) create mode 100644 packages/utils/src/filters/_glob.ts create mode 100644 packages/utils/src/filters/_intern.ts create mode 100644 packages/utils/test/glob.test.ts diff --git a/packages/utils/src/filters/_glob.ts b/packages/utils/src/filters/_glob.ts new file mode 100644 index 00000000..124b3d04 --- /dev/null +++ b/packages/utils/src/filters/_glob.ts @@ -0,0 +1,97 @@ +import type { PathPattern } from './types' + +// Compiles a path pattern into a matcher over a node's `path`. The three input +// forms collapse to two strategies: a RegExp is tested against the stringified +// path; a glob string or a segment array compiles to a sequence of per-segment +// matchers plus the `**` globstar. Compilation happens once (at builder-call +// time, behind `intern`); the returned matcher runs per node. + +// Sentinel for the `**` token (zero-or-more whole segments). A symbol so it +// can never be confused with a compiled-segment RegExp. +const GLOBSTAR = Symbol('globstar') +type Token = RegExp | typeof GLOBSTAR + +// Render a path the way the editor's copy-path / error strings do — dotted +// keys, `[n]` for numeric indices. This is the string a RegExp pattern tests. +const stringifyPath = (path: Array): string => + path.reduce( + (acc, seg) => + typeof seg === 'number' ? `${acc}[${seg}]` : acc === '' ? `${seg}` : `${acc}.${seg}`, + '' + ) + +// Split a glob string into segments: bracket indices are normalised to dotted +// (`users[0]` → `users.0`), then we split on `.`, dropping empty pieces (from a +// leading bracket or `..`). Dots are ALWAYS separators here — a key containing +// a literal dot must use the segment-array form instead. +const splitGlob = (pattern: string): string[] => + pattern + .replace(/\[(\d+)\]/g, '.$1') + .split('.') + .filter((seg) => seg !== '') + +// Regex metacharacters with no glob meaning — escaped to match literally. +// (`* ? { } ,` are handled explicitly below, so they're absent here.) +const LITERAL_META = /[.+^$()|[\]\\]/ + +// Compile one segment pattern to a RegExp anchored to a whole segment. Glob +// tokens within a segment: `*` = any run of chars, `?` = one char, `{a,b}` = +// alternation (comma splits only inside braces; depth tracked so it's robust). +const compileSegment = (segment: string): RegExp => { + let out = '' + let depth = 0 + for (const ch of segment) { + if (ch === '*') out += '[^]*' + else if (ch === '?') out += '[^]' + else if (ch === '{') { + out += '(?:' + depth++ + } else if (ch === '}' && depth > 0) { + out += ')' + depth-- + } else if (ch === ',' && depth > 0) out += '|' + else out += LITERAL_META.test(ch) ? `\\${ch}` : ch + } + if (depth > 0) out += ')'.repeat(depth) // tolerate an unclosed `{` + return new RegExp(`^${out}$`) +} + +// Match a token sequence against path segments. Each RegExp token consumes +// exactly one segment; GLOBSTAR consumes zero or more. Standard greedy wildcard +// matching, backtracking onto the most recent GLOBSTAR when a later token +// fails. +const matchSegments = (tokens: Token[], segments: string[]): boolean => { + let ti = 0 + let si = 0 + let starTi = -1 // token index of the last GLOBSTAR seen + let starSi = 0 // segment index where that GLOBSTAR started consuming + while (si < segments.length) { + const tok: Token | undefined = ti < tokens.length ? tokens[ti] : undefined + if (tok instanceof RegExp && tok.test(segments[si])) { + ti++ + si++ + } else if (tok === GLOBSTAR) { + starTi = ti + starSi = si + ti++ // first try matching zero segments + } else if (starTi !== -1) { + ti = starTi + 1 // backtrack: let the GLOBSTAR swallow one more segment + si = ++starSi + } else { + return false + } + } + while (tokens[ti] === GLOBSTAR) ti++ // trailing GLOBSTARs may match zero + return ti === tokens.length +} + +export const compilePathMatcher = ( + pattern: PathPattern +): ((path: Array) => boolean) => { + if (pattern instanceof RegExp) { + return (path) => pattern.test(stringifyPath(path)) + } + const segments = Array.isArray(pattern) ? pattern.map(String) : splitGlob(pattern) + const tokens: Token[] = segments.map((s) => (s === '**' ? GLOBSTAR : compileSegment(s))) + return (path) => matchSegments(tokens, path.map(String)) +} diff --git a/packages/utils/src/filters/_intern.ts b/packages/utils/src/filters/_intern.ts new file mode 100644 index 00000000..3db774a3 --- /dev/null +++ b/packages/utils/src/filters/_intern.ts @@ -0,0 +1,53 @@ +import type { FilterPredicate } from './types' + +// Soft cap on a single builder's argument cache. Literal arguments keep these +// tiny and bounded; the cap only bites if a consumer feeds pathologically +// dynamic arguments in a loop, and clearing then just rebuilds on next use — +// never a correctness issue, only a lost cache hit. +const MAX_CACHE_ENTRIES = 10_000 + +// Make one argument injectively serialisable. JSON.stringify already does the +// hard part — it quotes and escapes strings (so `['a','b']` can't collide with +// `['a,b']`) and keeps the number 1 distinct from the string "1". We only need +// to special-case RegExp, which JSON would flatten to `{}` (it has no own +// enumerable props); tag it by source + flags so `/x/i` and `/x/g` differ. +const normalise = (arg: unknown): unknown => + arg instanceof RegExp ? { __re: arg.source, flags: arg.flags } : arg + +const keyOf = (args: readonly unknown[]): string => JSON.stringify(args.map(normalise)) + +/** + * Wrap a value-argument builder so equal arguments return the SAME predicate + * instance ("interning" / hash-consing). + * + * Why it matters: it lets a builder be written inline on a filter prop + * (`allowEdit={byKey('name')}`) without minting a fresh function every render. + * json-edit-react compares filter props by identity (the node `React.memo` + * boundary, and `useMemo(…, [allowEdit])` upstream), so a new identity each + * render would defeat fine-grained re-rendering tree-wide. A stable identity + * keeps the memo intact; a genuinely different argument still produces a + * different instance, so real changes still propagate. + * + * Each wrapped builder owns its own `Map`, keyed by an injective JSON + * serialisation of the arguments (see `keyOf`). The cache lives as long as the + * builder (module scope) and grows with the number of DISTINCT argument-sets — + * bounded for the usual literal args; `MAX_CACHE_ENTRIES` backstops the rest. + * + * Builders whose arguments are FUNCTIONS (the `and`/`or`/`not` combinators) + * can't be string-keyed and intern on reference identity via a `WeakMap` + * instead — see those builders. + */ +export const intern = ( + build: (...args: A) => FilterPredicate +): ((...args: A) => FilterPredicate) => { + const cache = new Map() + return (...args: A): FilterPredicate => { + const key = keyOf(args) + const cached = cache.get(key) + if (cached) return cached + const predicate = build(...args) + if (cache.size >= MAX_CACHE_ENTRIES) cache.clear() + cache.set(key, predicate) + return predicate + } +} diff --git a/packages/utils/src/filters/index.ts b/packages/utils/src/filters/index.ts index 93e097e2..e3df1ce0 100644 --- a/packages/utils/src/filters/index.ts +++ b/packages/utils/src/filters/index.ts @@ -1,4 +1,6 @@ import type { FilterPredicate, NodeValueType, PathPattern, Range } from './types' +import { intern } from './_intern' +import { compilePathMatcher } from './_glob' // Public types. `Range` is intentionally NOT re-exported (see types.ts). export type { FilterPredicate, NodeValueType, PathPattern } from './types' @@ -16,30 +18,89 @@ const TODO = (name: string): never => { /** Matches when the node's own key is one of `keys` (string/number = exact on * the stringified key; RegExp = tested against the stringified key). */ -export const byKey = (...keys: Array): FilterPredicate => - (void keys, TODO('byKey')) +export const byKey: (...keys: Array) => FilterPredicate = intern( + (...keys: Array): FilterPredicate => { + // Split the args once, at builder-call time — not per node. + const exact = new Set() + const patterns: RegExp[] = [] + for (const k of keys) { + if (k instanceof RegExp) patterns.push(k) + else exact.add(String(k)) + } + return (node) => { + const key = String(node.key) + return exact.has(key) || patterns.some((re) => re.test(key)) + } + } +) /** Matches when the node's path matches `pattern` (glob string, RegExp, or * explicit segment array). */ -export const byPath = (pattern: PathPattern): FilterPredicate => (void pattern, TODO('byPath')) +export const byPath: (pattern: PathPattern) => FilterPredicate = intern( + (pattern: PathPattern): FilterPredicate => { + const matches = compilePathMatcher(pattern) // compiled once per pattern + return ({ path }) => matches(path) + } +) + +// Shared by byLevel/bySize: resolve a Range to concrete inclusive bounds — a +// bare number means exactly that value; an omitted end becomes ±Infinity, so +// the builders read as a plain `min <= x <= max` with no undefined-checks. +const bounds = (range: Range): { min: number; max: number } => + typeof range === 'number' + ? { min: range, max: range } + : { min: range.min ?? -Infinity, max: range.max ?? Infinity } /** Matches when the node's depth is within `range` (root = level 0). */ -export const byLevel = (range: Range): FilterPredicate => (void range, TODO('byLevel')) +export const byLevel: (range: Range) => FilterPredicate = intern((range: Range): FilterPredicate => { + const { min, max } = bounds(range) + return ({ level }) => level >= min && level <= max +}) /** Matches when a collection's child count is within `range`. Leaves (no size) * never match. */ -export const bySize = (range: Range): FilterPredicate => (void range, TODO('bySize')) +export const bySize: (range: Range) => FilterPredicate = intern((range: Range): FilterPredicate => { + const { min, max } = bounds(range) + return ({ size }) => size != null && size >= min && size <= max +}) + +// The JSON value kind of a node's value, including the two collection types. +const valueType = (value: unknown): NodeValueType => { + if (value === null) return 'null' + if (Array.isArray(value)) return 'array' + return typeof value as NodeValueType +} /** Matches when the node's value type is one of `types` (incl. `'object'` / * `'array'`). */ -export const byType = (...types: NodeValueType[]): FilterPredicate => - (void types, TODO('byType')) - -/** Matches when the node's value equals one of `values` (RegExp is tested - * against the stringified value). */ -export const byValue = ( +export const byType: (...types: NodeValueType[]) => FilterPredicate = intern( + (...types: NodeValueType[]): FilterPredicate => { + const set = new Set(types) + return ({ value }) => set.has(valueType(value)) + } +) + +/** Matches when the node's value equals one of `values` (literals = strict + * equality; RegExp is tested against the stringified value, leaves only). */ +export const byValue: ( ...values: Array -): FilterPredicate => (void values, TODO('byValue')) +) => FilterPredicate = intern( + (...values: Array): FilterPredicate => { + const literals = new Set() + const patterns: RegExp[] = [] + for (const v of values) { + if (v instanceof RegExp) patterns.push(v) + else literals.add(v) + } + return ({ value }) => { + if (literals.has(value as string | number | boolean | null)) return true + if (patterns.length === 0) return false + // A stringified object/array isn't a meaningful pattern target. + if (value !== null && typeof value === 'object') return false + return patterns.some((re) => re.test(String(value))) + } + } +) // --- Node-position constants ------------------------------------------------ diff --git a/packages/utils/test/glob.test.ts b/packages/utils/test/glob.test.ts new file mode 100644 index 00000000..04233ec9 --- /dev/null +++ b/packages/utils/test/glob.test.ts @@ -0,0 +1,161 @@ +import type { PathPattern } from '../src' +import { compilePathMatcher } from '../src/filters/_glob' + +// Direct unit tests for the path-glob engine that backs `byPath` (and locates +// the record layer in `matchRecord`). `byPath`'s own tests exercise realistic +// patterns against the shared fixture tree; these probe the matcher in +// isolation, with synthetic paths, to cover the edge cases the tree can't reach +// — zero-segment `**`, multiple/adjacent globstars, `?`, nested braces, escaped +// metacharacters, the empty pattern, and root matching. + +const matches = (pattern: PathPattern, path: Array): boolean => + compilePathMatcher(pattern)(path) + +describe('within-segment wildcards (`*`, `?`) stay inside one segment', () => { + it.each<[PathPattern, Array, boolean]>([ + ['user*', ['username'], true], // prefix + ['*Id', ['userId'], true], // suffix + ['a*c', ['abc'], true], // infix + ['a*c', ['ac'], true], // `*` matches zero chars + ['a*c', ['abXYZc'], true], + ['a*c', ['abcd'], false], // anchored — no trailing slop + ['*', ['anything'], true], + ['*', [''], true], // a single empty-string segment + ['*', [], false], // …but `*` still needs ONE segment + ['*', ['a', 'b'], false], // and only one + ['a?c', ['abc'], true], + ['a?c', ['ac'], false], // `?` needs exactly one char + ['a?c', ['abbc'], false], + ])('%p over %p → %p', (pattern, path, expected) => { + expect(matches(pattern, path)).toBe(expected) + }) +}) + +describe('`*` does not cross segment boundaries', () => { + it.each<[PathPattern, Array, boolean]>([ + ['users.*', ['users', 0], true], + ['users.*', ['users', 'name'], true], + ['users.*', ['users', 0, 'name'], false], // `*` is one segment, not `**` + ['a.*.c', ['a', 'b', 'c'], true], + ['a.*.c', ['a', 'c'], false], // the middle segment must exist + ['a.*.c', ['a', 'b', 'x', 'c'], false], + ])('%p over %p → %p', (pattern, path, expected) => { + expect(matches(pattern, path)).toBe(expected) + }) +}) + +describe('`**` matches zero or more whole segments', () => { + it.each<[PathPattern, Array, boolean]>([ + ['a.**', ['a'], true], // zero (subtree includes its root) + ['a.**', ['a', 'b'], true], + ['a.**', ['a', 'b', 'c'], true], + ['a.**', ['x'], false], + ['**', [], true], // `**` alone matches even the root + ['**', ['a', 'b', 'c'], true], + ['**.c', ['c'], true], // leading `**` matches zero + ['**.c', ['a', 'b', 'c'], true], + ['**.c', ['a', 'b'], false], + ['a.**.c', ['a', 'c'], true], // interior `**` matches zero + ['a.**.c', ['a', 'b', 'c'], true], + ['a.**.c', ['a', 'b', 'x', 'c'], true], + ])('%p over %p → %p', (pattern, path, expected) => { + expect(matches(pattern, path)).toBe(expected) + }) +}) + +describe('multiple and adjacent globstars (backtracking)', () => { + it.each<[PathPattern, Array, boolean]>([ + ['**.b.**.d', ['a', 'b', 'c', 'd'], true], + ['**.b.**.d', ['b', 'd'], true], + ['**.b.**.d', ['a', 'd'], false], // no `b` + ['a.**.**.b', ['a', 'b'], true], + ['a.**.**.b', ['a', 'x', 'y', 'b'], true], + ])('%p over %p → %p', (pattern, path, expected) => { + expect(matches(pattern, path)).toBe(expected) + }) +}) + +describe('exact vs subtree (a bare name is exact)', () => { + it.each<[PathPattern, Array, boolean]>([ + ['a', ['a'], true], + ['a', ['a', 'b'], false], + ['a', [], false], + ['a.b', ['a', 'b'], true], + ['a.b', ['a'], false], + ])('%p over %p → %p', (pattern, path, expected) => { + expect(matches(pattern, path)).toBe(expected) + }) +}) + +describe('{a,b} alternation', () => { + it.each<[PathPattern, Array, boolean]>([ + ['{x,y}', ['x'], true], + ['{x,y}', ['y'], true], + ['{x,y}', ['z'], false], + ['v{1,2}', ['v1'], true], // alternation with a prefix + ['v{1,2}', ['v3'], false], + ['{only}', ['only'], true], // single option + ['{a,{b,c}}', ['a'], true], // nested + ['{a,{b,c}}', ['c'], true], + ['{a,{b,c}}', ['d'], false], + ])('%p over %p → %p', (pattern, path, expected) => { + expect(matches(pattern, path)).toBe(expected) + }) +}) + +describe('index notation (bracket and dotted are equivalent)', () => { + it.each<[PathPattern, Array, boolean]>([ + ['users[0]', ['users', 0], true], + ['users.0', ['users', 0], true], + ['users[0].name', ['users', 0, 'name'], true], + ['users.*', ['users', 0], true], // `*` matches a numeric index + ['0', [0], true], + ['[0]', [0], true], // leading bracket index + ])('%p over %p → %p', (pattern, path, expected) => { + expect(matches(pattern, path)).toBe(expected) + }) +}) + +describe('literal metacharacters via the segment-array form', () => { + it.each<[PathPattern, Array, boolean]>([ + [['a.b'], ['a.b'], true], // a dot in a key — one literal segment + [['a.b'], ['a', 'b'], false], + [['a+b'], ['a+b'], true], // `+` is literal, not "one-or-more" + [['a+b'], ['ab'], false], + [['(x)'], ['(x)'], true], + [['users', '*', 'email'], ['users', 0, 'email'], true], // `*` still globs + [['**', 'name'], ['a', 'b', 'name'], true], // `**` still globstars + [['users', 0, 'name'], ['users', 0, 'name'], true], // numeric element + ])('%p over %p → %p', (pattern, path, expected) => { + expect(matches(pattern, path)).toBe(expected) + }) +}) + +describe('RegExp form — tested against the stringified path, not anchored', () => { + it.each<[PathPattern, Array, boolean]>([ + [/^users/, ['users', 0, 'name'], true], + [/\.geo$/, ['a', 'geo'], true], + [/\.geo$/, ['a', 'geo', 'lat'], false], + [/\[0\]/, ['users', 0], true], // matches the bracket rendering + [/name/, ['a', 'name', 'b'], true], // unanchored: matches mid-path + ])('%p over %p → %p', (pattern, path, expected) => { + expect(matches(pattern, path)).toBe(expected) + }) +}) + +describe('empty pattern is the root', () => { + it.each<[PathPattern, Array, boolean]>([ + ['', [], true], + ['', ['a'], false], + ])('%p over %p → %p', (pattern, path, expected) => { + expect(matches(pattern, path)).toBe(expected) + }) +}) + +describe('robustness', () => { + it('tolerates an unclosed brace instead of throwing', () => { + expect(() => compilePathMatcher('a{b,c')).not.toThrow() + expect(matches('a{b,c', ['ab'])).toBe(true) + expect(matches('a{b,c', ['ac'])).toBe(true) + }) +}) From fe2683786fb3d0c7059aae5cd52cf8b6f63e3670 Mon Sep 17 00:00:00 2001 From: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:24:42 +1200 Subject: [PATCH 03/11] Implement remaining builders --- packages/utils/src/filters/_intern.ts | 72 ++++++++++++++++++++-- packages/utils/src/filters/index.ts | 89 ++++++++++++++++++--------- 2 files changed, 129 insertions(+), 32 deletions(-) diff --git a/packages/utils/src/filters/_intern.ts b/packages/utils/src/filters/_intern.ts index 3db774a3..1e24e201 100644 --- a/packages/utils/src/filters/_intern.ts +++ b/packages/utils/src/filters/_intern.ts @@ -6,13 +6,20 @@ import type { FilterPredicate } from './types' // never a correctness issue, only a lost cache hit. const MAX_CACHE_ENTRIES = 10_000 -// Make one argument injectively serialisable. JSON.stringify already does the +// Make an argument injectively serialisable. JSON.stringify already does the // hard part — it quotes and escapes strings (so `['a','b']` can't collide with // `['a,b']`) and keeps the number 1 distinct from the string "1". We only need // to special-case RegExp, which JSON would flatten to `{}` (it has no own -// enumerable props); tag it by source + flags so `/x/i` and `/x/g` differ. -const normalise = (arg: unknown): unknown => - arg instanceof RegExp ? { __re: arg.source, flags: arg.flags } : arg +// enumerable props); tag it by source + flags so `/x/i` and `/x/g` differ. The +// recursion covers RegExps nested inside array/object args (e.g. a pattern in +// `matchRecord`'s options). +const normalise = (arg: unknown): unknown => { + if (arg instanceof RegExp) return { __re: arg.source, flags: arg.flags } + if (Array.isArray(arg)) return arg.map(normalise) + if (arg !== null && typeof arg === 'object') + return Object.fromEntries(Object.entries(arg).map(([k, v]) => [k, normalise(v)])) + return arg +} const keyOf = (args: readonly unknown[]): string => JSON.stringify(args.map(normalise)) @@ -51,3 +58,60 @@ export const intern = ( return predicate } } + +// --- Reference-keyed interning, for the combinators ------------------------- +// +// `and`/`or`/`not` take FUNCTION arguments (other predicates), which can't be +// string-serialised — and shouldn't be (two predicates with identical source +// can differ; only identity tells them apart). They intern on reference +// identity via WeakMaps instead. Two upshots: it's correct (distinct closures +// stay distinct), and it's leak-free (an entry is GC'd once its key predicate +// is unreferenced). Because the kit's own builders already intern, a combinator +// over them — `and(byKey('a'), byPath('b'))` — is itself inline-stable. + +/** Memoise a unary combinator (`not`) on its single predicate's identity. */ +export const internRef = ( + combine: (pred: FilterPredicate) => FilterPredicate +): ((pred: FilterPredicate) => FilterPredicate) => { + const cache = new WeakMap() + return (pred) => { + const cached = cache.get(pred) + if (cached) return cached + const result = combine(pred) + cache.set(pred, result) + return result + } +} + +// A node in the WeakMap trie: a branch per child predicate, plus the memoised +// combinator for the sequence that ends here. +interface RefNode { + next: WeakMap + result?: FilterPredicate +} + +/** + * Memoise a variadic combinator (`and`/`or`) on the identities of its children, + * in order, via a trie of WeakMaps — so `and(a, b)` returns one instance as + * long as `a` and `b` are themselves stable. `empty` is the identity element + * returned for the no-argument call. + */ +export const internRefs = ( + combine: (preds: FilterPredicate[]) => FilterPredicate, + empty: FilterPredicate +): ((...preds: FilterPredicate[]) => FilterPredicate) => { + const root: RefNode = { next: new WeakMap() } + return (...preds: FilterPredicate[]): FilterPredicate => { + if (preds.length === 0) return empty + let node = root + for (const pred of preds) { + let child = node.next.get(pred) + if (!child) { + child = { next: new WeakMap() } + node.next.set(pred, child) + } + node = child + } + return (node.result ??= combine(preds)) + } +} diff --git a/packages/utils/src/filters/index.ts b/packages/utils/src/filters/index.ts index e3df1ce0..c33d7513 100644 --- a/packages/utils/src/filters/index.ts +++ b/packages/utils/src/filters/index.ts @@ -1,19 +1,11 @@ +import { extract, matchNode, matchNodeKey, type JsonData } from 'json-edit-react' import type { FilterPredicate, NodeValueType, PathPattern, Range } from './types' -import { intern } from './_intern' +import { intern, internRef, internRefs } from './_intern' import { compilePathMatcher } from './_glob' // Public types. `Range` is intentionally NOT re-exported (see types.ts). export type { FilterPredicate, NodeValueType, PathPattern } from './types' -// --------------------------------------------------------------------------- -// SCAFFOLD — signatures only. Implemented one at a time (#343, step 3). Each -// throws until built, so the test suite fails loudly rather than passing on a -// stub's accidental return value (no false greens). -// --------------------------------------------------------------------------- -const TODO = (name: string): never => { - throw new Error(`[@json-edit-react/utils] filters: "${name}" not implemented yet`) -} - // --- Property builders ------------------------------------------------------ /** Matches when the node's own key is one of `keys` (string/number = exact on @@ -104,43 +96,84 @@ export const byValue: ( // --- Node-position constants ------------------------------------------------ +// A value is a collection (object or array) iff it's a non-null object — the +// same test the editor uses to split collection nodes from leaves. +const isCollectionValue = (value: unknown): boolean => value !== null && typeof value === 'object' + /** The root node (level 0). */ -export const root: FilterPredicate = () => TODO('root') +export const root: FilterPredicate = ({ level }) => level === 0 /** Objects and arrays. */ -export const collections: FilterPredicate = () => TODO('collections') +export const collections: FilterPredicate = ({ value }) => isCollectionValue(value) /** Leaf values (everything that isn't a collection). */ -export const primitives: FilterPredicate = () => TODO('primitives') +export const primitives: FilterPredicate = ({ value }) => !isCollectionValue(value) /** Nodes whose parent is an array. */ -export const inArray: FilterPredicate = () => TODO('inArray') +export const inArray: FilterPredicate = ({ parentData }) => Array.isArray(parentData) -/** Nodes whose parent is an object. */ -export const inObject: FilterPredicate = () => TODO('inObject') +/** Nodes whose parent is an object (not an array; the root has no parent). */ +export const inObject: FilterPredicate = ({ parentData }) => + isCollectionValue(parentData) && !Array.isArray(parentData) // --- Combinators ------------------------------------------------------------ +// Identity elements for the empty combinator calls — module-level singletons, +// so `and()` / `or()` are referentially stable too. +const alwaysTrue: FilterPredicate = () => true +const alwaysFalse: FilterPredicate = () => false + /** True when every predicate matches. `and()` (no args) is always true. */ -export const and = (...preds: FilterPredicate[]): FilterPredicate => (void preds, TODO('and')) +export const and: (...preds: FilterPredicate[]) => FilterPredicate = internRefs( + (preds) => (node, searchText) => preds.every((p) => p(node, searchText)), + alwaysTrue +) /** True when any predicate matches. `or()` (no args) is always false. */ -export const or = (...preds: FilterPredicate[]): FilterPredicate => (void preds, TODO('or')) +export const or: (...preds: FilterPredicate[]) => FilterPredicate = internRefs( + (preds) => (node, searchText) => preds.some((p) => p(node, searchText)), + alwaysFalse +) /** Negates a predicate. */ -export const not = (pred: FilterPredicate): FilterPredicate => (void pred, TODO('not')) +export const not: (pred: FilterPredicate) => FilterPredicate = internRef( + (pred) => (node, searchText) => !pred(node, searchText) +) // --- Search bridges --------------------------------------------------------- -/** Wraps core's `matchNode` / `matchNodeKey` against the current `searchText`. - * `mode` defaults to `'value'` — the editor's own default `searchFilter`. */ -export const matchesSearch = (mode: 'key' | 'value' | 'all' = 'value'): FilterPredicate => - (void mode, TODO('matchesSearch')) +/** Matches against the current `searchText` using core's own matchers: + * `'value'` (the default — node values), `'key'` (keys + path segments), or + * `'all'` (either). `searchText` is threaded in by the editor / a combinator. */ +export const matchesSearch: (mode?: 'key' | 'value' | 'all') => FilterPredicate = intern( + (mode: 'key' | 'value' | 'all' = 'value'): FilterPredicate => { + if (mode === 'key') return (node, searchText = '') => matchNodeKey(node, searchText) + if (mode === 'all') + return (node, searchText = '') => + matchNode(node, searchText) || matchNodeKey(node, searchText) + return (node, searchText = '') => matchNode(node, searchText) + } +) /** Reveals a whole record when one of its `fields` values matches `searchText`. * `path` (a `byPath` pattern, default `'*'` = top-level items) locates the - * record layer. */ -export const matchRecord = (options: { - fields: string[] - path?: PathPattern -}): FilterPredicate => (void options, TODO('matchRecord')) + * record layer: a node belongs to the SHORTEST prefix of its path that matches + * `path`. Pin a layer with the pattern — avoid `**` for the record path. */ +export const matchRecord: (options: { fields: string[]; path?: PathPattern }) => FilterPredicate = + intern((options: { fields: string[]; path?: PathPattern }): FilterPredicate => { + const { fields, path = '*' } = options + const isRecordPath = compilePathMatcher(path) + return ({ path: nodePath, fullData }, searchText = '') => { + for (let len = 0; len <= nodePath.length; len++) { + const prefix = nodePath.slice(0, len) + if (!isRecordPath(prefix)) continue + // The prefix is always a valid slice of this node's path, so extract + // resolves it without hitting its throw-on-missing branch. + const record: unknown = extract(fullData, prefix) + if (record === null || typeof record !== 'object') return false + const rec = record as Record + return fields.some((f) => matchNode({ value: rec[f] as JsonData }, searchText)) + } + return false // the node sits above the record layer + } + }) From 3f469f43b5d6de804cd5e93be9c08641b5c037db Mon Sep 17 00:00:00 2001 From: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:32:10 +1200 Subject: [PATCH 04/11] Apply helpers to demo --- demo/src/demoData/dataDefinitions.tsx | 72 ++++++++++++++++----------- 1 file changed, 43 insertions(+), 29 deletions(-) diff --git a/demo/src/demoData/dataDefinitions.tsx b/demo/src/demoData/dataDefinitions.tsx index ff660099..4722129d 100644 --- a/demo/src/demoData/dataDefinitions.tsx +++ b/demo/src/demoData/dataDefinitions.tsx @@ -35,6 +35,16 @@ import { UpdateFunctionProps, type AssignInput, } from '@json-edit-react' +import { + and, + byKey, + byLevel, + byType, + matchRecord, + not, + primitives, + root, +} from '@json-edit-react/utils' import jsonSchema from './jsonSchema.json' import customNodesSchema from './customNodesSchema.json' import Ajv from 'ajv' @@ -172,9 +182,12 @@ export const demoDataDefinitions: Record = { ), rootName: 'Star Wars data', - allowEdit: ({ value }) => typeof value !== 'object' || value === null, - allowDelete: ({ value }) => typeof value !== 'object' || value === null, - allowAdd: ({ value }) => Array.isArray(value), + // allowEdit: ({ value }) => typeof value !== 'object' || value === null, + allowEdit: primitives, + // allowDelete: ({ value }) => typeof value !== 'object' || value === null, + allowDelete: primitives, + // allowAdd: ({ value }) => Array.isArray(value), + allowAdd: byType('array'), allowTypeSelection: ({ key, path }) => { if (path.slice(-2)[0] === 'films' || (path.slice(-3)[0] === 'films' && key === 'title')) return [ @@ -301,20 +314,14 @@ export const demoDataDefinitions: Record = { ), rootName: 'Clients', - allowEdit: ({ key, level }) => key !== 'id' && level !== 0 && level !== 1, - allowAdd: ({ level }) => level !== 1, - allowDelete: ({ level }) => level === 1, + // allowEdit: ({ key, level }) => key !== 'id' && level !== 0 && level !== 1, + allowEdit: and(not(byKey('id')), byLevel({ min: 2 })), + // allowAdd: ({ level }) => level !== 1, + allowAdd: not(byLevel(1)), + // allowDelete: ({ level }) => level === 1, + allowDelete: byLevel(1), collapse: 2, - searchFilter: ({ path, fullData }, searchText) => { - const data = fullData as { name: string; username: string }[] - if (path?.length >= 2) { - const index = path?.[0] as number - return ( - matchNode({ value: data[index].name }, searchText) || - matchNode({ value: data[index].username }, searchText) - ) - } else return false - }, + searchFilter: matchRecord({ fields: ['name', 'username'] }), searchPlaceholder: 'Search by name or username', defaultValue: ({ level }) => { if (level === 0) @@ -610,9 +617,12 @@ export const demoDataDefinitions: Record = { ), rootName: 'theme', - allowEdit: ({ key, level }) => level !== 0 && !['fragments', 'styles'].includes(key as string), - allowDelete: ({ key }) => !['displayName', 'fragments', 'styles'].includes(key as string), - allowAdd: ({ level }) => level !== 0, + // allowEdit: ({ key, level }) => level !== 0 && !['fragments', 'styles'].includes(key as string), + allowEdit: and(not(root), not(byKey('fragments', 'styles'))), + // allowDelete: ({ key }) => !['displayName', 'fragments', 'styles'].includes(key as string), + allowDelete: not(byKey('displayName', 'fragments', 'styles')), + // allowAdd: ({ level }) => level !== 0, + allowAdd: not(root), allowTypeSelection: ['string', 'object', 'array'], collapse: 2, searchFilter: 'key', @@ -667,16 +677,18 @@ export const demoDataDefinitions: Record = { ), rootName: 'Superheroes', collapse: 2, - searchFilter: ({ path, fullData }, searchText = '') => { - const data = fullData as { name: string }[] - if (path?.length >= 2) { - const index = path?.[0] as number - return matchNode({ value: data[index].name }, searchText) - } else return false - }, + // searchFilter: ({ path, fullData }, searchText = '') => { + // const data = fullData as { name: string }[] + // if (path?.length >= 2) { + // const index = path?.[0] as number + // return matchNode({ value: data[index].name }, searchText) + // } else return false + // }, + searchFilter: matchRecord({ fields: ['name'] }), searchPlaceholder: 'Search by character name', data: data.customNodes, - allowEdit: ({ level }) => level === 0, + // allowEdit: ({ level }) => level === 0, + allowEdit: root, allowAdd: false, allowDelete: false, onUpdate: ({ newData }, toast) => { @@ -867,8 +879,10 @@ export const demoDataDefinitions: Record = { rootName: 'dossier', collapse: 2, data: data.customKeys, - allowAdd: ({ level }) => level !== 0, - allowDelete: ({ level }) => level !== 0, + // allowAdd: ({ level }) => level !== 0, + allowAdd: not(root), + // allowDelete: ({ level }) => level !== 0, + allowDelete: not(root), customNodeDefinitions: [ // 1. "REDACTED_" prefix — blacked-out key, original visible on hover. // Must come before the `_` matcher (which would still match these From 0f743a1f298b98fee39850dffaa11d8c117f45c5 Mon Sep 17 00:00:00 2001 From: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:43:06 +1200 Subject: [PATCH 05/11] Add documentation --- .changeset/utils-filter-toolkit.md | 9 ++ packages/utils/IDEAS.md | 8 +- packages/utils/README.md | 20 +++++ packages/utils/src/filters/README.md | 130 +++++++++++++++++++++++++++ 4 files changed, 164 insertions(+), 3 deletions(-) create mode 100644 .changeset/utils-filter-toolkit.md create mode 100644 packages/utils/src/filters/README.md diff --git a/.changeset/utils-filter-toolkit.md b/.changeset/utils-filter-toolkit.md new file mode 100644 index 00000000..d88f4a22 --- /dev/null +++ b/.changeset/utils-filter-toolkit.md @@ -0,0 +1,9 @@ +--- +'@json-edit-react/utils': minor +--- + +Add a filter-function toolkit — composable predicate builders for the `allow*` props (`allowEdit`, `allowDelete`, `allowAdd`, `allowTypeSelection`, …) and `searchFilter`. + +Every builder returns the same `FilterPredicate` shape — `(node, searchText?) => boolean` — whose optional second argument makes it assignable to both `FilterFunction` (the `allow*` props) and `SearchFilterFunction` (`searchFilter`), so one set of builders serves every filter prop. Property builders: `byKey`, `byPath` (glob / RegExp / segment-array paths), `byLevel`, `bySize`, `byType`, `byValue`. Position constants: `root`, `collections`, `primitives`, `inArray`, `inObject`. Combinators: `and` / `or` / `not` (they thread `searchText`, so search bridges compose with structural builders). Search bridges: `matchesSearch(mode?)` (wraps core's own matchers) and `matchRecord({ fields, path? })` (reveals a whole record when one of its fields matches, instead of collapsing it to the single matching field). + +Each builder interns its result, so equal arguments return the same instance — you can write a builder inline on a prop (`allowEdit={byKey('id')}`) without a `useMemo` or hoisting, and it won't churn json-edit-react's fine-grained re-rendering. No third-party runtime dependencies; the search bridges reuse core's exported `matchNode` / `matchNodeKey` / `extract`. See `src/filters/README.md` for the full reference, including the glob path syntax. diff --git a/packages/utils/IDEAS.md b/packages/utils/IDEAS.md index f3f42b53..32f50579 100644 --- a/packages/utils/IDEAS.md +++ b/packages/utils/IDEAS.md @@ -22,7 +22,9 @@ it's a place to capture and rank ideas before they earn an issue. - **Search helpers** — ready-made `searchFilter` functions for common patterns. ([#319](https://github.com/CarlosNZ/json-edit-react/issues/319)) Should also include **`expandToMatches(data, searchText, searchFilter?)`** — reveal search matches hidden inside collapsed subtrees. Has to be a *data-level* traversal: lazy mounting means core never evaluates `filterNode` below the collapse frontier, so it can't know about deep matches — walk `data` with the search predicate, collect matching paths, emit a `CollapseState[]` expanding every ancestor, feed it to `editorRef.collapse`. Composes with the `searchFilter` helpers (same predicate finds the paths). Drift hazard: with no custom `searchFilter` it must mirror core's default match semantics (`searchProperty`, collection-matches-via-descendants) — build it on core's exported `matchNode` / `matchNodeKey`, don't re-implement. -- **Filter-function toolkit** — composable predicate builders (`byKey`, `byPath`, `byLevel`, `byType`, …) + `and`/`or`/`not` combinators for the `allow*` props and `searchFilter`. Full design with examples in [#343](https://github.com/CarlosNZ/json-edit-react/issues/343). +- **Filter-function toolkit** — composable predicate builders (`byKey`, `byPath`, `byLevel`, `bySize`, `byType`, `byValue`), `root`/`collections`/`primitives`/`inArray`/`inObject` constants, `and`/`or`/`not` combinators, and the `matchesSearch`/`matchRecord` search bridges, for the `allow*` props and `searchFilter`. _Shipped_ (`src/filters/`). Design with examples in [#343](https://github.com/CarlosNZ/json-edit-react/issues/343). + + Possible companion — **a predicate-driven dispatcher** (working names `pick` / `cond` / `choose`): `pick(predicate, value, predicate, value, …, default?)` → a function returning the value for the first matching predicate. One tier above the filter kit — `node → anything`, not `node → boolean` — so it's a *dispatcher* built on the predicates, **not** a filter builder (don't make it assignable to filter props, and don't name it `match*`, which collides with the boolean `matchNode`/`matchRecord`/`matchesSearch`). First-match-wins; needs a trailing default so it can return `false` (e.g. `allowTypeSelection`'s no-match value); thread `searchText` to the conditions. Typing fork: flat alternating args read best but type awkwardly (recursive variadic tuple / overload stack) vs. pairs `pick([p, v], …)` which type cleanly. Payloads aren't internable, so document "define it in your static config" rather than promising inline memo-safety. Earns its keep only if it has more than one customer (`allowTypeSelection` is the obvious one; a node→value `defaultValue` form, future node-keyed value props are the maybes) — for a single small table the hand-written if-chain is barely longer. Parked 2026-06-16. --- @@ -103,8 +105,8 @@ plus a `diffJson(a, b)` helper for "what changed" UIs. Zero-dep. ## Rough priority 1. Undo / redo — basic `useUndo` shipped; settlement-awareness extension pending -2. Persistence -3. Filter-function toolkit +2. Filter-function toolkit — _shipped_ (`src/filters/`) +3. Persistence …then validate-on-commit and dirty-state. diff --git a/packages/utils/README.md b/packages/utils/README.md index 3020dedb..24453c2b 100644 --- a/packages/utils/README.md +++ b/packages/utils/README.md @@ -21,6 +21,7 @@ pnpm add @json-edit-react/utils - **Confirm-before-update hooks** — gate edits/deletes on a confirmation dialog without hand-rolling the deferred-promise dance. _Available now._ ([#307](https://github.com/CarlosNZ/json-edit-react/issues/307)) - **Undo / redo** — wrap a consumer-owned `data`/`setData` pair with undo/redo (snapshot stacks plus `canUndo` / `canRedo`), zero-dep. _Available now._ - **Reactive validation** — `useValidationState` runs your validator over the whole document and exposes an O(1), identity-stable error index, so styles / filters / conditions reflect validity correctly even for cross-branch effects. Ships `validationStyles` (theme sugar), `ajvAdapter`, and the `useStableValue` primitive it's built on. Zero-dep (you bring your own validator). _Available now._ ([#357](https://github.com/CarlosNZ/json-edit-react/issues/357)) +- **Filter-function toolkit** — composable predicate builders (`byKey`, `byPath`, `byLevel`, `byType`, …), `and` / `or` / `not` combinators, and search bridges for the `allow*` props and `searchFilter`. Zero-dep. _Available now._ ([#343](https://github.com/CarlosNZ/json-edit-react/issues/343)) - **JSON Schema → Filter Functions** — generate `allowEdit` / `allowDelete` / `allowAdd` (etc.) functions from a JSON Schema so the editor UI can't produce invalid data in the first place. _Planned._ ([#285](https://github.com/CarlosNZ/json-edit-react/issues/285)) - **Search helpers** — ready-made `searchFilter` functions for common search patterns. _Planned._ ([#319](https://github.com/CarlosNZ/json-edit-react/issues/319)) @@ -160,6 +161,25 @@ const MyEditor = () => { You bring your own validator (`ajvAdapter` wraps a compiled AJV function; or pass any `(data) => ValidationIssue[]`), so the package stays zero-dependency. See [src/validation/README.md](src/validation/README.md) for the consumption recipes (styles, a glyph via a custom node, `allow*` gating) and [src/stable-value/README.md](src/stable-value/README.md) for `useStableValue`, the identity-stabilizer it's built on. +## Filter-function toolkit + +The `allow*` props and `searchFilter` all take a function of a node; hand-writing them — destructure `key` / `path` / `level` / `value`, compare, combine — is repetitive. This kit gives you small, named, composable pieces instead: property builders (`byKey`, `byPath`, `byLevel`, `bySize`, `byType`, `byValue`), position constants (`root`, `collections`, `primitives`, `inArray`, `inObject`), `and` / `or` / `not` combinators, and the search bridges `matchesSearch` / `matchRecord`. Every piece is the same `FilterPredicate` shape, so it works on any of those props, and each builder interns its result — so you can write them inline without a `useMemo` or churning the editor's memoization. + +```tsx +import { JsonEditor } from 'json-edit-react' +import { and, byKey, byLevel, byType, matchRecord, not, primitives } from '@json-edit-react/utils' + + +``` + +See [src/filters/README.md](src/filters/README.md) for the full reference — every builder, the glob path syntax, and the composition / referential-stability rules. + ## License MIT diff --git a/packages/utils/src/filters/README.md b/packages/utils/src/filters/README.md new file mode 100644 index 00000000..c5665d79 --- /dev/null +++ b/packages/utils/src/filters/README.md @@ -0,0 +1,130 @@ +# Filter-function toolkit + +Composable predicate builders for `json-edit-react`'s `allow*` props (`allowEdit`, `allowDelete`, `allowAdd`, `allowTypeSelection`, `allowDrag`, …) and `searchFilter`. Exported from `@json-edit-react/utils`. Zero runtime dependencies. + +Those props all take a function of a node — `allowEdit={(node) => …}`, `searchFilter={(node, text) => …}` — and hand-writing them is repetitive: you destructure `key` / `path` / `level` / `value`, compare, and combine. This kit replaces the boilerplate with small, named, composable pieces — `byKey('id')`, `byLevel({ min: 2 })`, `and`, `or`, `not` — that read like a sentence and snap together. Every builder returns the same `FilterPredicate` shape, so the same piece works on any of those props. + +## Quick start + +```tsx +import { JsonEditor } from 'json-edit-react' +import { and, byKey, byLevel, byType, matchRecord, not, primitives, root } from '@json-edit-react/utils' + +// Only leaf values are editable; only arrays accept new children. + + +// Everything below the top two levels is editable, except `id` fields. + + +// Searching a list of records keeps a whole record visible when its name OR +// username matches — not just the single field that matched. + + +// The root object is locked; everything else can be added to and deleted. + +``` + +You can write these **inline**, as above, without a `useMemo` or hoisting them out of render — see [Referential stability](#referential-stability) for why that's safe. + +## The predicate type + +Everything here is a `FilterPredicate`: + +```ts +type FilterPredicate = (node: NodeData, searchText?: string) => boolean +``` + +The optional second argument is the load-bearing detail: a function of this shape is assignable to **both** `FilterFunction` (the `allow*` props, called with one argument) **and** `SearchFilterFunction` (`searchFilter`, called with two). So one set of builders serves every filter prop — there are no search-specific variants. The combinators thread `searchText` through, so a search bridge composes with a structural builder (`and(matchesSearch(), byLevel({ min: 1 }))`). + +## Property builders + +| Builder | Matches when… | +| --- | --- | +| `byKey(...keys)` | the node's own key is one of `keys`. A string/number matches the stringified key exactly (so `byKey(0)` matches array index `0`); a `RegExp` is tested against the stringified key. | +| `byPath(pattern)` | the node's path matches `pattern` — a glob string, a `RegExp`, or an explicit segment array. See [Path patterns](#path-patterns). | +| `byLevel(range)` | the node's depth is within `range` (the root is level `0`). | +| `bySize(range)` | a collection's child count is within `range`. Leaves have no size and never match. | +| `byType(...types)` | the node's value type is one of `types`: `'string'`, `'number'`, `'boolean'`, `'null'`, `'object'`, `'array'`. | +| `byValue(...values)` | a leaf's value equals one of `values`. Literals (`string` / `number` / `boolean` / `null`) match by strict equality; a `RegExp` is tested against the stringified value. Collections never match. | + +`range` (for `byLevel` / `bySize`) is `number | { min?: number; max?: number }`: a bare number means *exactly* that, the object form bounds one or both ends, and an omitted end is unbounded. Bounds are inclusive — `byLevel(1)` is level 1 only, `byLevel({ min: 2 })` is level 2 and deeper, `bySize({ max: 0 })` is empty collections. + +## Path patterns + +`byPath` (and `matchRecord`'s `path` option) accept three forms. + +**Glob string** — segments split on `.`, matched against the node's path: + +| Token | Means | Example | Matches | +| --- | --- | --- | --- | +| *(a bare name)* | that exact segment | `'users'` | `users` only — not `users.0` | +| `*` | any one whole segment | `'users.*'` | `users.0`, `users.name` — *not* `users.0.name` | +| `*` *(within a segment)* | zero or more characters, not crossing a `.` | `'*Id'` | `userId`, `Id` | +| `?` | exactly one character | `'a?c'` | `abc` — not `ac` | +| `**` | zero or more whole segments (a subtree, *including its own root*) | `'users.**'` | `users`, `users.0`, `users.0.name` | +| `{a,b}` | alternation (nestable) | `'{name,email}'` | `name` or `email` | +| `[0]` / `.0` | an array index (equivalent forms) | `'users[0].name'` | `users.0.name` | + +Patterns are anchored at both ends — `'a.b'` matches the path `a.b` exactly, not a path that merely contains it. `'**'` alone matches every node (including the root); `''` matches only the root. + +**RegExp** — tested against the *stringified* path (dotted, with `[n]` for indices) and **not** anchored, so it matches anywhere in the path: `byPath(/^users\b/)`, `byPath(/\.geo$/)`. + +**Segment array** — the escape hatch for keys that contain a literal `.` (a glob string would split on it): `byPath(['a.b', 'c'])` matches the two-segment path whose first key is literally `a.b`. `*` / `**` / `?` / `{…}` still apply *within* each element, so `['users', '*', 'email']` globs the middle segment. + +## Position constants + +These are plain predicates (no call needed) — use them directly or inside a combinator. + +| Constant | Matches | +| --- | --- | +| `root` | the root node (level `0`). | +| `collections` | objects and arrays. | +| `primitives` | leaf values — everything that isn't a collection. | +| `inArray` | nodes whose parent is an array. | +| `inObject` | nodes whose parent is an object (not an array; the root, having no parent, doesn't match). | + +## Combinators + +`and(...preds)`, `or(...preds)`, and `not(pred)` build a predicate from others. They propagate `searchText`, so search bridges compose freely. The empty calls are the identity elements: `and()` is always true, `or()` is always false. + +```ts +allowEdit={and(not(root), not(byKey('fragments', 'styles')))} // editable, except the root and two reserved keys +allowDelete={or(byLevel({ min: 2 }), byType('array'))} // deep nodes, or any array +``` + +A combinator's argument is any `FilterPredicate`, so a one-off inline function drops straight in alongside the kit's builders: `or(byKey('id'), (node) => node.value === 0)`. + +## Search bridges + +These turn the editor's live search into a predicate. + +`matchesSearch(mode?)` matches the current `searchText` using core's own matchers — `'value'` (the default, node values), `'key'` (keys and path segments), or `'all'` (either). On its own, `searchFilter={matchesSearch()}` is just the editor's default search; its value is **composition** — combine it with structure to bend the default: `or(matchesSearch(), byKey('id'))` keeps `id` fields visible during a search, and `and(matchesSearch('value'), not(byLevel(0)))` searches values but never reveals the root. + +`matchRecord({ fields, path? })` reveals a whole **record** when one of its `fields` matches the search — so a search over a list of objects keeps each matching object intact rather than collapsing it to the single field that matched. `path` (a `byPath` pattern, default `'*'` = the top-level items) locates the record layer: a node belongs to the *shortest* prefix of its path that matches `path`. Pin the layer with the pattern — avoid `**` for the record path, or every ancestor prefix qualifies. + +```ts +searchFilter={matchRecord({ fields: ['name', 'username'] })} // top-level records +searchFilter={matchRecord({ fields: ['title'], path: 'sections.*.items.*' })} // a nested record layer +``` + +## Referential stability + +The `allow*` and `searchFilter` props are compared by identity at the node `React.memo` boundary (and memoized upstream on prop identity), so a *fresh* function on every render would defeat json-edit-react's fine-grained re-rendering tree-wide. That's normally why you'd hoist a filter or wrap it in `useMemo`. + +These builders remove that chore: each one **interns** its result, so equal arguments return the *same* instance. `byKey('name')` called this render is the exact function it returned last render, so writing it inline on a prop is memo-safe. A genuinely different argument still produces a different instance, so real changes propagate. Combinators intern on their children's identities, so a composite over the kit's own builders — `and(not(root), byLevel({ min: 1 }))` — is inline-stable too. + +The one thing that breaks the chain is a *raw* inline function: `and(byKey('id'), (node) => node.value === 0)` mints a new arrow each render, so the `and` can't be cached. If you need that, hoist the inline part (or the whole `and`) to module scope or a `useMemo`, as you would any function prop. + +## Using a predicate inside your own callback + +The builders are plain `node → boolean` functions, so they're useful anywhere you need a boolean about a node — including inside a hand-written callback that returns something *other* than a boolean, like `allowTypeSelection` (which returns type/enum options): + +```tsx +allowTypeSelection={(node) => { + if (byKey('eye_color', 'hair_color')(node)) return ['blue', 'brown', 'green'] + if (byType('number')(node)) return ['number', 'string'] + return false +}} +``` + +Note the predicate must be **called** with the node — `byKey('eye_color')(node)`, not `byKey('eye_color')`. A bare `if (byKey('eye_color'))` is always truthy (a function is truthy), which would fire every branch. From 7ee70d52d214bdc2ba48896bc4ca5b00a9bdbdb2 Mon Sep 17 00:00:00 2001 From: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Tue, 16 Jun 2026 18:16:30 +1200 Subject: [PATCH 06/11] Build initial example page --- demo/src/examples/example-list.md | 1 + demo/src/examples/filter-toolkit/Example.tsx | 233 ++++++++++++++++++ demo/src/examples/filter-toolkit/data.ts | 194 +++++++++++++++ demo/src/examples/filter-toolkit/recipes.ts | 234 +++++++++++++++++++ demo/src/examples/registry.ts | 7 + packages/utils/IDEAS.md | 2 +- 6 files changed, 670 insertions(+), 1 deletion(-) create mode 100644 demo/src/examples/filter-toolkit/Example.tsx create mode 100644 demo/src/examples/filter-toolkit/data.ts create mode 100644 demo/src/examples/filter-toolkit/recipes.ts diff --git a/demo/src/examples/example-list.md b/demo/src/examples/example-list.md index 1c8bd2f9..b30d17b8 100644 --- a/demo/src/examples/example-list.md +++ b/demo/src/examples/example-list.md @@ -17,6 +17,7 @@ | External control | | | | Custom components | | | One that demonstrates all the types and props | | Custom components | | | One that demonstrates all the types and props | +| Filter toolkit | ✅️ | | Predicate builders from `@json-edit-react/utils`: highlight matches via a theme layer, bind to `allowEdit`, `matchesSearch`/`matchRecord` bridges; org-chart data | | Search utilities | | | Demonstrate "Search" utils | | JsonViewer | | | | | Validation staleness | ✅️ | | `devOnly` scratchpad — naive style-fn validation under fine-grained re-rendering | diff --git a/demo/src/examples/filter-toolkit/Example.tsx b/demo/src/examples/filter-toolkit/Example.tsx new file mode 100644 index 00000000..4c88ed70 --- /dev/null +++ b/demo/src/examples/filter-toolkit/Example.tsx @@ -0,0 +1,233 @@ +import { useMemo, useState, type CSSProperties } from 'react' +import { + Box, + Button, + Code, + Flex, + FormLabel, + Heading, + Input, + Select, + Switch, + Text, + Wrap, + WrapItem, +} from '@chakra-ui/react' +import { JsonEditor, type JsonData, type NodeData, type ThemeStyles } from '@json-edit-react' +import { type FilterPredicate } from '@json-edit-react/utils' +import { useExampleProps, useExampleTheme } from '../kit/exampleProps' +import { useExamplePalette } from '../kit/useThemePalette' +import { SplitPane } from '../kit/SplitPane' +import { orgData } from './data' +import { RECIPES, RECIPE_GROUPS, SEARCH_STRATEGIES } from './recipes' + +// The match highlight. A translucent amber reads on both light and dark themes, +// and the rounded corners make a tinted key/value look like a pill. +const HIGHLIGHT: CSSProperties = { + backgroundColor: 'rgba(255, 196, 0, 0.5)', + borderRadius: '0.2em', +} + +// Turn a predicate into a theme layer that paints every node it matches. The +// whole trick of this example: a `FilterPredicate` is `(node) => boolean` and a +// theme style function is `(node) => CSSProperties`, so the predicate drops +// straight in. We tint both the key (`property`) and the value of a matched +// node — plus the brackets — so collection and leaf matches both read clearly. +const buildHighlightTheme = (match: FilterPredicate): ThemeStyles => { + const paint = (nodeData: NodeData): CSSProperties | undefined => + match(nodeData) ? HIGHLIGHT : undefined + return { + property: paint, + string: paint, + number: paint, + boolean: paint, + null: paint, + bracket: paint, + } +} + +export default function FilterToolkit() { + const exampleProps = useExampleProps() + const baseTheme = useExampleTheme() + const palette = useExamplePalette() + + const [data, setData] = useState(orgData) + const [recipeId, setRecipeId] = useState('numbers') + const [lockNonMatching, setLockNonMatching] = useState(false) + const [searchText, setSearchText] = useState('') + const [searchId, setSearchId] = useState('value') + + const activeRecipe = RECIPES.find((r) => r.id === recipeId) ?? RECIPES[0] + const activeSearch = SEARCH_STRATEGIES.find((s) => s.id === searchId) ?? SEARCH_STRATEGIES[0] + + // Recompose only when the base theme or the active recipe changes. The + // recipe's predicate is interned (a stable reference per recipe), so this + // doesn't churn between unrelated renders. + const theme = useMemo( + () => [baseTheme, buildHighlightTheme(activeRecipe.predicate)], + [baseTheme, activeRecipe] + ) + + return ( + + + + } + right={ + + {/* Section 1 — highlight matches (+ optional allowEdit binding). */} + + + Highlight matches + + + Pick a builder — every node it matches lights up in the tree. A predicate is just{' '} + {'(node) => boolean'}, and a theme style function is{' '} + {'(node) => CSSProperties'}, so the same function drives + the highlight. + + + {RECIPE_GROUPS.map((group) => ( + + + {group} + + + {RECIPES.filter((r) => r.group === group).map((r) => ( + + + + ))} + + + ))} + + + {activeRecipe.code} + + + {activeRecipe.description} + + + + setLockNonMatching(e.target.checked)} + /> + + Also bind it to allowEdit — lock non-matching nodes + + + + {lockNonMatching + ? 'Double-click a value to try — edits are blocked on non-highlighted nodes. (allowEdit governs value editing, so leaf-targeting recipes show it best.)' + : 'Off — every node is editable, as usual.'} + + + + {/* Section 2 — search bridges. */} + + + Search bridges + + + These turn the live search into a searchFilter. Type a + query, then switch strategy to compare how each one reveals matches. + + setSearchText(e.target.value)} + /> + + + {activeSearch.code} + + + {activeSearch.description} + + + + } + /> + ) +} diff --git a/demo/src/examples/filter-toolkit/data.ts b/demo/src/examples/filter-toolkit/data.ts new file mode 100644 index 00000000..82eb7a5e --- /dev/null +++ b/demo/src/examples/filter-toolkit/data.ts @@ -0,0 +1,194 @@ +// A deep, varied org-chart document for the Filter toolkit example. It's +// deliberately broad so every builder has something to match: a top-level +// array of department records, nested teams and members, mixed value types +// (strings, numbers, booleans, nulls), URLs, ISO timestamps (`*_at` keys), +// tag/skill arrays, and both large and small collections. + +export const orgData = { + company: 'Helios Robotics', + founded: 2009, + public: false, + website: 'https://heliosrobotics.example', + employees: 184, + headquarters: { + city: 'Wellington', + country: 'New Zealand', + address: '12 Cuba Street', + geo: { lat: -41.2924, lng: 174.7787 }, + }, + parentCompany: null, + certifications: ['ISO 9001', 'ISO 27001', 'B-Corp'], + created_at: '2009-03-14T09:00:00Z', + updated_at: '2026-06-10T16:45:00Z', + departments: [ + { + id: 'dept-eng', + name: 'Engineering', + headcount: 64, + budget: 4200000, + remote: true, + lead: { + name: 'Ada Okonkwo', + email: 'ada@heliosrobotics.example', + phone: '+64 21 555 0101', + }, + tags: ['firmware', 'control-systems', 'simulation'], + teams: [ + { + name: 'Motion Control', + focus: 'Actuator firmware and real-time control loops', + active: true, + repository: 'https://git.example/helios/motion', + members: [ + { + id: 'u-1042', + name: 'Bao Tran', + email: 'bao@heliosrobotics.example', + role: 'Staff Engineer', + active: true, + skills: ['C++', 'RTOS', 'Kalman filters'], + joined: '2014-08-01', + avatar: 'https://cdn.example/avatars/bao.png', + manager: null, + created_at: '2014-08-01T08:30:00Z', + }, + { + id: 'u-1108', + name: 'Priya Nair', + email: 'priya@heliosrobotics.example', + role: 'Senior Engineer', + active: true, + skills: ['Rust', 'embedded Linux'], + joined: '2018-02-12', + avatar: 'https://cdn.example/avatars/priya.png', + manager: 'u-1042', + created_at: '2018-02-12T10:00:00Z', + }, + ], + }, + { + name: 'Perception', + focus: 'Sensor fusion and computer vision', + active: true, + repository: 'https://git.example/helios/perception', + members: [ + { + id: 'u-1190', + name: 'Marcus Webb', + email: 'marcus@heliosrobotics.example', + role: 'Engineer', + active: false, + skills: ['Python', 'PyTorch', 'OpenCV', 'SLAM'], + joined: '2020-09-30', + avatar: 'https://cdn.example/avatars/marcus.png', + manager: 'u-1042', + created_at: '2020-09-30T14:15:00Z', + }, + ], + }, + ], + }, + { + id: 'dept-design', + name: 'Design', + headcount: 18, + budget: 980000, + remote: false, + lead: { + name: 'Sofia Marchetti', + email: 'sofia@heliosrobotics.example', + phone: '+64 21 555 0144', + }, + tags: ['industrial-design', 'ux'], + teams: [ + { + name: 'Hardware Design', + focus: 'Enclosures, ergonomics and materials', + active: true, + repository: null, + members: [ + { + id: 'u-2051', + name: 'Tomas Beck', + email: 'tomas@heliosrobotics.example', + role: 'Industrial Designer', + active: true, + skills: ['CAD', 'prototyping'], + joined: '2016-05-04', + avatar: 'https://cdn.example/avatars/tomas.png', + manager: null, + created_at: '2016-05-04T09:45:00Z', + }, + { + id: 'u-2099', + name: 'Lena Ford', + email: 'lena@heliosrobotics.example', + role: 'UX Designer', + active: true, + skills: ['Figma', 'user-research', 'prototyping'], + joined: '2021-11-22', + avatar: 'https://cdn.example/avatars/lena.png', + manager: 'u-2051', + created_at: '2021-11-22T11:30:00Z', + }, + ], + }, + ], + }, + { + id: 'dept-ops', + name: 'Field Operations', + headcount: 41, + budget: 1750000, + remote: false, + lead: { + name: 'Diego Alvarez', + email: 'diego@heliosrobotics.example', + phone: '+64 21 555 0177', + }, + tags: ['deployment', 'support', 'logistics'], + teams: [ + { + name: 'Deployment', + focus: 'On-site installation and commissioning', + active: true, + repository: 'https://git.example/helios/deploy-runbooks', + members: [ + { + id: 'u-3010', + name: 'Hana Suzuki', + email: 'hana@heliosrobotics.example', + role: 'Field Engineer', + active: true, + skills: ['networking', 'PLC', 'safety-systems'], + joined: '2019-07-08', + avatar: 'https://cdn.example/avatars/hana.png', + manager: null, + created_at: '2019-07-08T08:00:00Z', + }, + ], + }, + { + name: 'Support', + focus: 'Customer support and incident response', + active: false, + repository: null, + members: [ + { + id: 'u-3088', + name: 'Omar Haddad', + email: 'omar@heliosrobotics.example', + role: 'Support Lead', + active: true, + skills: ['troubleshooting', 'documentation'], + joined: '2022-03-15', + avatar: 'https://cdn.example/avatars/omar.png', + manager: 'u-3010', + created_at: '2022-03-15T13:20:00Z', + }, + ], + }, + ], + }, + ], +} diff --git a/demo/src/examples/filter-toolkit/recipes.ts b/demo/src/examples/filter-toolkit/recipes.ts new file mode 100644 index 00000000..b60124d4 --- /dev/null +++ b/demo/src/examples/filter-toolkit/recipes.ts @@ -0,0 +1,234 @@ +// The catalogue of filter-builder recipes shown in the control panel. Each +// `predicate` is a real `FilterPredicate` from `@json-edit-react/utils`; the +// `code` string is what's displayed (and is exactly what produced the +// predicate). The builders intern their results, so referencing them at module +// scope here gives one stable instance per recipe. + +import { + and, + byKey, + byLevel, + byPath, + bySize, + byType, + byValue, + collections, + inArray, + inObject, + matchesSearch, + matchRecord, + not, + or, + primitives, + root, + type FilterPredicate, +} from '@json-edit-react/utils' + +export interface Recipe { + id: string + group: string + label: string + code: string + description: string + predicate: FilterPredicate +} + +// Display order of the chip groups. +export const RECIPE_GROUPS = ['Keys & paths', 'Position', 'Type & value', 'Combinators'] as const + +export const RECIPES: Recipe[] = [ + // --- Keys & paths --- + { + id: 'key-id-name', + group: 'Keys & paths', + label: 'id / name keys', + code: "byKey('id', 'name')", + description: 'Nodes whose key is `id` or `name` — an exact key match, anywhere in the tree.', + predicate: byKey('id', 'name'), + }, + { + id: 'key-at', + group: 'Keys & paths', + label: 'timestamp keys', + code: 'byKey(/_at$/)', + description: 'Keys ending in `_at` (created_at, updated_at) — a RegExp tested against the key.', + predicate: byKey(/_at$/), + }, + { + id: 'path-tags', + group: 'Keys & paths', + label: 'tag entries', + code: "byPath('**.tags.*')", + description: 'Every element inside any `tags` array, at any depth — `**` spans whole segments.', + predicate: byPath('**.tags.*'), + }, + { + id: 'path-lead-email', + group: 'Keys & paths', + label: 'dept lead emails', + code: "byPath('departments.*.lead.email')", + description: 'The `email` of each department lead — `*` matches one segment (the array index).', + predicate: byPath('departments.*.lead.email'), + }, + + // --- Position --- + { + id: 'root', + group: 'Position', + label: 'root', + code: 'root', + description: 'The root node (level 0).', + predicate: root, + }, + { + id: 'deep', + group: 'Position', + label: 'deep (level ≥ 4)', + code: 'byLevel({ min: 4 })', + description: 'Anything at depth 4 or deeper — lead fields, teams, members and below.', + predicate: byLevel({ min: 4 }), + }, + { + id: 'big', + group: 'Position', + label: 'large collections (≥ 5)', + code: 'bySize({ min: 5 })', + description: 'Objects/arrays with five or more children. Leaves have no size and never match.', + predicate: bySize({ min: 5 }), + }, + { + id: 'in-array', + group: 'Position', + label: 'array items', + code: 'inArray', + description: 'Nodes whose parent is an array — department, team and member records, plus tags/skills.', + predicate: inArray, + }, + { + id: 'in-object', + group: 'Position', + label: 'object fields', + code: 'inObject', + description: 'Nodes whose parent is an object (the root, having no parent, is excluded).', + predicate: inObject, + }, + { + id: 'collections', + group: 'Position', + label: 'collections', + code: 'collections', + description: 'Objects and arrays.', + predicate: collections, + }, + { + id: 'primitives', + group: 'Position', + label: 'leaves', + code: 'primitives', + description: 'Leaf values — everything that isn’t a collection.', + predicate: primitives, + }, + + // --- Type & value --- + { + id: 'numbers', + group: 'Type & value', + label: 'numbers', + code: "byType('number')", + description: 'Every number-valued node — headcounts, budgets, coordinates, years.', + predicate: byType('number'), + }, + { + id: 'bool-null', + group: 'Type & value', + label: 'booleans & nulls', + code: "byType('boolean', 'null')", + description: 'Boolean and null values — flags like `active` / `remote`, and empty `manager` / `parentCompany`.', + predicate: byType('boolean', 'null'), + }, + { + id: 'true', + group: 'Type & value', + label: 'true values', + code: 'byValue(true)', + description: 'Leaves whose value is exactly `true`.', + predicate: byValue(true), + }, + { + id: 'urls', + group: 'Type & value', + label: 'URLs', + code: 'byValue(/^https?:/)', + description: 'String values that look like a URL — websites, repositories, avatars.', + predicate: byValue(/^https?:/), + }, + + // --- Combinators --- + { + id: 'str-not-url', + group: 'Combinators', + label: 'strings, not URLs', + code: "and(byType('string'), not(byValue(/^https?:/)))", + description: 'String values, excluding URLs — names, roles, cities, dates.', + predicate: and(byType('string'), not(byValue(/^https?:/))), + }, + { + id: 'email-or-web', + group: 'Combinators', + label: 'email or website', + code: "or(byKey('email'), byKey('website'))", + description: 'Any `email` or `website` field — `or` matches when either predicate does.', + predicate: or(byKey('email'), byKey('website')), + }, + { + id: 'not-collections', + group: 'Combinators', + label: 'not collections', + code: 'not(collections)', + description: 'The negation of `collections` — the same set as `primitives`, the long way round.', + predicate: not(collections), + }, +] + +export interface SearchStrategy { + id: string + label: string + code: string + description: string + filter: FilterPredicate +} + +export const SEARCH_STRATEGIES: SearchStrategy[] = [ + { + id: 'value', + label: "matchesSearch('value')", + code: "matchesSearch('value')", + description: 'Core’s default — matches a node’s value. Only the matching nodes (and their ancestors) stay visible.', + filter: matchesSearch('value'), + }, + { + id: 'key', + label: "matchesSearch('key')", + code: "matchesSearch('key')", + description: 'Matches keys and path segments rather than values — try a field name like “email” or “skills”.', + filter: matchesSearch('key'), + }, + { + id: 'all', + label: "matchesSearch('all')", + code: "matchesSearch('all')", + description: 'Matches keys OR values — the union of the two above.', + filter: matchesSearch('all'), + }, + { + id: 'record', + label: 'matchRecord (people)', + code: "matchRecord({\n fields: ['name', 'email', 'role'],\n path: 'departments.*.teams.*.members.*',\n})", + description: + 'Reveals a person’s WHOLE record when their name, email or role matches — not just the field that hit. Search “Bao” and compare against matchesSearch.', + filter: matchRecord({ + fields: ['name', 'email', 'role'], + path: 'departments.*.teams.*.members.*', + }), + }, +] diff --git a/demo/src/examples/registry.ts b/demo/src/examples/registry.ts index 86d00ecd..2cb9b904 100644 --- a/demo/src/examples/registry.ts +++ b/demo/src/examples/registry.ts @@ -42,6 +42,13 @@ const allExamples: Record = { 'Exercise the optimistic commit lifecycle, the hold() gate and the full onEditEvent stream. Pick an onUpdate behaviour, then edit / rename / add / delete and watch the event viewer.', load: () => import('./editing-model/Example'), }, + 'filter-toolkit': { + kind: 'custom', + title: 'Filter toolkit', + blurb: + "Compose the `allow*` props and `searchFilter` from `@json-edit-react/utils`' predicate builders. Pick a builder to highlight every node it matches (a predicate is `(node) => boolean`, a theme style function is `(node) => CSSProperties` — the same function drives both), optionally bind it to `allowEdit`, and try the `matchesSearch` / `matchRecord` search bridges against a deep org-chart document.", + load: () => import('./filter-toolkit/Example'), + }, 'event-signals': { kind: 'static', title: 'Event signals', diff --git a/packages/utils/IDEAS.md b/packages/utils/IDEAS.md index 32f50579..5e9c0a4f 100644 --- a/packages/utils/IDEAS.md +++ b/packages/utils/IDEAS.md @@ -21,7 +21,7 @@ it's a place to capture and rank ideas before they earn an issue. so the UI can't produce invalid data (preventive). ([#285](https://github.com/CarlosNZ/json-edit-react/issues/285)) - **Search helpers** — ready-made `searchFilter` functions for common patterns. ([#319](https://github.com/CarlosNZ/json-edit-react/issues/319)) - Should also include **`expandToMatches(data, searchText, searchFilter?)`** — reveal search matches hidden inside collapsed subtrees. Has to be a *data-level* traversal: lazy mounting means core never evaluates `filterNode` below the collapse frontier, so it can't know about deep matches — walk `data` with the search predicate, collect matching paths, emit a `CollapseState[]` expanding every ancestor, feed it to `editorRef.collapse`. Composes with the `searchFilter` helpers (same predicate finds the paths). Drift hazard: with no custom `searchFilter` it must mirror core's default match semantics (`searchProperty`, collection-matches-via-descendants) — build it on core's exported `matchNode` / `matchNodeKey`, don't re-implement. + Should also include **`expandToMatches(data, searchText, searchFilter?)`** — auto-*expand* the collapsed ancestors of search matches. Core already *finds* deep matches regardless of collapse: `computeFilterState` walks the whole `data` tree (children are always mounted — collapse is a CSS max-height clip, not lazy mounting), so a match's collapsed ancestors stay visible in the filtered tree with their `n of m` counts. It just leaves them collapsed, so the user has to click down to the match. This helper closes that gap: walk `data` with the search predicate, collect matching paths, emit a `CollapseState[]` expanding every ancestor, feed it to `editorRef.collapse`. (The old "core can't see below the collapse frontier" worry was the v1 per-node `filterNode` model — the v2 `computeFilterState` rewrite removed it.) Composes with the `searchFilter` helpers (same predicate finds the paths). Drift hazard: with no custom `searchFilter` it must mirror core's default match semantics (collection-matches-via-descendants) — build it on core's exported `matchNode` / `matchNodeKey`, don't re-implement. - **Filter-function toolkit** — composable predicate builders (`byKey`, `byPath`, `byLevel`, `bySize`, `byType`, `byValue`), `root`/`collections`/`primitives`/`inArray`/`inObject` constants, `and`/`or`/`not` combinators, and the `matchesSearch`/`matchRecord` search bridges, for the `allow*` props and `searchFilter`. _Shipped_ (`src/filters/`). Design with examples in [#343](https://github.com/CarlosNZ/json-edit-react/issues/343). Possible companion — **a predicate-driven dispatcher** (working names `pick` / `cond` / `choose`): `pick(predicate, value, predicate, value, …, default?)` → a function returning the value for the first matching predicate. One tier above the filter kit — `node → anything`, not `node → boolean` — so it's a *dispatcher* built on the predicates, **not** a filter builder (don't make it assignable to filter props, and don't name it `match*`, which collides with the boolean `matchNode`/`matchRecord`/`matchesSearch`). First-match-wins; needs a trailing default so it can return `false` (e.g. `allowTypeSelection`'s no-match value); thread `searchText` to the conditions. Typing fork: flat alternating args read best but type awkwardly (recursive variadic tuple / overload stack) vs. pairs `pick([p, v], …)` which type cleanly. Payloads aren't internable, so document "define it in your static config" rather than promising inline memo-safety. Earns its keep only if it has more than one customer (`allowTypeSelection` is the obvious one; a node→value `defaultValue` form, future node-keyed value props are the maybes) — for a single small table the hand-written if-chain is barely longer. Parked 2026-06-16. From aeb9a54ccb10b680f2299ab15a3a5e5a61d94b56 Mon Sep 17 00:00:00 2001 From: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Tue, 16 Jun 2026 18:54:27 +1200 Subject: [PATCH 07/11] Example tweaks --- demo/src/examples/filter-toolkit/Example.tsx | 205 ++++++++++++++----- demo/src/examples/filter-toolkit/recipes.ts | 51 ++++- 2 files changed, 206 insertions(+), 50 deletions(-) diff --git a/demo/src/examples/filter-toolkit/Example.tsx b/demo/src/examples/filter-toolkit/Example.tsx index 4c88ed70..15c4b823 100644 --- a/demo/src/examples/filter-toolkit/Example.tsx +++ b/demo/src/examples/filter-toolkit/Example.tsx @@ -6,13 +6,24 @@ import { Flex, FormLabel, Heading, + IconButton, Input, + InputGroup, + InputRightElement, + Popover, + PopoverArrow, + PopoverBody, + PopoverCloseButton, + PopoverContent, + PopoverHeader, + PopoverTrigger, Select, Switch, Text, Wrap, WrapItem, } from '@chakra-ui/react' +import { SmallCloseIcon } from '@chakra-ui/icons' import { JsonEditor, type JsonData, type NodeData, type ThemeStyles } from '@json-edit-react' import { type FilterPredicate } from '@json-edit-react/utils' import { useExampleProps, useExampleTheme } from '../kit/exampleProps' @@ -21,8 +32,10 @@ import { SplitPane } from '../kit/SplitPane' import { orgData } from './data' import { RECIPES, RECIPE_GROUPS, SEARCH_STRATEGIES } from './recipes' -// The match highlight. A translucent amber reads on both light and dark themes, -// and the rounded corners make a tinted key/value look like a pill. +// The match highlight and the active-chip accent share one amber, so the +// selected builder visually ties to what's lit up in the tree. It's bright +// enough to read on both light and dark themes. +const ACCENT = '#FFB300' const HIGHLIGHT: CSSProperties = { backgroundColor: 'rgba(255, 196, 0, 0.5)', borderRadius: '0.2em', @@ -52,22 +65,37 @@ export default function FilterToolkit() { const palette = useExamplePalette() const [data, setData] = useState(orgData) - const [recipeId, setRecipeId] = useState('numbers') + // `null` = nothing selected (no highlight). Clicking the active chip toggles + // back to this. + const [recipeId, setRecipeId] = useState('numbers') const [lockNonMatching, setLockNonMatching] = useState(false) const [searchText, setSearchText] = useState('') const [searchId, setSearchId] = useState('value') - const activeRecipe = RECIPES.find((r) => r.id === recipeId) ?? RECIPES[0] + const activeRecipe = RECIPES.find((r) => r.id === recipeId) ?? null const activeSearch = SEARCH_STRATEGIES.find((s) => s.id === searchId) ?? SEARCH_STRATEGIES[0] - // Recompose only when the base theme or the active recipe changes. The - // recipe's predicate is interned (a stable reference per recipe), so this - // doesn't churn between unrelated renders. + // Compose the highlight layer over the picked theme — or just the theme when + // nothing's selected. The predicate is interned (stable per recipe), so this + // only recomputes when the base theme or active recipe actually changes. const theme = useMemo( - () => [baseTheme, buildHighlightTheme(activeRecipe.predicate)], + () => (activeRecipe ? [baseTheme, buildHighlightTheme(activeRecipe.predicate)] : baseTheme), [baseTheme, activeRecipe] ) + // Chips borrow the editor theme's own colours (via the palette) so they stay + // legible on any theme; the active chip is the amber accent. + const chipProps = (isActive: boolean) => + ({ + size: 'xs', + variant: 'outline', + fontWeight: isActive ? 'bold' : 'normal', + bg: isActive ? ACCENT : 'transparent', + color: isActive ? 'gray.900' : palette.property ?? 'inherit', + borderColor: isActive ? ACCENT : palette.itemCount ?? palette.property ?? 'currentColor', + _hover: { borderColor: ACCENT, color: isActive ? 'gray.900' : ACCENT }, + }) as const + return ( } @@ -109,7 +137,7 @@ export default function FilterToolkit() { Pick a builder — every node it matches lights up in the tree. A predicate is just{' '} {'(node) => boolean'}, and a theme style function is{' '} {'(node) => CSSProperties'}, so the same function drives - the highlight. + the highlight. Click the active chip again to clear it. {RECIPE_GROUPS.map((group) => ( @@ -125,41 +153,113 @@ export default function FilterToolkit() { {group} - {RECIPES.filter((r) => r.group === group).map((r) => ( - + {RECIPES.filter((r) => r.group === group).map((r) => { + const isActive = r.id === recipeId + return ( + + + + ) + })} + + + ))} + + {activeRecipe ? ( + + + Filter function + + + The predicate you'd hand to a filter prop like allowEdit{' '} + or searchFilter — the same{' '} + {'(node) => boolean'}. Here it drives the highlight (and{' '} + allowEdit, with the toggle below). + + + {/* Floats over the top-right of the code display so the + shorthand-vs-long-hand comparison is right where the eye + already is. */} + + - - ))} - + + + + + + The same filter, written by hand + + + + What you'd pass to the prop without the toolkit: + + + {activeRecipe.longhand} + + + + + + {activeRecipe.code} + + + + {activeRecipe.description} + - ))} - - - {activeRecipe.code} - - - {activeRecipe.description} - + ) : ( + + Nothing highlighted — pick a builder above to see its filter function. + + )} setLockNonMatching(e.target.checked)} /> @@ -167,7 +267,7 @@ export default function FilterToolkit() { - {lockNonMatching + {lockNonMatching && activeRecipe ? 'Double-click a value to try — edits are blocked on non-highlighted nodes. (allowEdit governs value editing, so leaf-targeting recipes show it best.)' : 'Off — every node is editable, as usual.'} @@ -188,18 +288,31 @@ export default function FilterToolkit() { These turn the live search into a searchFilter. Type a query, then switch strategy to compare how each one reveals matches. - setSearchText(e.target.value)} - /> + + setSearchText(e.target.value)} + /> + {searchText && ( + + } + size="sm" + variant="ghost" + color="gray.600" + _hover={{ color: 'gray.900', bg: 'blackAlpha.100' }} + onClick={() => setSearchText('')} + /> + + )} + - - {activeSearch.code} - - - {activeSearch.description} - + + {activeSearch.description} } diff --git a/demo/src/examples/filter-toolkit/recipes.ts b/demo/src/examples/filter-toolkit/recipes.ts index ced402bf..f94d3919 100644 --- a/demo/src/examples/filter-toolkit/recipes.ts +++ b/demo/src/examples/filter-toolkit/recipes.ts @@ -78,16 +78,6 @@ export const RECIPES: Recipe[] = [ description: 'The `email` of each department lead — `*` matches one segment (the array index).', predicate: byPath('departments.*.lead.email'), }, - { - id: 'path-skills', - group: 'Keys & paths', - label: 'every skill', - code: "byPath('**.members.*.skills.*')", - longhand: - "({ path }) => {\n const i = path.lastIndexOf('members')\n return (\n i !== -1 &&\n path[i + 2] === 'skills' &&\n path.length === i + 4\n )\n}", - description: 'Every skill entry, however deep — `**` skips any leading segments, then the two array indices.', - predicate: byPath('**.members.*.skills.*'), - }, // --- Position --- { @@ -222,14 +212,26 @@ export const RECIPES: Recipe[] = [ predicate: not(collections), }, { - id: 'editable-content', + id: 'editable-fields', + group: 'Combinators', + label: 'editable fields', + code: "and(byLevel({ min: 2 }), not(byKey('id', 'created_at', 'updated_at')))", + longhand: + "({ level, key }) =>\n level >= 2 &&\n !['id', 'created_at', 'updated_at'].includes(String(key))", + description: + 'A realistic `allowEdit` — everything below the top level is editable, except the read-only `id` and timestamp fields.', + predicate: and(byLevel({ min: 2 }), not(byKey('id', 'created_at', 'updated_at'))), + }, + { + id: 'deletable-records', group: 'Combinators', - label: 'editable content', - code: 'and(primitives, byLevel({ min: 2 }), not(byKey(/^id$|_at$/)))', + label: 'deletable records', + code: 'and(inArray, collections)', longhand: - "({ value, level, key }) =>\n (value === null || typeof value !== 'object') &&\n level >= 2 &&\n !/^id$|_at$/.test(String(key))", - description: 'Leaf values below the top level, except ids and timestamps — a realistic `allowEdit`.', - predicate: and(primitives, byLevel({ min: 2 }), not(byKey(/^id$|_at$/))), + "({ parentData, value }) =>\n Array.isArray(parentData) &&\n value !== null &&\n typeof value === 'object'", + description: + 'A realistic `allowDelete` — only whole records (objects sitting directly in an array): a department, team or member, never an individual field.', + predicate: and(inArray, collections), }, ] @@ -237,6 +239,7 @@ export interface SearchStrategy { id: string label: string code: string + longhand: string description: string filter: FilterPredicate } @@ -244,31 +247,82 @@ export interface SearchStrategy { export const SEARCH_STRATEGIES: SearchStrategy[] = [ { id: 'value', - label: "matchesSearch('value')", + label: 'Search by value', code: "matchesSearch('value')", - description: 'Core’s default — matches a node’s value. Only the matching nodes (and their ancestors) stay visible.', + longhand: 'searchFilter="value"', + description: + 'Core’s default — matches a node’s value. On its own it’s identical to the built-in `searchFilter="value"`; the builder earns its keep when you **compose** search with structure, e.g. `or(matchesSearch(), byKey("id"))` to keep id fields visible while searching.', filter: matchesSearch('value'), }, { id: 'key', - label: "matchesSearch('key')", + label: 'Search by key', code: "matchesSearch('key')", - description: 'Matches keys and path segments rather than values — try a field name like “email” or “skills”.', + longhand: 'searchFilter="key"', + description: + 'Matches keys and path segments rather than values — try a field name like “email” or “skills”. On its own, the same as the built-in `searchFilter="key"`; being a predicate, it **composes** with `and`/`or`/`not`.', filter: matchesSearch('key'), }, { id: 'all', - label: "matchesSearch('all')", + label: 'Search all', code: "matchesSearch('all')", - description: 'Matches keys OR values — the union of the two above.', + longhand: 'searchFilter="all"', + description: + 'Matches keys OR values — the union of the two above. On its own, the same as the built-in `searchFilter="all"`; being a predicate, it **composes** with `and`/`or`/`not`.', filter: matchesSearch('all'), }, { - id: 'record', - label: 'matchRecord (people)', + id: 'compose-names', + label: 'Compose — names only', + code: "and(matchesSearch('value'), byKey('name'))", + longhand: `(node, searchText) => + node.key === 'name' && + typeof node.value === 'string' && + node.value.toLowerCase().includes(searchText.toLowerCase())`, + description: + '**Composition in action** — `matchesSearch("value")` AND `byKey("name")`, so only `name` fields whose value matches stay visible. Search `bao`: a bare value search would also surface his email (it contains “bao”), but this keeps just the `name`. That’s the point of the search bridges being predicates — they slot into `and`/`or`/`not` like any other builder.', + filter: and(matchesSearch('value'), byKey('name')), + }, + { + id: 'record-dept', + label: 'matchRecord — department', + code: "matchRecord({\n fields: ['name'],\n path: 'departments.*',\n})", + longhand: `(node, searchText) => { + // climb to the record — a top-level department + const p = node.path + if (p[0] !== 'departments' || p[1] === undefined) return false + const record = node.fullData.departments?.[p[1]] + if (!record || typeof record !== 'object') return false + // …then test its name + return String(record.name ?? '') + .toLowerCase() + .includes(searchText.toLowerCase()) +}`, + description: + 'Reveals a whole **department** when its `name` matches. Search `Engineering`, `Design` or `Field` — the entire department (its teams, members, every field) stays while the others drop away. `matchesSearch` would surface only the matching `name` node.', + filter: matchRecord({ fields: ['name'], path: 'departments.*' }), + }, + { + id: 'record-person', + label: 'matchRecord — team member', code: "matchRecord({\n fields: ['name', 'email', 'role'],\n path: 'departments.*.teams.*.members.*',\n})", + longhand: `(node, searchText) => { + // climb to the record — departments[d].teams[t].members[m] + const p = node.path + if (p[0] !== 'departments' || p[2] !== 'teams' || p[4] !== 'members') + return false + const record = + node.fullData.departments?.[p[1]]?.teams?.[p[3]]?.members?.[p[5]] + if (!record || typeof record !== 'object') return false + // …then test each field + const q = searchText.toLowerCase() + return ['name', 'email', 'role'].some((f) => + String(record[f] ?? '').toLowerCase().includes(q) + ) +}`, description: - 'Reveals a person’s WHOLE record when their name, email or role matches — not just the field that hit. Search “Bao” and compare against matchesSearch.', + 'Pins the record layer deeper — a single **team member**. Search a member like `Bao`, `Lena` or `Hana` and their whole record stays (id, email, skills…), not just the matching field. Department *leads* (`Ada`, `Sofia`, `Diego`) live on `departments.*.lead` — a different path — so they fall outside this record layer. That layer boundary is exactly what `path` controls.', filter: matchRecord({ fields: ['name', 'email', 'role'], path: 'departments.*.teams.*.members.*', From 25a98a660aa7af31fe94b4cbc0ebd9944050e3c1 Mon Sep 17 00:00:00 2001 From: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:54:26 +1200 Subject: [PATCH 09/11] Update pr-bundle-size.yml --- .github/workflows/pr-bundle-size.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/pr-bundle-size.yml b/.github/workflows/pr-bundle-size.yml index cce1ce94..de0fc76f 100644 --- a/.github/workflows/pr-bundle-size.yml +++ b/.github/workflows/pr-bundle-size.yml @@ -20,6 +20,7 @@ jobs: core: ${{ steps.filter.outputs.core }} themes: ${{ steps.filter.outputs.themes }} components: ${{ steps.filter.outputs.components }} + utils: ${{ steps.filter.outputs.utils }} any: ${{ steps.filter.outputs.changes != '[]' }} steps: - uses: actions/checkout@v4 @@ -35,6 +36,8 @@ jobs: - 'packages/themes/**' components: - 'packages/components/**' + utils: + - 'packages/utils/**' size: needs: detect @@ -81,6 +84,9 @@ jobs: if [[ "${{ needs.detect.outputs.components }}" == "true" ]]; then pnpm --filter @json-edit-react/components build fi + if [[ "${{ needs.detect.outputs.utils }}" == "true" ]]; then + pnpm --filter @json-edit-react/utils build + fi - name: Measure PR sizes working-directory: pr @@ -89,6 +95,7 @@ jobs: MEASURE_CORE: ${{ needs.detect.outputs.core }} MEASURE_THEMES: ${{ needs.detect.outputs.themes }} MEASURE_COMPONENTS: ${{ needs.detect.outputs.components }} + MEASURE_UTILS: ${{ needs.detect.outputs.utils }} - name: Install (base) working-directory: base @@ -106,6 +113,9 @@ jobs: if [[ "${{ needs.detect.outputs.components }}" == "true" ]]; then pnpm --filter @json-edit-react/components build fi + if [[ "${{ needs.detect.outputs.utils }}" == "true" ]]; then + pnpm --filter @json-edit-react/utils build + fi - name: Measure base sizes working-directory: base @@ -114,6 +124,7 @@ jobs: MEASURE_CORE: ${{ needs.detect.outputs.core }} MEASURE_THEMES: ${{ needs.detect.outputs.themes }} MEASURE_COMPONENTS: ${{ needs.detect.outputs.components }} + MEASURE_UTILS: ${{ needs.detect.outputs.utils }} - name: Format comment run: node pr/scripts/format-size-diff.mjs base-sizes.json pr-sizes.json > comment.md From 3e990c5f911d07ef9713d78392b57ad7d7ad9c3f Mon Sep 17 00:00:00 2001 From: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:55:50 +1200 Subject: [PATCH 10/11] Update measure-build.mjs --- scripts/measure-build.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/measure-build.mjs b/scripts/measure-build.mjs index d51e41ec..9beb0430 100644 --- a/scripts/measure-build.mjs +++ b/scripts/measure-build.mjs @@ -7,12 +7,14 @@ const PACKAGES = { core: { name: 'json-edit-react', dir: 'build' }, themes: { name: '@json-edit-react/themes', dir: 'packages/themes/build' }, components: { name: '@json-edit-react/components', dir: 'packages/components/build' }, + utils: { name: '@json-edit-react/utils', dir: 'packages/utils/build' }, } const ENV_FLAGS = { core: 'MEASURE_CORE', themes: 'MEASURE_THEMES', components: 'MEASURE_COMPONENTS', + utils: 'MEASURE_UTILS', } const FORMATS = [ From 380e3e7dc78741bb41bd886b5b9f89ffef9fbc2e Mon Sep 17 00:00:00 2001 From: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:37:42 +1200 Subject: [PATCH 11/11] Address review: glob rules docstring + demo link Add a brief glob-matching-rules overview to _glob.ts's docstring and a link to the live demo in the filters README quick-start. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/utils/src/filters/README.md | 2 ++ packages/utils/src/filters/_glob.ts | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/packages/utils/src/filters/README.md b/packages/utils/src/filters/README.md index c5665d79..980bc246 100644 --- a/packages/utils/src/filters/README.md +++ b/packages/utils/src/filters/README.md @@ -26,6 +26,8 @@ import { and, byKey, byLevel, byType, matchRecord, not, primitives, root } from You can write these **inline**, as above, without a `useMemo` or hoisting them out of render — see [Referential stability](#referential-stability) for why that's safe. +See these builders in action in the [live demo](https://carlosnz.github.io/json-edit-react/). + ## The predicate type Everything here is a `FilterPredicate`: diff --git a/packages/utils/src/filters/_glob.ts b/packages/utils/src/filters/_glob.ts index 124b3d04..dd8b73bf 100644 --- a/packages/utils/src/filters/_glob.ts +++ b/packages/utils/src/filters/_glob.ts @@ -5,6 +5,18 @@ import type { PathPattern } from './types' // path; a glob string or a segment array compiles to a sequence of per-segment // matchers plus the `**` globstar. Compilation happens once (at builder-call // time, behind `intern`); the returned matcher runs per node. +// +// Glob rules (anchored at both ends; segments split on `.`, with `[n]` +// normalised to `.n`): +// foo a literal segment — matches the key `foo` exactly +// * any one whole segment (does not cross a `.`) +// *Id within a segment, `*` is zero-or-more chars — matches `userId` +// ? within a segment, exactly one char +// {a,b} alternation within a segment (nestable) +// ** a globstar: zero or more whole segments (a whole subtree) +// So `users.*` matches `users.0` but not `users.0.name`, while `users.**` +// matches `users`, `users.0`, and `users.0.name`. See the package README's +// "Path patterns" section for the full reference. // Sentinel for the `**` token (zero-or-more whole segments). A symbol so it // can never be confused with a compiled-segment RegExp.