Skip to content

Views ​

Each view is a thin Vue component that delegates to a composable.

Route structure ​

setup.ts builds two routes per entity:

  • Overview — /{routePrefix}, name ${key}Overview.
  • Details — /{routePrefix}/:id, name ${key}Details, with nested children Fiche (DetailsSummary, read-only) and Form (${key}Form, edit); it redirects to the Form by default. Creating uses :id = "new".

Overview — useSearchView + useRouteOverview ​

useSearchView({ service, searchObject, defaultPageSize }) returns the list state and handlers: searchObject, pagingInfo, items, itemsCount, isLoading, feedback, searchHandler, applySave, applyRemove, handleSave, handleRemove. useRouteOverview({ searchObject, pagingInfo, handler, defaultPageSize }) keeps those in sync with the URL query and re-searches on navigation, returning updateOverviewRoute. (useOverviewCore is the shared base.)

The URL owns the filters: each search replaces the search object with the parsed query, so restored values are strings. Only the first search on a URL without search parameters keeps the search object the view passed in — that is where default filter values go. Date filters are written to the URL as ISO-8601 with the local offset.

applySave and applyRemove raise failures through feedback rather than throwing, and report them in their return value — SaveResult<T> | undefined and boolean. Guard the list mutation on it:

ts
if (await applyRemove(item)) handleRemove(item)

Calling handleRemove(item) unconditionally removes the row even when the server refused the delete (a validator's 400, or a 409 while the row is still referenced), so the list and the failure message contradict each other until the next fetch.

useSearchView vs useListView — a fetch-shape choice (every controller exposes /search): use useSearchView when you want counted paging + filters (service.search() → { items, count }), and useListView when a plain list is enough (service.list() → Array<T>, already unwrapped — never destructure { items } from it). Both send the current search object merged with the paging info, and useListView derives itemsCount from the rows it got back rather than from a server total.

Neither fetches on mount. The scaffolded overview's first search comes from useRouteOverview, a separate composable; take either of these on its own and the initial fetch is the view's to make — onAuthenticated(() => searchHandler(true)) in an authenticated app, which is also the right token timing.

Guard the lazy refs. items / itemsCount are undefined until the first fetch, so bind v-for="x in items ?? []" and :count="itemsCount ?? 0".

Overlapping calls settle predictably. searchHandler, listHandler, applySave and applyRemove share one "newest wins" gate: only the newest of them writes items, itemsCount, feedback and isLoading, so a slower earlier call is dropped when it lands. They really do overlap: a filter change or fast paging starts a second fetch, the slice's onAuthenticated reload hook re-fetches as soon as a restored token arrives while useRouteOverview's mount fetch is still in flight, and a row saved or deleted from the list settles against whichever of those is on the wire. Only the shared state is gated — applySave and applyRemove still return what the server said, so handleSave / handleRemove apply to the list either way.

Details — useDetails ​

useDetails(service) loads the item for the route :id and returns item, isLoading, overviewUrl, load, and feedback. The Details component renders a nested <RouterView> for the Fiche/Form child, passing item. item is undefined until the onMounted load resolves — gate the child with <RouterView v-if="item" …>.

Form — useForm (and useModal) ​

useForm({ entityService, props, emit }) returns item plus handleSubmit, handleCancel, handleRemove, handleRestore, and feedback. Note the form's handleRemove() takes no arguments (it removes the bound item.value) — unlike the overview's handleRemove(item). Define props with withDefaults(defineProps<FormProps & …>(), { ...formDefaults }) and emits via FormEmits<T>. Its changeState event reports a write as FormStates.pending, then one final state: saved or removed when the write succeeded, error when the server refused it or it failed — a refused delete never reports removed. useModal is the in-modal variant for editing without leaving the page.

After an insert, handleSubmit replaces the current route with one carrying the new id (skipped when isPopup is set). A failed save lands in feedback: a 400's field errors on feedback.error (the ProblemDetails errors, read with toFeedbackError from vue/ui, which shows a validation error in the user's language when its message is a key in the app's translation messages), otherwise the server's text on feedback.message. The form's Feedback lists those field errors under the message, each headed by the field's label; a form that shows them at its inputs sets :hide-field-errors="true" on it.

readonly is the form's write gate, read each time a handler runs: on a readonly form, handleSubmit, handleRemove and handleRestore return without calling the service, and FormButtonsRow renders no buttons. When the API gates writes by role or row ownership, derive readonly from the signed-in user and pass it down the slice — the overview's List / ListItem and the details form all take it — so the UI offers only what that user may do. This hides buttons; the API remains the only place writes are authorized.

For child/owned collections inside a form, render the rows with InputSelectorInline — chips that mark persisted removals _deleted (undoable until save, filtered out in the service's prepareItem override), remove rows added this session outright (nothing to undo; override the detection via the isNew prop), and hand the picker slot an exclude list. Its delete button's title reads "Remove" or "Restore"; pass translated ones through the labels prop. The heavier per-row editors are useOwnedCollection, useOwnedModal, useListInput, and useListItemInput. The multi-Selector hard-removes on delete, so it does not fit collections that need the marked-delete UX.

Filter — useFilter ​

useFilter({ searchObject, emit, Constructor }) takes the search object as a Ref (the filter component supplies it via defineModel<SearchObject>) and returns handleUpdate, handleFilter, handleReset, handleToggle, and filterIsActive. Pass the slice's search-object class as Constructor — the scaffold's filter components call useFilter({ searchObject, emit, Constructor: SearchObject }) — because filterIsActive compares the model's non-null keys against a new Constructor()'s own keys; without it the fallback DefaultSearchObject has no own runtime keys, so filterIsActive is always false. Emit filter to trigger a search; the overview calls updateOverviewRoute(true) in response.

Selector ​

Selector.vue (generated per slice under selecting/) is a reusable entity picker for choosing related entities in forms.

Overview ​

  1. Abstractions
  2. Services
  3. Config
  4. Views
  5. Built-in features
  6. Attachments
  7. Checklist