diff --git a/.changeset/components-error-indicator.md b/.changeset/components-error-indicator.md new file mode 100644 index 00000000..a237f2ba --- /dev/null +++ b/.changeset/components-error-indicator.md @@ -0,0 +1,7 @@ +--- +'@json-edit-react/components': minor +--- + +Add `ErrorIndicator` — a custom-node component that wraps the built-in node (`originalNode`) and adds a glyph (default ⚠️) beside it, to flag nodes you target. + +Unlike the other pre-built components it has no intrinsic value type: `errorIndicatorDefinition({ condition })` decorates exactly the value nodes the consumer's `condition` selects, so it pairs directly with `useValidationState` from `@json-edit-react/utils` — `errorIndicatorDefinition({ condition: (nd) => validation.hasErrorAt(nd.path) })`, memoized on the validation state, marks invalid nodes (correctly across branches). It guards to value (leaf) nodes, so a condition that also matches a collection (e.g. an AJV `if`/`then` error reported at a parent object's path) never wraps that collection. Options via `componentProps`: `errorGlyph` (any `ReactNode`, default `⚠️`) and `position` (`'before' | 'after'`, default `'after'`). With no `condition` it flags nothing. Dependency-free — it imports only React and core types. diff --git a/demo/src/examples/registry.ts b/demo/src/examples/registry.ts index 0b70c9e0..c1a8bdd5 100644 --- a/demo/src/examples/registry.ts +++ b/demo/src/examples/registry.ts @@ -85,7 +85,7 @@ const allExamples: Record = { kind: 'static', title: 'Validation flagging (dev)', blurb: - "Reactive validation with `useValidationState` (from `@json-edit-react/utils`). An AJV schema links `payment.method` to `card.number` (`minLength: 16` only while method is `card`), so editing `method` flips the validity of a node on another branch. The hook runs the validator once per change, and `validationStyles` flags invalid nodes — restyling cross-branch correctly because the hook's identity changes exactly when validity does. This is the fix for the sibling “Validation staleness” gotcha.", + "Reactive validation with `useValidationState` (`@json-edit-react/utils`) plus the `ErrorIndicator` glyph component (`@json-edit-react/components`). An AJV schema links `payment.method` to `card.number` (`minLength: 16` only while method is `card`), so editing `method` flips the validity of a node on another branch. `errorIndicatorDefinition` adds a ⚠️ to invalid nodes via its `condition`, memoized on the validation state — so the marker appears/clears cross-branch the instant validity changes. The fix for the sibling “Validation staleness” gotcha.", load: () => import('./static/validation-flagging/Example'), code: () => import('./static/validation-flagging/Example.tsx?raw'), devOnly: true, diff --git a/demo/src/examples/static/validation-flagging/Example.tsx b/demo/src/examples/static/validation-flagging/Example.tsx index 994700b0..6bfa264f 100644 --- a/demo/src/examples/static/validation-flagging/Example.tsx +++ b/demo/src/examples/static/validation-flagging/Example.tsx @@ -1,14 +1,14 @@ import { useMemo, useState } from 'react' import { JsonEditor } from '@json-edit-react' -import { useValidationState, validationStyles, ajvAdapter } from '@json-edit-react/utils' +import { useValidationState, ajvAdapter } from '@json-edit-react/utils' +import { errorIndicatorDefinition } from '@json-edit-react/components' import Ajv from 'ajv' -import { useExampleTheme, useExampleProps } from '../../kit/exampleProps' // ---cut--- +import { useExampleProps } from '../../kit/exampleProps' // ---cut--- -// The fix for the cross-branch staleness shown in the sibling "Validation -// staleness" example. Same constraint: `card.number` must be ≥16 chars, but -// only while `payment.method` is 'card'. Editing `method` changes the validity -// of a node on a *different* branch — `useValidationState` restyles it -// correctly, because its result identity changes exactly when validity does. +// A custom-node glyph marker for invalid nodes, driven by `useValidationState`. +// Same cross-branch constraint as the sibling "Validation staleness" example: +// `card.number` must be ≥16 chars, but only while `payment.method` is 'card'. +// Editing `method` changes the validity of a node on a *different* branch. const schema = { type: 'object', properties: { @@ -19,8 +19,7 @@ const schema = { then: { properties: { card: { properties: { number: { minLength: 16 } } } } }, } -// Compile the validator once and wrap it for the hook — `ajvAdapter` normalises -// AJV's errors into the `{ path, message }[]` the hook consumes. +// Compile the validator once and wrap it for the hook. const ajv = new Ajv({ allErrors: true }) const validate = ajvAdapter(ajv.compile(schema)) @@ -30,33 +29,30 @@ const initialData = { } // Try it: -// 1. On load, `card.number` is flagged (method is 'card', number too short). -// 2. Edit `payment.method` → 'cash'. The flag clears *immediately* — even -// though `card.number` is on another branch and its own value didn't -// change. (Collapse `card` first and you'll still see the parent marked.) -// 3. Edit it back to 'card': the flag returns at once. No collapse/re-expand -// needed — the hook re-renders the tree exactly when validity changes. +// 1. On load, `card.number` shows a ⚠️ (method is 'card', number too short). +// 2. Edit `payment.method` → 'cash'. The ⚠️ clears *immediately* — even though +// `card.number` is on another branch and its own value didn't change. +// 3. Edit it back to 'card': the ⚠️ returns at once. No collapse/re-expand +// needed — the definitions, memoized on `validation`, re-render the tree +// exactly when validity changes. export default function ValidationFlagging() { const [data, setData] = useState(initialData) - const baseTheme = useExampleTheme() // One hook: re-validates per data change, queryable in O(1), and // referentially stable until the error set actually changes. const validation = useValidationState(data, validate) - // Compose the error styling over the host theme. Memoising on `validation` - // (not an inline array) is what keeps the tree from re-rendering every commit - // — and what lets it re-render, and restyle cross-branch nodes, when validity - // flips. - const theme = useMemo( + // `errorIndicatorDefinition` wraps the built-in node and adds a ⚠️. The + // validity check rides the definition's `condition`; memoising on `validation` + // is what lets the tree re-render — and flag cross-branch nodes — when + // validity flips. + const customNodeDefinitions = useMemo( () => [ - baseTheme, - validationStyles(validation, { - error: { backgroundColor: 'firebrick', color: 'white' }, - within: { backgroundColor: 'rgba(178, 34, 34, 0.08)' }, + errorIndicatorDefinition({ + condition: (nodeData) => validation.hasErrorAt(nodeData.path), }), ], - [baseTheme, validation] + [validation] ) return ( @@ -72,7 +68,7 @@ export default function ValidationFlagging() { setData={setData} {...useExampleProps()} // ---cut--- rootName="order" - theme={theme} + customNodeDefinitions={customNodeDefinitions} /> ) diff --git a/packages/components/README.md b/packages/components/README.md index 7ba3d696..1ed1a051 100644 --- a/packages/components/README.md +++ b/packages/components/README.md @@ -32,6 +32,7 @@ Each component ships a React component plus a definition factory that produces a | `NaN` | `NaN` value display | | `Symbol` | `Symbol` value display | | `Undefined` | `undefined` value display | +| `ErrorIndicator` | Wraps a node with a glyph (default ⚠️) to flag the nodes you target via `condition` — e.g. validation errors | ### Editor slot components @@ -57,6 +58,28 @@ import { hyperlinkDefinition, datePickerDefinition } from '@json-edit-react/comp See [json-edit-react's custom nodes documentation](https://github.com/CarlosNZ/json-edit-react#custom-nodes) for the definition shape and configuration options. +### `ErrorIndicator` — flag nodes with a glyph + +Unlike the other components, `ErrorIndicator` has no intrinsic value type: it wraps a value (leaf) node and adds a glyph beside whichever nodes you point it at via `condition`. It pairs naturally with `useValidationState` from `@json-edit-react/utils` to mark invalid nodes. + +```tsx +import { useMemo } from 'react' +import { JsonEditor } from 'json-edit-react' +import { useValidationState, ajvAdapter } from '@json-edit-react/utils' +import { errorIndicatorDefinition } from '@json-edit-react/components' + +const validation = useValidationState(data, ajvAdapter(compiledValidate)) +const customNodeDefinitions = useMemo( + () => [errorIndicatorDefinition({ condition: (nd) => validation.hasErrorAt(nd.path) })], + [validation] +) +// +``` + +Memoizing on `validation` re-renders the tree exactly when validity changes, so the marker appears/clears correctly even when an edit on one node flips the validity of a node on another branch. Options, via `componentProps`: `errorGlyph` (any `ReactNode`, default `⚠️`) and `position` (`'before' | 'after'`, default `'after'`). With no `condition` it flags nothing (its default targeting is a deliberate no-op). + +It guards to value (leaf) nodes, so a `condition` that also matches a collection (e.g. an AJV `if`/`then` error reported at a parent object's path) never decorates that collection — only the leaf where the value is wrong. For collection-level marking, tint the subtree with `validationStyles({ within })` from `@json-edit-react/utils` instead. + ## Tree-shaking This package is ESM with `"sideEffects": false`. Modern bundlers (Webpack 4+, Vite, Rollup, esbuild, Parcel 2+) drop unused components and their imports from the final bundle. Heavy components (`DatePicker`, `Markdown`, `ColorPicker`, `ReactSelect`, `CodeEditor`) additionally use `React.lazy` for their third-party libraries, so even when imported they don't load those libraries until first render. diff --git a/packages/components/src/ErrorIndicator/component.tsx b/packages/components/src/ErrorIndicator/component.tsx new file mode 100644 index 00000000..df76f792 --- /dev/null +++ b/packages/components/src/ErrorIndicator/component.tsx @@ -0,0 +1,51 @@ +/** + * A view-mode decorator that wraps the built-in node and appends (or prepends) + * a small glyph — typically an error marker (⚠️). It renders `originalNode` + * unchanged and adds the glyph beside it, so it works on a value (leaf) node of + * any type and inherits the node's normal styling and edit affordances. The + * definition guards to value nodes (not collections — see `definition.ts`). + * + * The component is presentation-only: *which* nodes it decorates is the + * definition's `condition` (see `errorIndicatorDefinition`), not anything this + * component reads. Point that condition at your error nodes — e.g. + * `condition: (nd) => validation.hasErrorAt(nd.path)` with `useValidationState` + * from `@json-edit-react/utils`. + */ + +import { type ReactNode } from 'react' +import { type CustomComponentProps } from 'json-edit-react' +import './style.css' + +export interface ErrorIndicatorProps { + /** The glyph shown beside a flagged node. Default `'⚠️'`. */ + errorGlyph?: ReactNode + /** Glyph placement relative to the node. Default `'after'`. */ + position?: 'before' | 'after' +} + +export const ErrorIndicatorComponent = ({ + originalNode, + componentProps, +}: CustomComponentProps) => { + const { errorGlyph = '⚠️', position = 'after' } = componentProps ?? {} + + const glyph = ( + + {errorGlyph} + + ) + + // inline-flex keeps the glyph on the same line as the value (originalNode is a + // block-level node) and vertically centred; `gap` spaces it without per-side + // margins. + return ( + + {position === 'before' && glyph} + {originalNode} + {position === 'after' && glyph} + + ) +} diff --git a/packages/components/src/ErrorIndicator/definition.ts b/packages/components/src/ErrorIndicator/definition.ts new file mode 100644 index 00000000..77451174 --- /dev/null +++ b/packages/components/src/ErrorIndicator/definition.ts @@ -0,0 +1,45 @@ +import { type CustomNodeDefinition } from 'json-edit-react' +import { createDefinitionFactory } from '../_common/createDefinitionFactory' +import { ErrorIndicatorComponent, type ErrorIndicatorProps } from './component' +// Imported here too (as in DatePicker) so `sideEffects: false` can't tree-shake +// the styles out for consumers who only reach the factory. +import './style.css' + +const ErrorIndicatorDefinition: CustomNodeDefinition = { + // Guard to value (leaf) nodes — the glyph sits beside a scalar, not wrapped + // around a whole collection (which also looks wrong and disrupts the + // collection's own rendering). This is ANDed with the consumer's `condition`, + // so e.g. an AJV `if`/`then` error reported at the parent object's path never + // flags that collection; only the leaf where the value is wrong gets marked. + // (`typeof null === 'object'`, so null is included as a value node + // explicitly.) Override `guard` to opt collections back in. + condition: ({ value }) => value === null || typeof value !== 'object', + component: ErrorIndicatorComponent, + // We wrap the built-in rendering rather than replace it. + passOriginalNode: true, + // A view decorator: the standard editor shows while editing; the glyph + // reappears on the next view render. + showOnView: true, + showOnEdit: false, +} + +/** + * Decorate nodes with a glyph (default ⚠️). Unlike the other pre-built + * components, this one has no intrinsic target — pass a `condition` for the + * nodes to flag, e.g. with `useValidationState` from `@json-edit-react/utils`: + * + * ```tsx + * const validation = useValidationState(data, validate) + * const customNodeDefinitions = useMemo( + * () => [errorIndicatorDefinition({ condition: (nd) => validation.hasErrorAt(nd.path) })], + * [validation] + * ) + * ``` + * + * The default targeting is a no-op, so calling it with no `condition` flags + * nothing (rather than every node). + */ +export const errorIndicatorDefinition = createDefinitionFactory( + ErrorIndicatorDefinition, + () => false +) diff --git a/packages/components/src/ErrorIndicator/index.ts b/packages/components/src/ErrorIndicator/index.ts new file mode 100644 index 00000000..618b7d06 --- /dev/null +++ b/packages/components/src/ErrorIndicator/index.ts @@ -0,0 +1,2 @@ +export * from './definition' +export * from './component' diff --git a/packages/components/src/ErrorIndicator/style.css b/packages/components/src/ErrorIndicator/style.css new file mode 100644 index 00000000..9a7a3f61 --- /dev/null +++ b/packages/components/src/ErrorIndicator/style.css @@ -0,0 +1,8 @@ +/* Lay the glyph out inline with the value (originalNode is a block-level node) + and vertically centred; `gap` spaces it from the value without per-side + margins. A class (not inline styles) so consumers can override it. */ +.jer-error-indicator-wrapper { + display: inline-flex; + align-items: center; + gap: 0.4em; +} diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 46bf0d91..5c8d3c4d 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -12,6 +12,7 @@ export * from './Image' export * from './ColorPicker' export * from './ReactSelect' export * from './CodeEditor' +export * from './ErrorIndicator' // The definition factories' override surface; the factory builder itself // (`createDefinitionFactory`) stays internal diff --git a/test/error-indicator.test.tsx b/test/error-indicator.test.tsx new file mode 100644 index 00000000..d9a2061d --- /dev/null +++ b/test/error-indicator.test.tsx @@ -0,0 +1,155 @@ +import { useMemo, useState } from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { JsonEditor } from '../src/JsonEditor' +// Import from the component's own subtree, NOT the package barrel — the barrel +// re-exports components that pull ESM-only deps (react-markdown) which break +// Jest. ErrorIndicator itself depends only on React + core types. +import { errorIndicatorDefinition } from '../packages/components/src/ErrorIndicator' +import { useValidationState, type Validate } from '../packages/utils/src' + +const errorGlyph = () => screen.queryByRole('img', { name: 'error' }) +const wrapperOf = (text: string) => + screen.getByText(text).closest('.jer-error-indicator-wrapper') + +describe('errorIndicatorDefinition', () => { + it('flags nothing by default (no condition → no-op)', () => { + render( + {}} + customNodeDefinitions={[errorIndicatorDefinition()]} + /> + ) + expect(errorGlyph()).not.toBeInTheDocument() + }) + + it('decorates only the nodes its condition selects', () => { + render( + {}} + customNodeDefinitions={[ + errorIndicatorDefinition({ condition: (nd) => nd.key === 'bad' }), + ]} + /> + ) + expect(screen.getAllByRole('img', { name: 'error' })).toHaveLength(1) + expect(wrapperOf('"y"')).not.toBeNull() // flagged node is wrapped + expect(wrapperOf('"x"')).toBeNull() // sibling is untouched + }) + + it('guards to value nodes — never decorates a collection, even with a broad condition', () => { + render( + {}} + // A condition that matches everything; the guard still excludes the + // root + `obj` collections, so only the two leaf values are flagged. + customNodeDefinitions={[errorIndicatorDefinition({ condition: () => true })]} + /> + ) + expect(screen.getAllByRole('img', { name: 'error' })).toHaveLength(2) + expect(wrapperOf('"x"')).not.toBeNull() + expect(wrapperOf('"y"')).not.toBeNull() + // Collections render normally (children visible) — they weren't wrapped. + expect(screen.getByText('obj')).toBeInTheDocument() + }) + + it('renders the default ⚠️ glyph after the node', () => { + render( + {}} + customNodeDefinitions={[errorIndicatorDefinition({ condition: (nd) => nd.key === 'bad' })]} + /> + ) + const wrapper = wrapperOf('"y"')! + expect(wrapper.lastElementChild).toHaveClass('jer-error-indicator') + expect(wrapper.lastElementChild).toHaveTextContent('⚠️') + }) + + it('honors the errorGlyph and position componentProps', () => { + const { rerender } = render( + {}} + customNodeDefinitions={[ + errorIndicatorDefinition({ + condition: (nd) => nd.key === 'bad', + componentProps: { errorGlyph: '❌', position: 'before' }, + }), + ]} + /> + ) + let wrapper = wrapperOf('"y"')! + expect(wrapper.firstElementChild).toHaveClass('jer-error-indicator') + expect(wrapper.firstElementChild).toHaveTextContent('❌') + + rerender( + {}} + customNodeDefinitions={[ + errorIndicatorDefinition({ + condition: (nd) => nd.key === 'bad', + componentProps: { errorGlyph: '❌', position: 'after' }, + }), + ]} + /> + ) + wrapper = wrapperOf('"y"')! + expect(wrapper.lastElementChild).toHaveClass('jer-error-indicator') + expect(wrapper.lastElementChild).toHaveTextContent('❌') + }) +}) + +// The headline: editing one node flips the validity of a node on another +// branch, and the glyph appears/clears there. That node bails on the commit via +// the §16 memo boundary, so only the customNodeDefinitions identity (memoized on +// `validation`) piercing the memo can re-render it. Mirrors the #359 cross- +// branch test, proven here through the component. +describe('cross-branch flagging via useValidationState', () => { + const crossBranch: Validate = (data) => { + const d = data as { method?: string; cardNumber?: unknown } + return d.method === 'card' && String(d.cardNumber ?? '').length < 4 + ? [{ path: ['cardNumber'], message: 'card number too short', keyword: 'minLength' }] + : [] + } + + const Host = ({ initial }: { initial: object }) => { + const [data, setData] = useState(initial) + const validation = useValidationState(data, crossBranch) + const customNodeDefinitions = useMemo( + () => [errorIndicatorDefinition({ condition: (nd) => validation.hasErrorAt(nd.path) })], + [validation] + ) + return + } + + it('clears the glyph on a cross-branch node when the edit makes it valid', async () => { + const user = userEvent.setup() + render() + expect(wrapperOf('"12"')).not.toBeNull() // invalid on load → flagged + + await user.dblClick(screen.getByText('"card"')) + await user.clear(screen.getByRole('textbox')) + await user.type(screen.getByRole('textbox'), 'cash{Enter}') + await screen.findByText('"cash"') + + expect(wrapperOf('"12"')).toBeNull() // cross-branch node un-flagged + }) + + it('applies the glyph to a cross-branch node when the edit makes it invalid', async () => { + const user = userEvent.setup() + render() + expect(wrapperOf('"12"')).toBeNull() // valid on load → not flagged + + await user.dblClick(screen.getByText('"cash"')) + await user.clear(screen.getByRole('textbox')) + await user.type(screen.getByRole('textbox'), 'card{Enter}') + await screen.findByText('"card"') + + expect(wrapperOf('"12"')).not.toBeNull() // cross-branch node now flagged + }) +})