@stonecrop/desktop
v0.45.1
Published
Desktop-specific components for Stonecrop UI
Readme
@stonecrop/desktop
A three-view UI shell for Stonecrop applications. Renders a doctype list → records list → record form layout driven by the host application's Registry and HST state.
Desktop reads through Stonecrop: on navigating to a list or a record it calls Stonecrop.getRecords / Stonecrop.getRecord, which fetch through the host's registered DataClient and write into HST. Writes are the host's: Desktop emits action and the host dispatches it.
A record shows as a form only once it is in HST. Until then the record view says it is loading, that the server does not have the record, or why the read failed (with Try again), and offers none of the record's actions.
Features
- Three-view layout: doctypes → records → record form, navigated by route or adapter
- ActionSet tile column: expandable tile UI with Search, host slots, and an Actions drawer; FSM transitions populate the actions list automatically from the doctype workflow
- CommandPalette:
Ctrl+K/Cmd+Ksearch across doctypes and records - SheetNav: tabbed navigation between open records, with a toolbar for host controls in the footer (see Slots)
- Event-driven: all significant interactions emit typed events for the host to respond to
Installation
pnpm add @stonecrop/desktopDesktop requires @stonecrop/stonecrop to be installed and the StonecropPlugin mounted before use:
import { createApp } from 'vue'
import Stonecrop, { Doctype } from '@stonecrop/stonecrop'
import { RestDataClient } from './client'
import planDoctype from './doctypes/plan.json'
const app = createApp(App)
// The plugin constructs the Registry itself and provides it as `$registry`; it does not
// accept one. Register doctypes on that instance, after install.
app.use(Stonecrop, { router, client: new RestDataClient() })
const registry = app.config.globalProperties.$registry
registry.addDoctype(Doctype.fromObject(planDoctype))
app.mount('#app')client is the DataClient Desktop reads through. It can also be supplied later with stonecrop.setClient(client); Nuxt hosts do this from a plugin via useStonecropSetup().registerClient.
Basic Usage
<script setup lang="ts">
import { Desktop } from '@stonecrop/desktop'
import { useClientAction } from '@stonecrop/stonecrop'
// Runs an action's clientHandler when the doctype declares one, dispatches to the server
// otherwise, and reconciles the store and the route with the identity the server settled on.
// In a Nuxt host this is auto-imported, so drop the import line.
const { run } = useClientAction()
</script>
<template>
<Desktop
:available-doctypes="['plan', 'recipe', 'resource']"
@action="run"
/>
</template>Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| availableDoctypes | string[] | [] | Doctype slugs to display in the doctypes list |
| routeAdapter | RouteAdapter | none | Custom routing layer (required for Nuxt/custom hosts) |
| actionSetSlots | ActionSetSlot[] | [] | Host drawer slots shown as tiles in ActionSet |
| hostActions | ActionElements[] | none | Actions listed in the Actions drawer in place of those derived from the doctype; [] lists none |
Record identity is not a prop. It is declared per doctype (primaryKey, falling back to id) and resolved through Doctype.getRecordId, so a row's link always matches the key the record is stored under. One shell renders many doctypes, so a single prop could never answer this correctly.
Slots
| Slot | Renders |
|------|---------|
| sheetnav-toolbar | Host controls in the footer, immediately left of the navigation tabs |
A control deeper in the page than this slot can reach, such as a custom field inside the record form, teleports into the same place:
<script setup lang="ts">
import { SHEET_NAV_TOOLBAR_SELECTOR } from '@stonecrop/desktop'
</script>
<template>
<Teleport :to="SHEET_NAV_TOOLBAR_SELECTOR">
<MyToolbar />
</Teleport>
</template>Mount one SheetNav per page: with two, teleported content lands in the first and SheetNav logs a warning. When the toolbar and the tabs do not fit on one row, the toolbar moves to a row above the tabs. The Nuxt playground's country records (/country/US) carry a working example.
Emitted Events
| Event | When |
|-------|------|
| action | User triggers a declared action: an FSM transition or a Command |
| navigate | Desktop wants to change views |
| record:open | User opens a specific record |
| load-records | Desktop is about to read a records list (a notification: Desktop performs the read) |
| load-record | Desktop is about to read a single record (a notification: Desktop performs the read) |
See api.md for payload type definitions.
Event Handling Notes
- action: Desktop merges
Doctype.getAvailableTransitionsandDoctype.getAvailableCommands, both resolved againstStonecrop.getRecordState, into the ActionSet Actions drawer. Desktop never dispatches: that is the host application's responsibility. - load-records / load-record: notifications, not fetch requests. Desktop reads through
Stonecrop.getRecords/Stonecrop.getRecorditself, using the registeredDataClient; these events announce that read so a host can hang analytics off it. A host that fetches here races Desktop's own read into the same HST key.load-recordis not emitted for a draft, which has nothing to fetch.
Router Adapter
For Nuxt apps (or any host with custom route conventions), supply a routeAdapter instead of relying on the registry's Vue Router:
import { useRoute, useRouter } from '#app'
import type { RouteAdapter, NavigationTarget } from '@stonecrop/desktop'
function useCustomRouteAdapter(): RouteAdapter {
const route = useRoute()
const router = useRouter()
return {
getCurrentDoctype: () => route.meta.slug as string ?? '',
getCurrentRecordId: () => route.params.id as string ?? '',
getCurrentView: () => {
if (!route.meta.slug) return 'doctypes'
if (!route.params.id) return 'records'
return 'record'
},
navigate: (target: NavigationTarget) => {
if (target.view === 'doctypes') return router.push('/')
if (target.view === 'records') return router.push(`/${target.doctype}`)
return router.push(`/${target.doctype}/${target.recordId}`)
},
}
}<Desktop :route-adapter="useCustomRouteAdapter()" @action="handleAction" />Handling action Events
Dispatching is not the whole job: the result has to land in HST under the identity the server settled on, which for a newly created record is not the id that was dispatched.
Bind @action to useClientAction's run, as in Basic Usage above. It runs an action's clientHandler when it has one, dispatches otherwise, and reconciles the store and the route. It lives in @stonecrop/stonecrop, so every Vue 3 host gets the same one; Nuxt hosts also get it as an auto-import from @stonecrop/nuxt.
Three things are adjustable, for the cases that genuinely differ between applications:
| Option | Replaces | Use it for |
|--------|----------|------------|
| buildArgs | the [{ id, data }] envelope | a backend expecting another argument shape |
| followRecord | router.replace('/{doctype}/{id}') | a locale prefix, a nested route, or staying put |
| onError | a blocking window.alert | your own notification system |
args is an opaque JSON array: nothing validates it, so both ends of your own stack have to agree. A backend taking positional [recordId, data] supplies buildArgs to say so.
Resolving a record's identity and keying it into HST are deliberately not adjustable. That rule is declared on the doctype and re-derived server-side by the adapter, and every host that re-derived it client-side got it wrong. If you dispatch through Stonecrop.dispatchAction directly instead of using this composable, that method still files the result's record (the server's read of the record after the action) under the settled identity, so you cannot store it under the wrong key by accident. What you lose is the stale-key cleanup and the route-follow, which need the id you dispatched.
Do not copy form data into HST before dispatching. Desktop already hands you the current form snapshot in payload.data, and an unsaved record has no HST node to write to.
See the host integration guide for the full wiring.
Provide / Inject
Desktop provides a desktopMethods object that child components (slot content) can inject:
import { inject } from 'vue'
const { navigateToDoctype, openRecord, createNewRecord, emitAction } =
inject('desktopMethods')!emitAction(name, data?) is a convenience wrapper for emitting an action event from deeply nested slot content without passing refs down manually.
Desktop blesses no action name. It used to expose a handleDelete method and a confirmFn prop, which together emitted a hardcoded DELETE action and prompted before it; but no doctype declares DELETE, so it failed on every click, and only the host knows which of its actions are destructive. Removal is a workflow outcome: declare an action with a nextState such as Archived or CANCELLED, and confirm inside your own @action handler before dispatching.
