npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@qrotux/editrig-shadcn-react

v0.2.1

Published

Server-driven entity editor for React on shadcn/ui and rjsf: schema-driven forms, relation pickers, media uploads, keyed and repeatable blocks.

Downloads

402

Readme

@qrotux/editrig-shadcn-react

Server-driven entity editor for React, styled with shadcn/ui and Tailwind v4. The server describes one record — JSON Schema, uiSchema and data in a single envelope — and EditorPage renders the form with rjsf v6: sections, a relation picker, media upload, keyed (localized) fields, a structural JSON editor, clearable date/time inputs, a record header with copy-id and back, and a sticky Save / Reset / Delete footer with dirty tracking and per-field revert.

The reference server implementation is editrig-go. Any backend that speaks the wire protocol below works.

Install

npm install @qrotux/editrig-shadcn-react

Installing straight from git also works; the prepare script builds dist on install.

npm install github:qrotux/editrig-shadcn-react#v0.1.0

Peer dependencies: react, react-dom, @rjsf/core, @rjsf/shadcn, @rjsf/utils, @rjsf/validator-ajv8, @tanstack/react-query, lucide-react. react-router-dom is optional and only needed for the ./react-router entry. Install them in one go if your project does not have them yet:

npm install react react-dom @rjsf/core @rjsf/shadcn @rjsf/utils \
  @rjsf/validator-ajv8 @tanstack/react-query lucide-react

rjsf and react-query are peers, not dependencies, on purpose: both hold React context that the host application shares with the rest of its pages. A second copy in the tree would give the form its own QueryClient and its own rjsf registry.

Styling

The components carry Tailwind utility classes and use the shadcn theme variables (--background, --muted, --accent, --primary, --destructive, --input, --ring, --popover, --card, --secondary, and their -foreground pairs). The form body is rendered by the @rjsf/shadcn theme, which carries its own classes, so your Tailwind setup must scan both packages:

@import "tailwindcss";
@import "@qrotux/editrig-shadcn-react/theme.css";

@source "../node_modules/@qrotux/editrig-shadcn-react/dist";
@source "../node_modules/@rjsf/shadcn/lib";

Adjust the relative @source paths to your CSS entry file.

theme.css does two things: it maps the shadcn tokens to Tailwind utilities so bg-background, text-muted-foreground, border-input and rounded-md are generated at all, and it supplies default token values so a project with no theme of its own is styled out of the box. The defaults are declared at zero specificity, so an application that already defines the shadcn tokens (its own :root { --background: … }, layered or not) overrides them with no import-order requirement — a real :root beats the file's :where(:root), and an @layer base declaration beats its @layer theme one. So the import is safe either way: it fills only the tokens you leave undefined, and it follows your --radius for the corner scale (rounded-md is calc(var(--radius) - 2px), the canonical shadcn formula).

For dark mode, theme.css carries the .dark token values, but the dark: utilities in the form body come from @rjsf/shadcn and need the host to declare the class variant once, as any shadcn project does:

@custom-variant dark (&:where(.dark, .dark *));

Usage

EditorPage renders one record. name selects the entity, id is null for the create form and the record id otherwise. A QueryClientProvider must be mounted above it.

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useNavigate, useParams } from "react-router-dom";
import { EditorPage } from "@qrotux/editrig-shadcn-react";
import { useEditorState } from "@qrotux/editrig-shadcn-react/react-router";

const qc = new QueryClient();

export function UserEditorPage() {
  const { id: routeId } = useParams();
  const { id } = useEditorState(routeId);
  const navigate = useNavigate();

  return (
    <QueryClientProvider client={qc}>
      <EditorPage
        name="users"
        id={id}
        onSaved={(savedId) => navigate(`/admin/users/${savedId}`, { replace: true })}
        onDeleted={() => navigate("/admin/users")}
        onCancel={() => navigate("/admin/users")}
      />
    </QueryClientProvider>
  );
}

useEditorState(routeId) maps the route parameter to the editor's id: the literal segment new (and a missing parameter) become null, i.e. the create form. It needs no Router context of its own. It lives in the ./react-router entry because that entry is the one place allowed to import react-router-dom; a project on another router derives id from its own route and passes it directly.

EditorPage props

