Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/components-error-indicator.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion demo/src/examples/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ const allExamples: Record<string, ExampleDef> = {
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,
Expand Down
50 changes: 23 additions & 27 deletions demo/src/examples/static/validation-flagging/Example.tsx
Original file line number Diff line number Diff line change
@@ -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: {
Expand All @@ -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))

Expand All @@ -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-renderand 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 (
Expand All @@ -72,7 +68,7 @@ export default function ValidationFlagging() {
setData={setData}
{...useExampleProps()} // ---cut---
rootName="order"
theme={theme}
customNodeDefinitions={customNodeDefinitions}
/>
</div>
)
Expand Down
23 changes: 23 additions & 0 deletions packages/components/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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]
)
// <JsonEditor data={data} setData={setData} customNodeDefinitions={customNodeDefinitions} />
```

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.
Expand Down
51 changes: 51 additions & 0 deletions packages/components/src/ErrorIndicator/component.tsx
Original file line number Diff line number Diff line change
@@ -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<ErrorIndicatorProps>) => {
const { errorGlyph = '⚠️', position = 'after' } = componentProps ?? {}

const glyph = (
<span className="jer-error-indicator" role="img" aria-label="error">
{errorGlyph}
</span>
)

// 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 (
<span
className="jer-error-indicator-wrapper"
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.4em' }}
>
{position === 'before' && glyph}
{originalNode}
{position === 'after' && glyph}
</span>
)
}
45 changes: 45 additions & 0 deletions packages/components/src/ErrorIndicator/definition.ts
Original file line number Diff line number Diff line change
@@ -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<ErrorIndicatorProps> = {
// 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
)
2 changes: 2 additions & 0 deletions packages/components/src/ErrorIndicator/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './definition'
export * from './component'
8 changes: 8 additions & 0 deletions packages/components/src/ErrorIndicator/style.css
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions packages/components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading