neuron-explorer
Qwen3.5‑4B 전용 조향형 뉴런 탐색기, 모든 MLP 뉴런을 밀어 결과를 보관·탐색
react-data-form·react-resource-view 문서, 데이터를 폼으로 정의하고 API와 연동해 목록·상세·CRUD 폼 제공
Documentation for react-data-form and react-resource-view: describe a form as data, declare a resource, and get the list, the detail and the CRUD forms wired to your API.
개발 도구TypeScript · HTMLGitHub @hermez-agency온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
CRUD views for JSON-LD / Hydra APIs: list, read, create, update and delete, in table, card, column, split, calendar or timeline layouts.
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
CRUD views for REST APIs — API Platform, Strapi, Supabase, FastAPI. You
declare a resource — its path, its form, its layout — and the package renders
the list, the detail, the create and edit forms, and the delete confirmation,
wired to the API and to the URL.
Built on react-data-form
for the forms. Which API answers, and how it spells a page or a filter, is a
dialect — JSON-LD is the default, not a requirement.
Two libraries, one idea: react-data-form for the forms, react-resource-view
for the application around them.
Watch the 1-minute tour
(with sound).README 전문 보기 ↗Documentation: hermez.dev/react-resource-view
— a runnable starter in your browser, with two resources and every layout, no install and no API: edit a declaration and watch the screen change.
CRUD views for REST APIs — API Platform, Spring Boot, Supabase, Strapi, FastAPI, Django, or any REST API that publishes OpenAPI. You declare a resource — its path, its form, its layout — and the package renders the list, the detail, the create and edit forms, and the delete confirmation, wired to the API and to the URL.
import { createViewResource, layout } from "react-resource-view"
const articles = createViewResource<Article>("articles", {
name: "Articles",
path: "/api/articles",
form: { inputs: { title: { label: "Title" }, body: { label: "Body" } } },
views: {
list: { layouts: [layout.table(), layout.cards({ columns: 3 })] },
},
})
That is the whole declaration: a list with its buttons, a detail page, create
and edit forms and a delete confirmation — every action offered unless
permissions restricts it, every field name of form.inputs checked against Article.
Upgrading from 0.x? 1.0 rewrote the declaration:
npx react-resource-view migraterewrites yours, MIGRATION-1.0.md lists every change, andmigrations/1.0.jsonthe same list as data.
Built on react-data-form
for the forms. Which API answers, and how it spells a page or a filter, is a
dialect — JSON-LD is the default, not a requirement.
Two libraries, one idea: react-data-form for the forms, react-resource-view
for the application around them.
Watch the 1-minute tour
(with sound).
A page builder declared as data: blocks on the left, the live page on the right, and the JSON behind it. Watch the full video (with sound) or open the page builder.
Everything below, at length and with the examples running rather than quoted: the documentation site — a page per layout, per filter, per dialect — and a playground: a whole back office built from eight resource declarations, every edit real, every screen a URL, and the source of each screen one click away.
For an AI assistant: llms.txt
indexes the documentation,
llms-full.txt holds
every page in one Markdown file, code samples included, and the
API reference is
generated from the published types, so a declaration is written against the
exact signatures.
A resource is declared once and registered. The URL says which resource, which
action and which filters; the context resolves that into a view, fetches through
the resource's own repository, and renders. The package never talks to a router
itself, nor to one API in particular: the router arrives through
configurePorts, the API through configureApi.
The picture above is a still of an interactive diagram: open it to trace a relationship, focus a component, or follow the four guided views.
The views know a resource has rows, pages and filters. How a given backend spells those — the URL an item lives at, the query string a filter becomes, the envelope a collection arrives in, where the validation errors hide — is a dialect, set once at startup:
import { configureApi, strapiDialect } from "react-resource-view"
configureApi({
baseUrl: "https://cms.example.com",
getAuthToken: () => (isLogged() ? getUserToken() : undefined),
dialect: strapiDialect(),
})
Resources read the dialect at each request, so configureApi may run before or
after the modules declaring them.
configureApi applies to every dialect, JSON-LD included: once it sets a
baseUrl, a fetch or getHeaders, the JSON-LD requests go through them
rather than through the configureClient client, and the baseUrl and
getAuthToken are handed to Mercure as well.
| Dialect | Backend | What it knows |
|---|---|---|
jsonLdDialect() | API Platform, Hydra | member / totalItems, IRIs, page and itemsPerPage, Hydra violations, Mercure, CSV export |
strapiDialect() | Strapi v4 and v5 | pagination[page], filters[field][$eq], sort[0], populate, writes under data, documentId |
supabaseDialect() | Supabase, PostgREST | limit / offset, field=eq.value, order, the count in Content-Range, Prefer: return=representation |
fastapiDialect() | FastAPI | skip / limit or page / size, plain query filters, order_by=-field, { items, total }, 422 detail |
springDialect() | Spring Boot | page (from 0) / size, sort=field,desc, Page, PagedModel or HAL _embedded, Bean Validation errors |
djangoDialect() | Django REST Framework | page / page_size or limit / offset, django-filter query filters, ordering=-field, { count, results }, field errors |
restDialect() | Any plain REST API | the names of its page, size and sort parameters, the total in a header (X-Total-Count), Link: rel="next", a cursor |
jsonServerDialect() | json-server | _page, _limit, _sort + _order, X-Total-Count |
drfCursorDialect() | DRF CursorPagination | ?cursor= / page_size, , no total |
JSON-LD is the default, so an application already talking to API Platform needs
none of this — and keeps going through the client it configured with
configureClient (see The rest of the configuration).
import { configureApi, strapiDialect, createViewResource } from "react-resource-view"
configureApi({
baseUrl: "https://cms.example.com",
getAuthToken: () => getApiToken(),
dialect: strapiDialect(),
})
const articles = createViewResource("articles", {
path: "articles", // → /api/articles
name: "Articles",
form: { inputs: { title: { label: "Title" } } },
views: { list: { pageSize: 25 } },
})
strapiDialect takes apiPath (default /api), populate (default "*" —
without it the relation columns come back empty), identifier (documentId on
v5, id on v4) and defaultOperator ($eq; pass $containsi to make every
text filter a case-insensitive search). The v4 { id, attributes } envelope is
flattened on the way in, so a resource declared once reads the same on both
versions.
import {
configureApi,
supabaseDialect,
createViewResource,
} from "react-resource-view"
configureApi({
baseUrl: "https://xyzcompany.supabase.co",
getAuthToken: () => getSession()?.access_token,
dialect: supabaseDialect({ apiKey: import.meta.env.VITE_SUPABASE_ANON_KEY }),
})
const articles = createViewResource("articles", {
path: "articles", // → /rest/v1/articles
name: "Articles",
form: { inputs: { title: { label: "Title" } } },
})
supabaseDialect takes apiKey (a value or a function), primaryKey (default
id — PostgREST addresses a row by a filter on it, having no item route),
select (default *; "*,author(*)" embeds a relation), schema,
defaultTextOperator and restPath.
import {
configureApi,
fastapiDialect,
createViewResource,
} from "react-resource-view"
configureApi({
baseUrl: "https://api.example.com",
getAuthToken: () => getAccessToken(),
dialect: fastapiDialect(),
})
const items = createViewResource("items", {
path: "items", // → /items, /items/{id}
name: "Items",
form: { inputs: { title: { label: "Title" } } },
})
fastapiDialect takes primaryKey (default id), pagination
("skip-limit", the default, or "page-size" for
fastapi-pagination),
defaultItemsPerPage (default 30), orderParam (default order_by, sent as
-created_at,title) and trailingSlash (default false; set it when the
collection route is declared as /items/, or FastAPI answers with a 307
redirect). A 422 lands under the field its loc names —
["body", "address", "city"] on the address.city input; a detail spelled
as a sentence becomes the error's explanation.
A route the dialect reads as it stands, as in the FastAPI tutorial — a bare array carries no total, so the pagination hides itself:
@app.get("/items", response_model=list[Item])
def list_items(skip: int = 0, limit: int = 30, order_by: str | None = None):
return repository.list(offset=skip, limit=limit, order_by=order_by)
@app.get("/items/{item_id}", response_model=Item)
def read_item(item_id: int): ...
@app.post("/items", response_model=Item)
def create_item(item: ItemCreate): ...
@app.patch("/items/{item_id}", response_model=Item)
def update_item(item_id: int, item: ItemUpdate): ...
@app.delete("/items/{item_id}", status_code=204)
def delete_item(item_id: int): ...
With fastapi-pagination the list answers { items, total, page, size, pages },
and the pagination counts its pages from total — pass
fastapiDialect({ pagination: "page-size" }):
from fastapi_pagination import Page, add_pagination, paginate
@app.get("/items", response_model=Page[Item])
def list_items():
return paginate(repository.all())
add_pagination(app)
import { configureApi, springDialect, createViewResource } from "react-resource-view"
configureApi({
baseUrl: "http://localhost:8080",
getAuthToken: () => getAccessToken(),
dialect: springDialect(),
})
const books = createViewResource("books", {
path: "/api/books", // → /api/books?page=0&size=20, /api/books/{id}
name: "Books",
form: { inputs: { title: { label: "Title" } } },
})
A controller taking a Pageable and returning a Page needs nothing more: the
dialect reads content and totalElements, the page block of a PagedModel,
or Spring Data REST's _embedded — a record whose id is hidden is addressed
by its _links.self.href. springDialect takes primaryKey (default id),
defaultItemsPerPage (default 20) and updateMethod ("PUT", the default, or
"PATCH"). A failed @Valid lands each errors[].field on its input, and a
ProblemDetail gives the error its title and explanation.
import { configureApi, djangoDialect, createViewResource } from "react-resource-view"
configureApi({
baseUrl: "http://localhost:8000",
getAuthToken: () => getAccessToken(),
dialect: djangoDialect(),
})
const articles = createViewResource("articles", {
path: "/api/articles", // → /api/articles/?page=1&page_size=30, /api/articles/{id}/
name: "Articles",
form: { inputs: { title: { label: "Title" } } },
})
A ModelViewSet with a paginator answers { count, next, previous, results };
the dialect reads results and counts its pages from count. A view without
pagination answers a bare array, which carries no total, so the pagination hides
itself. djangoDialect takes primaryKey (default id), pagination
("page-number", the default, or "limit-offset" for
LimitOffsetPagination), pageSizeParam (default page_size, which DRF only
reads when the paginator sets page_size_query_param), defaultItemsPerPage
(default 30), orderParam (default ordering, sent as -created_at,title)
and trailingSlash (default true, as DRF's routers declare it; Django
redirects the other spelling and the redirect loses the body of a POST). A 400
lands each message under its field — { address: { city: [...] } } on
address.city, non_field_errors on the form — and a detail becomes the
explanation of the error. The schema comes from
drf-spectacular at
/api/schema/?format=json.
import { configureApi, jsonServerDialect, restDialect } from "react-resource-view"
configureApi({ baseUrl: "http://localhost:3000", dialect: jsonServerDialect() })
// Or describe your own: the names of its parameters, where the total comes from.
restDialect({
pageParam: "page",
sizeParam: "per_page",
totalHeader: "X-Total-Count",
})
restDialect({ pageParam: false, linkHeader: true }) // follows Link: rel="next"
restDialect({ cursorParam: "cursor", itemsKey: "results", nextKey: "next" })
jsonServerDialect() is _page, _limit, _sort with _order, and the total
in X-Total-Count; drfCursorDialect() is DRF's CursorPagination. A cursor-paged
page carries a nextCursor (null on the last one), which the list and the
export follow without needing a total.
A resource may carry a dialect of its own, which wins over the configured one:
const invoices = createViewResource("invoices", {
path: "invoices",
dialect: supabaseDialect({ apiKey }),
})
They are written once, in the package's own vocabulary, and the dialect translates them:
| Key | Means | Strapi | Supabase | FastAPI | Django |
|---|---|---|---|---|---|
page | 1-based page | pagination[page] | offset | skip (or page) | page |
itemsPerPage | rows per page (views.list.pageSize) | pagination[pageSize] | limit | limit (or size) | page_size (or limit) |
order | { title: "asc" } | sort[0]=title:asc | order=title.asc | order_by=title (-title) | ordering=title (-title) |
title: "hello" | a field | filters[title][$eq] | title=eq.hello | title=hello | title=hello |
status: ["a","b"] | any of | filters[status][$in] | status=in.(a,b) | status=a&status=b | status=a&status=b |
A value spelled as an object carries its own operator through untouched —
{ title: { $containsi: "hell" } } on Strapi, { createdAt: { gte: "2024-01-01" } }
on Supabase.
A dialect is one object — buildRequest, readCollection, readItem,
getId, getIdentifier, normalizeError — and ApiDialectInterface is
exported to implement it. A resource that brings its own repository: { getCollection, getItem, … } still bypasses all of this, as it always could.
A hand-written repository is not tied to JSON-LD:
getCollection answers { data: { items, totalItems } } — no member, no
@id, no cast (nextCursor is accepted too);getItem and removeItem always receive { id: string }, and updateItem
the record with that same id — the record's idField when declared, else
its short identifier (7 for /api/tasks/7), never the whole item or an IRI;idField: "id" on the resource builds links, keys and selection from that
field, so the records need no @id.createViewResource<Task>("tasks", {
path: "/rpc/tasks",
idField: "id",
repository: {
getCollection: async (params) => {
const { rows, total } = await rpc.tasks.list(params)
return { data: { items: rows, totalItems: total } }
},
getItem: ({ id }) => rpc.tasks.get(id).then((data) => ({ data })),
},
})
Rather than copying the API's types into hand-written interfaces, export the
OpenAPI schema your API publishes (/api/docs.jsonopenapi on API Platform,
/openapi.json on FastAPI, where the Pydantic models become
components["schemas"]), and generate the types with
api-dumper:
pnpm add -D api-dumper typescript
// api-dumper.config.ts
import { defineConfig } from "api-dumper"
export default defineConfig({
source: "http://localhost/api/docs.jsonopenapi",
outDir: "src/api-schema",
preset: "api-platform", // only on API Platform
strip: { contentTypes: ["text/csv"] },
})
npx api-dumper writes openapi.json, api-schema.ts and one file per enum under
enums/, all to commit; npx api-dumper --check fails the CI when they are out of
date.
paths types the client, components["schemas"] types the resources:
import type { components } from "./src/api-schema/api-schema"
// An item is typed with the shape the API returns: API Platform's JSON-LD
// schema carries "@id", a PostgREST or Strapi row does not.
type Article = components["schemas"]["Article.jsonld"]
const articles = createViewResource<Article>("articles", {
path: "/api/articles",
// `form.inputs`, `titleField`, `layout.board({ groupBy })`… are checked
// against Article: a field that does not exist is a TypeScript error. An
// input that is no field (a confirmation) goes in the second generic:
// createViewResource<Article, Article & { confirm: string }>. `filters.inputs`
// is not checked: a filter is often no field either.
form: { inputs: { title: { label: "Title" } } },
})
The whole recipe — the config, the enums, the per-backend specifics for API Platform, Strapi, Supabase, FastAPI and Django, and when to regenerate — is on the documentation site.
One command writes a whole back office on your API — Vite, React, Tailwind, TanStack Router, the admin layout and one example resource — and asks for the rest:
npx react-resource-view init my-admin
It asks which API the back office talks to — API Platform, Spring Boot,
another REST API, Supabase, Strapi, FastAPI or Django — its URL, and the URL of its
OpenAPI schema, guessed from the backend (/api/docs.jsonopenapi,
/v3/api-docs, /openapi.json, /api/schema/?format=json). The dialect is wired in src/setup.ts, the URL
lands in .env, and the project comes with the commands that dump the schema
into TypeScript with api-dumper:
npm run api-schema # or: make api-schema — writes src/api-schema/
npm run api-schema:check # in the CI: fails when src/api-schema is stale
One starter per technology, ready to paste:
| Backend | Command | Schema dumped from |
|---|---|---|
| API Platform | npx react-resource-view init my-admin -b api-platform -u https://localhost | /api/docs.jsonopenapi |
| Spring Boot | npx react-resource-view init my-admin -b spring-boot -u http://localhost:8080 | /v3/api-docs |
| Other REST API | npx react-resource-view init my-admin -b rest -u http://localhost:3000 -s <url> | the URL you pass |
| Supabase | npx react-resource-view init my-admin -b supabase -u https://<ref>.supabase.co | supabase gen types |
| Strapi | npx react-resource-view init my-admin -b strapi -u http://localhost:1337 | /api/openapi.json |
| FastAPI | npx react-resource-view init my-admin -b fastapi -u http://localhost:8000 | /openapi.json |
| Django | npx react-resource-view init my-admin -b django -u http://localhost:8000 | /api/schema/?format=json |
Every question has a flag — --backend spring-boot --api-url http://localhost:8080 --schema-url … --yes — see npx react-resource-view init --help and
the starter page.
Then declare the rest of the API in one go: npx react-resource-view generate resources
reads the OpenAPI schema and writes one resource per collection — inputs, selects,
relations, permissions, a table and a board
(the generate page).
Writing your API in Python? FastAPI and Django side by side.
pnpm add react-resource-view react-data-form react-mini-i18n resource-registry
react, react-data-form, react-mini-i18n and resource-registry are peer
dependencies. The last two own module-level singletons — a dictionary and a
registry — so they must resolve to a single copy.
The components use Tailwind CSS v4 classes backed by the shadcn theme variables. Tailwind must scan the compiled files:
@import "tailwindcss";
@source "../node_modules/react-resource-view/dist";
/* Only if your application has no shadcn theme of its own */
@import "react-resource-view/styles.css";
The views navigate and build links, but the package knows no router. It asks for four primitives — a fifth, optional, says when it is loading — and ships an adapter for TanStack Router:
import { configurePorts } from "react-resource-view"
import { tanstackAdapter } from "react-resource-view/tanstack"
configurePorts({ navigation: tanstackAdapter })
With any other router, supply the four yourself:
configurePorts({
navigation: {
useNavigate: () => { /* ({ to, replace, resetScroll }) => void */ },
useLocation: () => ({ pathname, searchStr }),
Link: ({ to, children, ...rest }) => <RouterLink to={to} {...rest}>{children}</RouterLink>,
Navigate: ({ to, replace }) => <RouterRedirect to={to} replace={replace} />,
},
})
Left unconfigured, navigation falls back to full page loads through the History API. Enough for a test or a story, not for production.
Importing react-resource-view/tanstack is what pulls TanStack Router in — the
core never references it, so an application on another router installs nothing
extra.
Moving to another view takes the time the next one needs to load — its code,
its permissions, its data. Meanwhile a thin bar runs along the top of the
screen, in the theme's --primary, and the new view fades in, sliding up a few
pixels, once it is there. Nothing to install: React and inline styles.
prefers-reduced-motion: reduce, the view fades without moving.aria-busy, the bar has role="progressbar";
neither takes the focus.A navigation counts from a click on a link of the library, or a call to its
navigate, until the new view has loaded. A router that knows more says so
through the optional useIsNavigating of the navigation port — the TanStack
adapter reads useRouterState(s => s.status === "pending").
<ResourceViewProvider
configuration={{
// a node, a component receiving { isNavigating }, or false for nothing
navigationLoader: ({ isNavigating }) => <PageLoader isLoading={isNavigating} />,
viewTransition: false, // true by default
}}
/>
useIsNavigating() gives the raw signal, useDelayedVisibility() the delay
and minimum duration, and NavigationProgressBar the default bar, for an
indicator drawn elsewhere.
configurePorts({
appName: "My application", // page title suffix
description: "…", // page metadata
appUrl: "https://app.example.com", // absolute links escaping an iframe
isDev: import.meta.env.DEV, // development affordances
})
The API connection is configured separately, through
configureApi. On the JSON-LD dialect it can also come
from the client itself, which is what an existing API Platform application
already does:
import { configureClient } from "jsonld-api-client"
configureClient({
baseUrl: "https://api.example.com",
getAuthToken: () => (isLogged() ? getUserToken() : undefined),
getScope: () => getCurrentScope(),
})
configureApi falls back to those settings when it is given none of its own,
so nothing has to move.
A scope's decoratorComponent wraps every view of that scope. AdminLayout is
a ready-made one — a collapsible sidebar built from the scope's menu, a top
bar with a breadcrumb, a page header naming the record on screen, and a bottom
bar instead of the sidebar on a phone:
const adminScope: ScopeInterface = {
name: "admin",
label: "Back office",
decoratorComponent: createAdminLayout({ logo: <MyLogo />, topBarEnd: <UserMenu /> }),
// The one place a resource's scope is written: its links lead here.
resources: [tasks, users],
// One entry per resource; or a list mixing resources, links and groups.
menu: "auto",
}
Links kept out of the way of the pages — help, support, documentation — are
pinned at the bottom of the sidebar with footerMenu (an absolute href opens
in a new tab), or any node with sidebarFooter; on a phone they go behind a
last entry of the bottom bar:
createAdminLayout({
footerMenuTitle: "Need help?",
footerMenu: [{ name: "Documentation", href: "https://example.com/docs" }],
})
Views sit in a column of constrained width; a board, a calendar or a wide table
takes the whole page with fullWidth — at the root of the resource, on one
action of views, or on a single layout, the most specific one winning:
createViewResource<Task>("tasks", {
path: "/api/tasks",
fullWidth: true,
views: {
list: { layouts: [layout.board({ groupBy: "status", columns: STATUSES })] },
// The forms keep the column.
update: { fullWidth: false },
},
})
The admin layout page goes through the navigation, the slots and the theme; the playground runs on it, its task board full width.
What is common to every action sits at the root of the resource; what belongs
to one action sits in views.<action>, and the two are merged deeply — a view
only says what differs:
export const tasks = createViewResource<Task>("tasks", {
name: "Tasks",
icon: ListTodo,
path: "/api/tasks",
fullWidth: true,
// Everything is allowed by default: only restrictions are declared.
permissions: {
delete: ({ item }) => item?.status !== "done",
},
form: { inputs: TASK_FIELDS }, // shared by create, update and read
views: {
list: {
filters: { inputs: { title: { label: "Search a title" } } },
layouts: [
layout.board({ groupBy: "status" }), // one column per option of `status`
layout.table({ fields: ["title", "status", "dueDate"] }),
],
},
read: { open: "drawer" }, // a row's button and a card of the board alike
update: { fullWidth: false, open: "dialog", afterSave: "close" },
},
})
| Key | Where | What it does |
|---|---|---|
name, icon | root | The menu, the titles (derived: "Tasks", "Edit — Tasks"), the breadcrumb |
path | root | The API path — or storage: "local" to keep the records in the browser ({ type: "local", seed } for the first ones), or a repository |
idField | root | The field holding a record's identity when it has no @id (idField: "id") |
permissions | root | "all" (default), "readonly", a list of actions, or one boolean / function per action |
hooks | root | beforeList, beforeRead, beforeCreate, beforeUpdate, beforeDelete, afterSave, afterDelete |
realtime | root | Reloads the views when the API pushes a change (Mercure) |
quota | root | How many records may be created: { get: (ctx) => ({ current, max }), fallback } |
form, fullWidth, titleField, components, description | root or views.<action> | Merged deeply, the action winning; inputs: { notes: false } removes a field |
title, buttonLabel | views.<action> | The view's title, when the derived one will not do; the text of its button |
open | views.read, create, update, delete | "page", "dialog", "drawer" or open.custom() — written into the URL, see below |
afterSave | views.create, update | "close", "stay", "read", or |
Dropping a card in another column of a board asks permissions.move, which
answers as update unless it is declared: { update: false, move: true }
moves the cards without offering the edit form.
In development, createViewResource checks the declaration: a key it does not
read, a 0.x key or a resource with no data source throws an error that says how
to write it.
inspectDefinition(id, declaration, name) runs the same checks as
createViewResource but returns the problems instead of throwing, each a
DefinitionProblem ({ level: "error" | "warning", path, message }), so a
test can assert that a declaration is sound:
import { inspectDefinition } from "react-resource-view"
import { describe, expect, it } from "vitest"
import { tasksDeclaration } from "./tasks"
describe("the tasks declaration", () => {
it("has no problem", () => {
expect(inspectDefinition("tasks", tasksDeclaration, "Tasks")).toEqual([])
})
})
They decide what the interface offers — the API stays where access is enforced.
A refused action hides its buttons and protects its route: /books/update/1
draws the scope's forbiddenFallback instead of the form. A function is told
the record, and may answer asynchronously; the answers are cached per record
while a list is on screen. In a component, usePermission("delete", record).
views.<action>.open is decided once, by the view. Anything but page is
written into the URL under the view it was opened from —
…/tasks/list/tasks/update/12, with the list's filter and layout — so a row's
button, a board's card and a shared link open the same frame, Back closes it, a
reload opens it again, and a save closes it by going back to the list's URL.
A frame of your own is built like a layout of your own:
import { open } from "react-resource-view"
const accordion = open.custom({
type: "accordion",
placement: "inline", // under the row that opened it; or "overlay"
component: ({ title, children, close, expandHref }) => (
<Collapsible open onOpenChange={close}>
<h3>{title}</h3>
{children}
</Collapsible>
),
})
views: {
read: {
open: accordion
}
}
configureResourceView({ open: { accordion } }) registers it under a name
(open: "accordion", typed by augmenting the OpenFrames interface). inline
is drawn under the row by a table, a plain list and a card grid — a
layout.custom() says it can with supports: ["inline"] and draws
<InlineChildView row={row} />; elsewhere the frame falls back to a dialog,
with a warning in development.
A list renders through one or several layouts, from the layout namespace:
| Declaration | Layout |
|---|---|
layout.table() | Data table, editable in place |
layout.cards({ columns }) | Card grid |
layout.board({ groupBy }) | Kanban board, one column per option |
layout.split() | List on the left, details on the right |
layout.calendar({ dateField }) | Calendar, by day, week or month |
layout.timeline({ startField, endField }) | Timeline, grouped by row |
layout.items() | Plain item list |
layout.tree({ parentField }) | Records under their parent |
layout.map({ latitudeField, longitudeField }) | A marker per record on a map |
layout.chart({ groupBy }) | Figures and a chart over the records |
layout.custom({ type, components }) | A layout of your own |
Several layouts can coexist on one resource; the view keeps the reader's choice
in the URL, under the layout's type (?variant=board) — a label of its own
(label: "Kanban") does not change it. Every layout takes fields (the
columns, the lines of a card), label, icon, fullWidth and components.
A card of a board opens the record's read view where views.read.open says,
and several cards move together. The selection is the list's: declared once on
views.list, it holds for every layout — table, cards, items, board:
views: {
list: {
// On by default when there are bulk actions; a board stays selectable
// for "Move to…" unless `selectable: false`.
selectable: true,
// In the bar a selection brings up — beside "Move to…" on a board.
// `"delete"` is the built-in deletion of the selected records.
bulkActions: [
{ label: "Archive", onAction: (records) => archive(records) },
"delete",
],
layouts: [layout.board({ groupBy: "status", columns: STATUSES })],
},
}
views.read.open says: over the board — with a way to edit it — at
the same URL as the row's read button, or on the record's page.data-card-control); selecting
text does not open the card either.A layout is a layout.custom() call over three components, so the ninth layout
is a single file — and one command writes it:
npx react-resource-view create-view-variant Heatmap --dir src/views
Run it bare and it asks for the name and the directory. It writes
src/views/heatmapLayout.tsx: the list, row and item components, the function
that declares them, and the options interface to extend — read back in the
components with useLayout().options. Nothing is registered anywhere — declare
it in layouts beside the built-in ones:
import heatmapLayout from "./views/heatmapLayout"
views: {
list: { layouts: [layout.table(), heatmapLayout()] },
}
--icon <LucideIcon> picks the switcher's icon, --jsx writes JavaScript,
--dry-run prints the file instead of writing it, and --yes never asks — see
npx react-resource-view --help. Create your own view
variant
takes the generated file apart, and runs one.
filters.inputs declares the filter form, and filters.initial the filters
applied as long as the URL carries none of its own. Both are written in the
package's own vocabulary; the dialect translates
them for the API:
views: {
list: {
filters: {
inputs: { published: { label: "Published" } },
initial: { published: true },
},
},
}
The distinction matters: a defaultValue on a filter input would not do, since
the first request goes out before the form exists — the list would then show
something other than what the filters display.
pnpm install
pnpm test
pnpm typecheck
pnpm lint
pnpm build
The diagram is generated by archify from
diagrams/react-resource-view.architecture.json. The renderer is installed
rather than vendored; skills-lock.json records the version it came from:
npx skills add tt-a1i/archify --skill archify --agent claude-code --copy
ARCHIFY=.claude/skills/archify/bin/archify.mjs
# Rebuild the interactive artefact the documentation site serves
node $ARCHIFY deliver architecture \
diagrams/react-resource-view.architecture.json \
website/public/architecture.html --quality showcase
# Recapture the still the README shows, and keep the 2048x1320 light one
node $ARCHIFY visual-check website/public/architecture.html
cp website/public/architecture.visual-check.2048x1320.light.png \
diagrams/react-resource-view.png
rm website/public/architecture.visual-check.*
deliver refuses to write an artefact that fails its own layout and
composition checks, so a diagram that lands is a diagram that reads.
A Hermez product — hermez.dev.
MIT
A Hermez product
{ results, next }"reload"{ redirect: (item) => string }tabs | views.read, update | Sub-views under the record: { items: [{ resource, filter: ({ item }) => … }] } |
layouts, filters, pageSize, rowActions | views.list | How the list is drawn, filtered, paged, and what its rows offer |