@groveback/ui
v0.7.0
Published
React primitives for Groveback Studio screens — tables, forms, detail views and relation pickers that read through the Groveback SDK, so every query passes the policy engine.
Maintainers
Readme
@groveback/ui
React primitives for screens built with Groveback Studio — tables, forms, detail views and relation pickers that read through the Groveback SDK.
Beta. These components ship alongside Studio, whose stored screen format is still settling. Expect breaking changes before 1.0.
Every read and write goes through an SDK collection handed in as a prop: the primitives never build a URL or hold a key, so all data access passes your project's policy engine. A generated screen cannot reach data its viewer could not reach by hand.
Install
npm install @groveback/ui
# or
bun add @groveback/uiReact 18 or 19 is a peer dependency. The components are styled with Tailwind utility classes and ship no CSS of their own, so your app's Tailwind (v4) build has to scan them — one line in your CSS does it:
@import "tailwindcss";
@import "@groveback/ui/tailwind.css";That file carries the @source pointing at the package's own bundle, so it works from any install layout. Dark mode follows the viewer's prefers-color-scheme, which is Tailwind's default.
Usage
Studio generates screens that import these; you can also use them directly.
import { GroveUiProvider, Stack, Heading, DataTable } from '@groveback/ui';
import { createGrove } from './grove'; // generated by `grove gen`
const grove = createGrove();
export default function Products() {
return (
<GroveUiProvider value={{ navigate: (href) => router.push(href), currency: 'USD' }}>
<Stack direction="vertical" gap={4}>
<Heading level={1}>Products</Heading>
<DataTable
collection={grove.collection('products')}
options={{ limit: 50 }}
columns={[
{ field: 'name', label: 'Name' },
{ field: 'price', label: 'Price', format: 'currency' },
]}
rowHref="/products/:id"
rowActions={[{ label: 'Delete', action: { kind: 'delete' }, variant: 'destructive' }]}
/>
</Stack>
</GroveUiProvider>
);
}Navigation is injected
GroveUiProvider takes a navigate function rather than importing a router, so the same component works under Next.js, React Router, or plain History. Without a provider it falls back to the History API.
Relations are chosen, not typed
A relation field stores a document id. Pass resolveRelation and a display hint, and Form renders a searchable picker over the referenced collection instead of an id input:
<Form
collection={grove.collection('products')}
mode="create"
fields={[
{ field: 'name', label: 'Name' },
{ field: 'categoryId', label: 'Category', display: { field: 'title' } },
]}
resolveRelation={(field) => grove.collection('categories')}
/>The picker loads a bounded first page and filters in memory, and it tells the user when the list is truncated — the data API has no text search to delegate to, and silently hiding matches would be worse than saying so.
The routing shell lives here, not in your repo
grove gen --ui writes routes.tsx as a table — ROUTES, one entry per Studio screen — and a one-line GroveRoutes that mounts it on this package's shell:
import { GroveRoutes } from './routes'; // generated
import { SignIn } from '@groveback/ui';
import { createGrove } from './grove';
const grove = createGrove();
export default function App() {
return (
<SignIn auth={grove.auth}>
<GroveRoutes />
</SignIn>
);
}GroveRoutes matches the current URL against the table (specific routes ahead of parameterised ones), pushes history on navigation, and provides the context the primitives dispatch through. The generated mount already passes locale (the viewer's language when the document has it — detectLocale) and callEndpoint (custom-endpoint actions run through grove.client.run, so they carry the session). Pass context={{ currency, confirm, … }} for the rest, fallback for the unmatched case, and children for chrome that should render on every screen. With no fallback, the root with no home screen shows an index of the document's pages (RouteIndex) rather than "Not found". Already on a router? Feed ROUTES to it and skip GroveRoutes — matchRoute and resolveRoute are exported for that.
Multilingual documents compile each screen with a MESSAGES catalogue and const t = useMessages(MESSAGES, "en"): the active locale is the shell's, so every screen switches together — pass locale to the generated GroveRoutes to drive it from your own switcher.
Sign-in is a gate around the app
SignIn takes the SDK's auth object and renders its children once a session exists: email + password, an account-creation form (when auth.register exists), and the second-factor step when a login answers mfaRequired. Errors show the API's own message. Persisting the session across reloads is the app's job — hand the SDK an onTokensChanged and restore with setTokens on boot.
Deletes always confirm
The delete action lets you override the confirmation wording, never whether the prompt happens. That guarantee lives in the library, not in the calling screen.
Components
Stack · Grid · Divider · Heading · Text · Button · Header · DataTable · Detail · Form · RelationSelect · MarkdownField · GroveRoutes · RouteIndex · SignIn
Plus GroveUiProvider, useAction, useGroveUi, useMessages, detectLocale, formatValue, resolveHref, matchRoute, resolveRoute.
License
Apache-2.0