| Prop | Description | | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | name | Entity name; forms the endpoint path. | | id | Record id, or null for the create form. | | basePath | Endpoint prefix, default /api/admin/entities (DEFAULT_EDITOR_BASE). An absolute URL works too. | | fetch | Transport for every request the page and its fields make (FetchLike, i.e. typeof fetch). Defaults to the global fetch; see Transport. | | locale | BCP-47 tag used for Intl formatting in the header and read-only widgets, default en-GB. Not the key of keyed fields. | | onSaved(id) | Called after a successful save. The page stays on the record — use it to put a freshly created id into the URL. | | onDeleted() | Called after a successful delete. The record is gone, so the page renders nothing until the host navigates away — use this to leave the record route. | | onCancel() | Handler for "Back to list" in the header. The header renders no back button without it. | | canDelete | Defaults to "the record already exists" (id != null). Pass false for read-mostly entities. The flag can only remove the button, never show it on the create form (there is no record to delete). | | maxWidthClassName | Reading-width class on the form column, default max-w-3xl. | | transformSchema | (schema, uiSchema, formData) => { schema, uiSchema }, applied to the envelope before rendering. Project-side schema tweaks live here. | | initialData | Preset values for the create form, read as a snapshot on mount and mixed into the envelope itself (so a preset is neither "dirty" nor lost on Reset). | | messages | Partial EditorMessages overriding the English chrome strings. | | fields | RegistryFieldsType merged over the built-in fields, keyed by ui:field. | | widgets | RegistryWidgetsType merged over the built-in widgets, keyed by ui:widget. |

Transport

Every request goes through one function: the fetch prop when given, the global fetch otherwise, always with credentials: "include". A host that needs an auth header, a CSRF token or another origin wraps fetch once and passes the wrapper in; the fields that talk to the server on their own (the relation and media pickers) receive the same wrapper through the form context.

import type { FetchLike } from "@qrotux/editrig-shadcn-react";

const authedFetch: FetchLike = (input, init) => {
  const headers = new Headers(init?.headers);
  headers.set("Authorization", `Bearer ${token}`);
  return fetch(input, { ...init, headers });
};

<EditorPage name="users" id={id} basePath="https://api.example.com/admin/entities" fetch={authedFetch} />;

The wrapper must pass init through untouched apart from what it adds: a multipart submit relies on fetch deriving Content-Type from the FormData body.

Save, Reset and dirty state

The dirty baseline is the last envelope the server sent — the load response, or the echo of the last successful save. EditorPage compares top-level keys of the form data against envelope.data (with rjsf's deepEquals), and adds every field that has a file staged for upload. Save and Reset are both disabled while nothing differs.

Each changed field carries a revert control (messages.revertField) that puts that one field back to the baseline. A key that was absent from the baseline is removed rather than nulled, because the server reads null as a deliberate clear and an absent key as "leave this column alone".

A save response replaces the whole envelope, so the server may swap the schema on save (for example, unlocking fields once a record exists). The schema is re-read from load and save responses only — nothing else re-renders it.

Fields the server marked "ui:readonly": true are stripped from every create/update payload. That strip is hygiene; the Go engine strips them again and remains the boundary.

Cache invalidation

The envelope is cached by react-query under ["editor", name, id, base] (the basePath sits last so two pages on different bases but the same name and id do not share an entry; invalidateEntity still matches by prefix). The create schema (id === null) is cached with staleTime: Infinity; a loaded record is always fresh-checked. EditorPage invalidates its own entry after a save and the whole entity after a delete. From outside:

import { invalidateEntity } from "@qrotux/editrig-shadcn-react";

invalidateEntity(qc, "users"); // every record of the entity
invalidateEntity(qc, "users", "42"); // one record (null = the create schema)

Relation options are cached separately under ["editor-options", base, entity, field, search, parent] with a 60 s staleTime.

Messages and i18n

All chrome strings live in one dictionary:

import { defaultEditorMessages, type EditorMessages } from "@qrotux/editrig-shadcn-react";

<EditorPage name="users" id={id} messages={{ save: "Сохранить", back: "К списку" }} />;

messages is a partial overlay on defaultEditorMessages, so the keys you do not pass keep their English default. The dictionary covers exactly the frame the library owns: loading and the load-failure prefix, Save/Saving/Saved and the save-failure prefix, Delete/Deleting and the confirm dialog, Reset and per-field revert, Back, Copy ID, the create hint, the "Untitled" header fallback, select-all/none, the relation picker (select, search, empty, remove, reorder), media upload (hint, remove, choose from uploaded, the four rejection reasons, reorder, add more) and the clear control of nullable date/time inputs.

Domain copy is deliberately not here. Field titles, descriptions, enum labels, section headings, relation labels and validation messages all arrive already localized inside the server envelope, because the server owns the entity's vocabulary. A new user-visible chrome string is a new EditorMessages key with an English default, never an inline literal.

Extending with project-specific renderers

fields and widgets are merged over the built-in registries, so replacing one renderer leaves the rest intact. The key is whatever the server declares in ui:field / ui:widget:

<EditorPage name="users" id={id} fields={{ media: MyMediaField }} />

Built-in ui:field keys: json, keyed, relation, media. Built-in ui:widget keys: readonlyDisplay, number, date, datetime, date-time, localDatetime, localTime, SelectWidget, tags. The date / date-time entries deliberately shadow rjsf's format-resolved core widgets, whose "clear" path yields undefined; undefined is dropped by JSON.stringify, which would turn clearing a nullable column into "do not touch it". SelectWidget is rjsf's own key for every enum: the built-in one renders the option list in a Radix portal (the theme's inline list stacks under the sticky action bar and is clipped by overflow containers); a multiple enum shows its values as chips inside the same trigger, and a single nullable enum (ui:emptyValue: null) gets a clear button that sends null. tags renders a free-text string array (items: { type: "string" }, no enum) as chips beside an inline input: Enter, comma or blur add the typed value, Backspace on an empty input removes the last chip.

A replacement renderer gets the same ambient state the built-ins use, through two public helpers.

useEditorForm() returns the form-wide context rjsf does not thread down:

| Field | Use | | ------------------------------------------ | -------------------------------------------------------------------------------------------- | | messages | the resolved EditorMessages | | setFieldValues(patch) | shallow-merge a patch into the form data (rjsf gives object templates no whole-object write) | | disabled | true while saving or deleting | | dirtyFields, resetField(f) | the change markers and the per-field revert | | locale | BCP-47 tag for Intl | | contentKey, setContentKey | the form-wide key of keyed fields, so switching one translation switches the others | | entity, base | what a field needs to call the API on its own | | stagedFiles, stageFiles, clearStaged | the not-yet-uploaded files, keyed by field name | | isNew, recordId | create vs. edit, and the id used as ?parent when scoping a picker | | rootData | the root form data, for a field scoped by a sibling (ui:options.parentField) |

useRelationOptions(entity, field, search, enabled, base?, parent?, fetch?) is the option query behind the pickers. It stays idle while enabled is false, so a form with four relation fields costs no requests until a picker is opened. Pass useEditorForm().fetch as the last argument so a transport override reaches the picker.

MediaField is exported so a project renderer can wrap it rather than copy it: staging a file, building a preview and enforcing the client-side rejections is most of the component.

Wire protocol

Every request is sent with credentials: "include". All paths are relative to basePath (default /api/admin/entities, exported as DEFAULT_EDITOR_BASE).

| Request | Meaning | | ---------------------------------------------- | ----------------------------------------------------- | | GET {base}/{name}/schema | the create form: an envelope with id: null, no data | | GET {base}/{name}/entity/{id} | load one record | | POST {base}/{name}/entity | create; returns the saved envelope | | PATCH {base}/{name}/entity/{id} | update; returns the saved envelope | | DELETE {base}/{name}/entity/{id} | delete; 204, no body | | GET {base}/{name}/options/{field}?q=&parent= | relation/media options: { "options": [...] } |

The envelope is the whole contract:

{
  "name": "users",
  "id": "42",
  "schema": { "type": "object", "required": ["title"], "properties": {} },
  "uiSchema": {},
  "data": {}
}

schema is a JSON Schema (rjsf RJSFSchema), uiSchema an rjsf UiSchema carrying the library's declarative extensions, and data the record. Titles, descriptions and enum labels inside them are already localized.

Create and update bodies are {"data": {...}}. The response of both is a full envelope, which becomes the new baseline.

One option looks like {"value": "...", "label": "..."} — the label arrives already localized. Labels of values that are already selected do not come from this endpoint: the server ships them in the load envelope under the field's ui:options.labels, so mounting a form costs no extra request. q is the search term, parent scopes the list to an owner (the media picker asks for the files of the record being edited).

uiSchema extensions

Two keys live on the root uiSchema. rjsf lifts every root ui:* key into the root object's uiOptions with the prefix stripped, so they reach the library's templates untouched.

ui:groups is the section layout — an array of { id, title?, description?, columns?: 1 | 2, toggleAll?, fields: string[] }. Fields named in no group fall through to an implicit trailing section, so a newly added server-side column can never silently disappear.

ui:header is { titleField?, subtitleField?, metaFields? }: which data fields are promoted out of the form body into the record header. Meta labels come from schema.properties[f].title. A meta field literally named id gets the copy button — that one name is hardcoded, everything else about the header is declarative.

Per-field, the library reads "ui:readonly": true (read-only, and stripped from the payload), ui:widget and ui:field from the registry keys above, and ui:emptyValue (what a cleared nullable field sends — null rather than undefined). A read-only field is derived from its own entry alone; there is no root-level list of read-only field names, and deliberately no root ui:readonly, which rjsf would read as "disable the whole form".

The built-in fields and widgets read these ui:options keys:

| Renderer | Key | Meaning | | -------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | ui:field: relation | multi | true for an array of ids (the schema is then {type: "array", items: {type: "string"}}); default a single id. | | | labels | { [id]: label } for the values already selected, so mounting the form costs no options request. | | | parentField | Name of a sibling field whose value is sent as ?parent= when loading options; the list is then scoped by that field, not by the record. | | | collection | Informational: the collection behind the field. Options are requested by field name. | | ui:field: media | multi, labels, collection | As for relation; a label is the thumbnail URL of the stored file. | | | aspect | Preview box ratio: 1:1 (default), 2:1, 4:3 or 3:2. Unknown values fall back to 1:1. | | | maxBytes | Client-side size ceiling per file; default 10 MiB. | | | accept | MIME allow-list, also used as the file input's accept; default image/jpeg, image/png, image/webp. HEIC/HEIF is always rejected. | | ui:field: keyed | keys | [{ value, label? }]: the switchable keys (locales, tiers). Value and label travel together. | | | default | The key opened first when the form-wide key is not set. | | | layout | popover (default), chips or expanded: how the key switcher is offered. | | | inner | uiSchema of the inner field, one copy for every key ("ui:widget": "textarea", …). | | ui:widget: number | decimals | Fixed number of decimals shown while the input is not focused. | | ui:field: json | — | No options; a schema on the field drives the editor's own validation. |

relation and media are top-level fields only. Both request options by bare field name, and media additionally stages files and keys the multipart part by that name, so the same field inside an array item or a nested object would collide across rows. A nested media field degrades to a read-only view of its value (with a console.warn); a nested relation still renders but its picker shows an empty list, since the server does not know the nested name.

Errors

A non-2xx response raises an Error carrying status and the parsed body. The message is the body's error string when there is one, HTTP <status> otherwise; that message is what a non-422 failure shows in the form-level alert. On 422 the body is:

{
  "fieldErrors": [{ "field": "/email", "message": "already taken" }],
  "formErrors": []
}

field is a JSON Pointer (RFC 6901) rooted at the form data; "" or "/" means a form-level error. toExtraErrors folds the flat list into the nested tree rjsf wants for extraErrors, and the errors block submit until the next edit clears them. Any other failure (network, 500) surfaces as a single form-level alert prefixed with messages.saveFailed, and a failed initial load renders an alert prefixed with messages.loadFailed. A DELETE that fails with formErrors (a 409 on a record still referenced elsewhere) shows the server's own text unprefixed, since it is already localized.

File upload

A submit with staged files goes out as multipart/form-data instead of JSON: a payload part carrying the same {"data": ...} JSON, plus one file:<field> part per file (several parts under one name for a multi-value media field, reassembled server-side in part order). Content-Type is never set by hand — fetch derives it from the FormData body, together with the boundary. With no staged files the request is byte-for-byte the plain JSON path.

Before a file is staged the client rejects HEIC/HEIF by extension, anything outside ui:options.accept (falling back to image/jpeg, image/png, image/webp), files over ui:options.maxBytes (falling back to 10 MiB) and a batch that does not fit the field's maxItems. These are fast refusals, not the security boundary — the server checks again.

Golden fixture

src/__fixtures__/synthetic-entity.json is a dump of the envelope produced by the Go side's own test, used verbatim as the /schema response in editor-page.test.tsx. It is the drift gate between the two halves: when the Go envelope changes shape, the fixture is re-dumped and this suite is what notices. Never hand-edit it to make a test pass.

Entries

| Entry | Contents | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | . | EditorPage, useEditorForm, useRelationOptions, MediaField, invalidateEntity, defaultEditorMessages, the FetchLike transport type and the protocol types (Envelope, EditorOption, EditorGroup, EditorHeaderSpec, FieldError, EditorErrors, DEFAULT_EDITOR_BASE). | | ./react-router | useEditorState(routeId); the entry reserved for react-router-dom bindings. |

Everything else — the form templates, header, footer, confirm dialog, the individual fields and widgets — is internal. Consumers customise through EditorPage props and the declarative uiSchema keys.

The optional default theme is also published, imported from CSS rather than JS: @qrotux/editrig-shadcn-react/theme.css (see Styling).

Examples

examples/ holds complete, pastable pages — the minimal react-router setup, driving id without a router, custom renderers, and testing one through the public surface. They import the package by name and are checked by npm run typecheck and npm test, so a breaking change to the public API fails there. See examples/README.md.

Development

npm install
npm run typecheck   # checks src and examples/
npm run lint
npm test
npm run build

Tests run on vitest with jsdom and Testing Library (vitest.setup.ts registers jest-dom and stubs the ResizeObserver / pointer-capture APIs Radix expects). The build is tsup, ESM only, two entries, unminified so the consumer's Tailwind can read the class strings out of dist.

Links

Dependencies

Peer dependencies, installed by the host application:

Bundled: the Radix UI primitives behind the shadcn components, cmdk, vanilla-jsoneditor (loaded in its own chunk, only for entities that have a JSON field), class-variance-authority, clsx and tailwind-merge. Styling needs Tailwind CSS v4 in the host project, see Styling.

License

MIT