@modufolio/panel 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +165 -0
- package/dist/Builder/ImageNodeView.d.ts +33 -0
- package/dist/Builder/document.d.ts +17 -0
- package/dist/Builder/dragHandle.d.ts +13 -0
- package/dist/Builder/editing.d.ts +53 -0
- package/dist/Builder/plugins.d.ts +27 -0
- package/dist/Builder/schema.d.ts +20 -0
- package/dist/Components/Actions/Action.vue.d.ts +116 -0
- package/dist/Components/Actions/ActionGroup.vue.d.ts +25 -0
- package/dist/Components/Actions/ActionGroupItem.vue.d.ts +48 -0
- package/dist/Components/Auth/ForgotPassword.vue.d.ts +17 -0
- package/dist/Components/Auth/Login.vue.d.ts +33 -0
- package/dist/Components/Auth/ResetPassword.vue.d.ts +27 -0
- package/dist/Components/Auth/TwoFactor.vue.d.ts +32 -0
- package/dist/Components/Auth/TwoFactorVerify.vue.d.ts +31 -0
- package/dist/Components/Columns/BadgeColumn.vue.d.ts +34 -0
- package/dist/Components/Columns/BooleanColumn.vue.d.ts +78 -0
- package/dist/Components/Columns/CellActions.vue.d.ts +34 -0
- package/dist/Components/Columns/ColorColumn.vue.d.ts +84 -0
- package/dist/Components/Columns/CopyButton.vue.d.ts +15 -0
- package/dist/Components/Columns/DateColumn.vue.d.ts +60 -0
- package/dist/Components/Columns/IconColumn.vue.d.ts +56 -0
- package/dist/Components/Columns/ImageColumn.vue.d.ts +46 -0
- package/dist/Components/Columns/SelectColumn.vue.d.ts +71 -0
- package/dist/Components/Columns/TextColumn.vue.d.ts +69 -0
- package/dist/Components/Columns/TextInputColumn.vue.d.ts +75 -0
- package/dist/Components/Columns/ToggleColumn.vue.d.ts +91 -0
- package/dist/Components/Columns/TreeColumn.vue.d.ts +60 -0
- package/dist/Components/Columns/columnRegistry.d.ts +6 -0
- package/dist/Components/Composables/useInlineEdit.d.ts +27 -0
- package/dist/Components/Composables/useRelationship.d.ts +38 -0
- package/dist/Components/Core/Badge.vue.d.ts +47 -0
- package/dist/Components/Core/Button.vue.d.ts +131 -0
- package/dist/Components/Core/Dropdown.vue.d.ts +24 -0
- package/dist/Components/Core/Empty.vue.d.ts +54 -0
- package/dist/Components/Core/ErrorBoundary.vue.d.ts +24 -0
- package/dist/Components/Core/FlashMessages.vue.d.ts +7 -0
- package/dist/Components/Core/Icon.vue.d.ts +23 -0
- package/dist/Components/Core/Icons/DocumentLinesIcon.vue.d.ts +3 -0
- package/dist/Components/Core/Icons/SitemapIcon.vue.d.ts +3 -0
- package/dist/Components/Core/Label.vue.d.ts +52 -0
- package/dist/Components/Core/LoadingButton.vue.d.ts +18 -0
- package/dist/Components/Core/Modal.vue.d.ts +26 -0
- package/dist/Components/Core/Pagination.vue.d.ts +10 -0
- package/dist/Components/Core/SelectInput.vue.d.ts +26 -0
- package/dist/Components/Core/Tag.vue.d.ts +65 -0
- package/dist/Components/Core/TextInput.vue.d.ts +25 -0
- package/dist/Components/Core/iconRegistry.d.ts +3 -0
- package/dist/Components/Dialogs/ConfirmDialog.vue.d.ts +16 -0
- package/dist/Components/Dialogs/DeleteConfirmDialog.vue.d.ts +30 -0
- package/dist/Components/Dialogs/Dialog.vue.d.ts +81 -0
- package/dist/Components/Drawer/Drawer.vue.d.ts +130 -0
- package/dist/Components/Drawer/DrawerFieldGrid.vue.d.ts +39 -0
- package/dist/Components/Drawer/DrawerLink.vue.d.ts +96 -0
- package/dist/Components/Drawer/DrawerRelationList.vue.d.ts +81 -0
- package/dist/Components/Drawer/DrawerStack.vue.d.ts +93 -0
- package/dist/Components/Drawer/DrawerTabs.vue.d.ts +42 -0
- package/dist/Components/Drawer/NestedDrawerForm.vue.d.ts +55 -0
- package/dist/Components/Drawer/drawerFieldGrid.d.ts +25 -0
- package/dist/Components/Drawer/drawerTabs.d.ts +16 -0
- package/dist/Components/Drawer/useDrawerPage.d.ts +34 -0
- package/dist/Components/Drawer/useDrawerStack.d.ts +93 -0
- package/dist/Components/Drawer/useFocusedStackRow.d.ts +22 -0
- package/dist/Components/Drawer/useIsDrawer.d.ts +66 -0
- package/dist/Components/Drawer/visitDrawer.d.ts +48 -0
- package/dist/Components/Fields/BelongsToSelect.vue.d.ts +247 -0
- package/dist/Components/Fields/BlueprintForm.vue.d.ts +96 -0
- package/dist/Components/Fields/Calendar/CalendarPanel.vue.d.ts +63 -0
- package/dist/Components/Fields/Calendar/MonthGrid.vue.d.ts +82 -0
- package/dist/Components/Fields/CheckboxField.vue.d.ts +92 -0
- package/dist/Components/Fields/ColorPickerField.vue.d.ts +105 -0
- package/dist/Components/Fields/DatePickerField.vue.d.ts +102 -0
- package/dist/Components/Fields/DateRangePickerField.vue.d.ts +138 -0
- package/dist/Components/Fields/DateTimePickerField.vue.d.ts +134 -0
- package/dist/Components/Fields/FieldGrid.vue.d.ts +13 -0
- package/dist/Components/Fields/FileUploadField.vue.d.ts +117 -0
- package/dist/Components/Fields/MultiSelectField.vue.d.ts +129 -0
- package/dist/Components/Fields/ProseMirrorBuilderField.vue.d.ts +71 -0
- package/dist/Components/Fields/RangeField.vue.d.ts +122 -0
- package/dist/Components/Fields/RepeaterField.vue.d.ts +174 -0
- package/dist/Components/Fields/RichTextEditorField.vue.d.ts +118 -0
- package/dist/Components/Fields/SelectField.vue.d.ts +102 -0
- package/dist/Components/Fields/TagsField.vue.d.ts +102 -0
- package/dist/Components/Fields/TextField.vue.d.ts +138 -0
- package/dist/Components/Fields/TextareaField.vue.d.ts +111 -0
- package/dist/Components/Fields/TimePickerField.vue.d.ts +115 -0
- package/dist/Components/Fields/ToggleButtonsField.vue.d.ts +93 -0
- package/dist/Components/Fields/ToggleField.vue.d.ts +95 -0
- package/dist/Components/Fields/fieldRegistry.d.ts +25 -0
- package/dist/Components/Fields/fieldsFromSpec.d.ts +10 -0
- package/dist/Components/Fields/useBlueprint.d.ts +94 -0
- package/dist/Components/Fields/useFieldWidth.d.ts +10 -0
- package/dist/Components/Fields/validation.d.ts +43 -0
- package/dist/Components/Filters/DateRangeFilter.vue.d.ts +92 -0
- package/dist/Components/Filters/FacetedFilter.vue.d.ts +41 -0
- package/dist/Components/Filters/FilterIndicators.vue.d.ts +40 -0
- package/dist/Components/Filters/FilterPopover.vue.d.ts +38 -0
- package/dist/Components/Filters/MultiSelectFilter.vue.d.ts +51 -0
- package/dist/Components/Filters/NumberFilter.vue.d.ts +61 -0
- package/dist/Components/Filters/QueryBuilder.vue.d.ts +32 -0
- package/dist/Components/Filters/SelectFilter.vue.d.ts +61 -0
- package/dist/Components/Filters/TernaryFilter.vue.d.ts +73 -0
- package/dist/Components/Layout/AppLayout.vue.d.ts +137 -0
- package/dist/Components/Layout/Breadcrumbs.vue.d.ts +44 -0
- package/dist/Components/Layout/Cluster.vue.d.ts +88 -0
- package/dist/Components/Layout/Container.vue.d.ts +69 -0
- package/dist/Components/Layout/Grid.vue.d.ts +110 -0
- package/dist/Components/Layout/PageHeader.vue.d.ts +62 -0
- package/dist/Components/Layout/Sidebar.vue.d.ts +59 -0
- package/dist/Components/Layout/Stack.vue.d.ts +69 -0
- package/dist/Components/Layout/TopNavigation.vue.d.ts +124 -0
- package/dist/Components/Media/MediaPickerDialog.vue.d.ts +20 -0
- package/dist/Components/Media/mediaEndpoints.d.ts +18 -0
- package/dist/Components/Notifications/Toast.vue.d.ts +17 -0
- package/dist/Components/Notifications/useToast.d.ts +43 -0
- package/dist/Components/Relations/RelationManager.vue.d.ts +232 -0
- package/dist/Components/Sections/FieldsSection.vue.d.ts +56 -0
- package/dist/Components/Sections/FilesSection.vue.d.ts +120 -0
- package/dist/Components/Sections/FormSection.vue.d.ts +111 -0
- package/dist/Components/Sections/InfoSection.vue.d.ts +77 -0
- package/dist/Components/Sections/Section.vue.d.ts +67 -0
- package/dist/Components/Table/ColumnToggle.vue.d.ts +42 -0
- package/dist/Components/Table/ExportButton.vue.d.ts +93 -0
- package/dist/Components/Table/SchemaCell.d.ts +50 -0
- package/dist/Components/Table/SchemaFilterPanel.vue.d.ts +64 -0
- package/dist/Components/Table/SchemaTable.vue.d.ts +255 -0
- package/dist/Components/Table/Table.vue.d.ts +232 -0
- package/dist/Components/Table/TableEmptyState.vue.d.ts +24 -0
- package/dist/Components/Table/TablePagination.vue.d.ts +71 -0
- package/dist/Components/Table/TableSkeletonRows.vue.d.ts +52 -0
- package/dist/Components/Table/TableSortButton.vue.d.ts +38 -0
- package/dist/Components/Table/TableToolbar.vue.d.ts +62 -0
- package/dist/Components/Table/schemaLinks.d.ts +16 -0
- package/dist/Components/Table/tableSchema.d.ts +243 -0
- package/dist/Components/Table/tableTypes.d.ts +13 -0
- package/dist/Components/Table/useSchemaActions.d.ts +82 -0
- package/dist/Components/Table/useSchemaFilters.d.ts +28 -0
- package/dist/Components/Table/useTableExport.d.ts +16 -0
- package/dist/Components/Table/useTableGrouping.d.ts +9 -0
- package/dist/Components/Table/useTableRowFocus.d.ts +27 -0
- package/dist/Components/Table/useTableSelection.d.ts +15 -0
- package/dist/Components/Table/useTableTree.d.ts +23 -0
- package/dist/Components/Widgets/StatCard.vue.d.ts +81 -0
- package/dist/Components/Widgets/StatsWidget.vue.d.ts +27 -0
- package/dist/Components/Wizard/Wizard.vue.d.ts +99 -0
- package/dist/Composables/useAsyncData.d.ts +48 -0
- package/dist/Composables/useDeleteConfirmation.d.ts +86 -0
- package/dist/Composables/useDragReorder.d.ts +12 -0
- package/dist/Composables/useFieldSaver.d.ts +15 -0
- package/dist/Composables/useFocusTrap.d.ts +5 -0
- package/dist/Composables/useLocalStoragePersistence.d.ts +18 -0
- package/dist/Composables/useNestedDrawerForm.d.ts +58 -0
- package/dist/Composables/usePagination.d.ts +23 -0
- package/dist/Composables/usePendingKeys.d.ts +30 -0
- package/dist/Composables/useQuery.d.ts +61 -0
- package/dist/Composables/useReconciled.d.ts +22 -0
- package/dist/Composables/useUnsavedChangesWarning.d.ts +16 -0
- package/dist/Primitives/hideOthers.d.ts +12 -0
- package/dist/Primitives/teleportTarget.d.ts +2 -0
- package/dist/Primitives/useAnchoredPosition.d.ts +32 -0
- package/dist/Primitives/useArrowNavigation.d.ts +30 -0
- package/dist/Primitives/useBodyScrollLock.d.ts +9 -0
- package/dist/Primitives/useDismissableLayer.d.ts +46 -0
- package/dist/Primitives/useId.d.ts +13 -0
- package/dist/Primitives/useTypeahead.d.ts +11 -0
- package/dist/Utils/apiFetch.d.ts +28 -0
- package/dist/Utils/csrf.d.ts +2 -0
- package/dist/Utils/dates.d.ts +50 -0
- package/dist/Utils/moduleSingleton.d.ts +21 -0
- package/dist/Utils/optimistic.d.ts +33 -0
- package/dist/Utils/reconcile.d.ts +17 -0
- package/dist/Utils/tagsChanged.d.ts +16 -0
- package/dist/Utils/url.d.ts +40 -0
- package/dist/Utils/writeGate.d.ts +82 -0
- package/dist/index.css +1 -0
- package/dist/index.d.ts +157 -0
- package/dist/index.js +27193 -0
- package/dist/index.js.map +1 -0
- package/dist/injectionKeys.d.ts +10 -0
- package/dist/plugin.d.ts +36 -0
- package/dist/types/menu.d.ts +11 -0
- package/docs/async-writes.md +159 -0
- package/docs/custom-fields.md +71 -0
- package/docs/drawer-protocol.md +130 -0
- package/docs/overlays.md +106 -0
- package/docs/relation-fields.md +112 -0
- package/docs/roadmap.md +38 -0
- package/docs/table-schema.md +371 -0
- package/package.json +150 -0
- package/styles/buttons.css +31 -0
- package/styles/form.css +74 -0
- package/styles/index.css +15 -0
- package/styles/tokens.css +79 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { InjectionKey, Ref } from 'vue';
|
|
2
|
+
/**
|
|
3
|
+
* Typed provide/inject keys — the analog of Solid's typed createContext.
|
|
4
|
+
*
|
|
5
|
+
* Using a typed Symbol key instead of a bare string gives inject() the right
|
|
6
|
+
* type automatically and makes the provider/consumer contract greppable and
|
|
7
|
+
* collision-proof.
|
|
8
|
+
*/
|
|
9
|
+
/** Whether the app sidebar is collapsed. Provided by AppLayout, read by sidebars. */
|
|
10
|
+
export declare const SidebarCollapsedKey: InjectionKey<Ref<boolean>>;
|
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { App, Component } from 'vue';
|
|
2
|
+
import { MediaEndpoints } from './Components/Media/mediaEndpoints';
|
|
3
|
+
export interface CreatePanelOptions {
|
|
4
|
+
/** Mount path of the panel backend, e.g. '/panel' or '/admin'. */
|
|
5
|
+
baseUrl?: string;
|
|
6
|
+
/** Extra icons for <Icon>, name → component. Override built-ins by name. */
|
|
7
|
+
icons?: Record<string, Component>;
|
|
8
|
+
/** App-specific blueprint field types, type → async component loader. */
|
|
9
|
+
fields?: Record<string, () => Promise<Component | {
|
|
10
|
+
default: Component;
|
|
11
|
+
}>>;
|
|
12
|
+
/**
|
|
13
|
+
* Where overlays (dialogs, drawers, dropdowns) are teleported.
|
|
14
|
+
* Defaults to 'body'; set it when overlays must live inside a themed or
|
|
15
|
+
* transformed subtree instead.
|
|
16
|
+
*/
|
|
17
|
+
teleportTarget?: string | HTMLElement;
|
|
18
|
+
/**
|
|
19
|
+
* Backend paths the media picker calls, relative to `baseUrl`. Defaults to
|
|
20
|
+
* `/api/media/picker` and `/api/media/picker/albums`; override them when the
|
|
21
|
+
* app serves media under different routes.
|
|
22
|
+
*/
|
|
23
|
+
media?: Partial<MediaEndpoints>;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Vue plugin — the single required setup call for panel consumers:
|
|
27
|
+
*
|
|
28
|
+
* app.use(createPanel({
|
|
29
|
+
* baseUrl: '/panel',
|
|
30
|
+
* icons: { camera: CameraIcon },
|
|
31
|
+
* fields: { 'block-editor': () => import('./Fields/BlockEditorField.vue') },
|
|
32
|
+
* }))
|
|
33
|
+
*/
|
|
34
|
+
export declare function createPanel(options?: CreatePanelOptions): {
|
|
35
|
+
install(_app: App): void;
|
|
36
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** User-menu item rendered by TopNavigation's dropdown. */
|
|
2
|
+
export interface MenuItem {
|
|
3
|
+
label: string;
|
|
4
|
+
href?: string;
|
|
5
|
+
icon?: string;
|
|
6
|
+
divider?: boolean;
|
|
7
|
+
method?: 'get' | 'post' | 'put' | 'patch' | 'delete';
|
|
8
|
+
action?: () => void;
|
|
9
|
+
badge?: string | number;
|
|
10
|
+
badgeColor?: 'primary' | 'success' | 'danger' | 'warning';
|
|
11
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# Async writes: ordering, staleness and in-flight state
|
|
2
|
+
|
|
3
|
+
Admin panels do a lot of small writes — a toggle in a row, a debounced text
|
|
4
|
+
field, an optimistic star. Two of them overlapping is normal, and the
|
|
5
|
+
failure modes are quiet: no error, just the wrong value left on screen.
|
|
6
|
+
|
|
7
|
+
These primitives exist for that. All are exported from the package root.
|
|
8
|
+
|
|
9
|
+
## The rule they share
|
|
10
|
+
|
|
11
|
+
> The order that matters is the order requests were **dispatched** in, not
|
|
12
|
+
> the order responses **settled** in.
|
|
13
|
+
|
|
14
|
+
A slow first request must never overwrite what a faster second one already
|
|
15
|
+
applied. Nothing here retries or queues; they only decide which result is
|
|
16
|
+
allowed to land.
|
|
17
|
+
|
|
18
|
+
## `writeGate` — deciding which write wins
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { writeGate, writeKey, LOCAL_WRITE } from '@modufolio/panel'
|
|
22
|
+
|
|
23
|
+
const key = writeKey('users', user.id, 'is_active') // "users/42/is_active"
|
|
24
|
+
|
|
25
|
+
const stamp = writeGate.next(true) // take a sequence number
|
|
26
|
+
// … dispatch the request …
|
|
27
|
+
if (writeGate.admit(key, stamp)) {
|
|
28
|
+
// still the newest write for this key — safe to apply
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
| Member | Purpose |
|
|
33
|
+
|---|---|
|
|
34
|
+
| `next(record: boolean)` | Take the next sequence number. Call at **dispatch**, not on settle. |
|
|
35
|
+
| `admit(key, stamp)` | May this write land? Recording stamps also book the key. |
|
|
36
|
+
| `seal(key)` | The record is gone server-side — reject every write still in flight for it. |
|
|
37
|
+
| `clear()` | Drop all bookkeeping. Intended for tests. |
|
|
38
|
+
| `writeKey(resource, id, field?)` | One spelling for keys, so call sites agree on identity. |
|
|
39
|
+
| `LOCAL_WRITE` | A write with no dispatch order — seeding state, a value that never went over the wire. Always admitted, records nothing. |
|
|
40
|
+
|
|
41
|
+
Three details that are load-bearing:
|
|
42
|
+
|
|
43
|
+
- **Only mutating requests should record** (`next(true)`). A read that
|
|
44
|
+
recorded its number would gate out a save dispatched before it. Pass
|
|
45
|
+
`next(false)` for reads, or to *ask* whether your write is still newest
|
|
46
|
+
without moving the gate.
|
|
47
|
+
- **`LOCAL_WRITE` is a value you name, not a parameter you omit.** The
|
|
48
|
+
un-ordered case should be a decision at the call site.
|
|
49
|
+
- **`seal()` takes a fresh number**, not the delete's own. A delete becomes
|
|
50
|
+
true when it *settles*; anything still in flight at that moment is dead
|
|
51
|
+
data and must not re-create the record.
|
|
52
|
+
|
|
53
|
+
The counter is global and monotonic; comparison is per key. A per-key
|
|
54
|
+
counter restarting at zero would let a number minted for one key match a
|
|
55
|
+
later slot's — which is how a deleted record comes back to life.
|
|
56
|
+
|
|
57
|
+
## `optimistic()` — apply now, roll back if refused
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { optimistic, writeKey } from '@modufolio/panel'
|
|
61
|
+
|
|
62
|
+
const ok = await optimistic(
|
|
63
|
+
() => { // apply, returning a rollback
|
|
64
|
+
const previous = item.starred
|
|
65
|
+
item.starred = !previous
|
|
66
|
+
return () => { item.starred = previous }
|
|
67
|
+
},
|
|
68
|
+
() => apiFetch(url, { method: 'PATCH', body: { starred: item.starred } }),
|
|
69
|
+
writeKey('items', item.id, 'starred'), // optional, but see below
|
|
70
|
+
)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Resolves `true` on success, `false` if the request failed (already rolled
|
|
74
|
+
back).
|
|
75
|
+
|
|
76
|
+
**Pass a key whenever the same state can be mutated twice concurrently.**
|
|
77
|
+
Without one, two quick toggles interleave so that the *first* failing rolls
|
|
78
|
+
state back to before either ran — silently erasing the second toggle, whose
|
|
79
|
+
request succeeded. With a key, a failure only rolls back while its own write
|
|
80
|
+
is still the newest.
|
|
81
|
+
|
|
82
|
+
Bulk actions that span many records are the honest exception: one key cannot
|
|
83
|
+
represent them, so leave it off.
|
|
84
|
+
|
|
85
|
+
## `useFieldSaver()` — debounced saves with status
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
const { saveStatus, save, debouncedSave } = useFieldSaver(
|
|
89
|
+
(value: string) => apiFetch(url, { method: 'PATCH', body: { title: value } }),
|
|
90
|
+
)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`saveStatus` is `null | 'saving' | 'saved' | 'error'`. Ordering is built in:
|
|
94
|
+
a slow save settling after a faster later one cannot report its stale
|
|
95
|
+
outcome in either direction — no stale `error` over a newer success, no
|
|
96
|
+
stale `saved` hiding a newer failure. Timers are cleared on scope dispose.
|
|
97
|
+
|
|
98
|
+
## `usePendingKeys()` — which row is busy
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
const { isPending, anyPending, run } = usePendingKeys()
|
|
102
|
+
|
|
103
|
+
const toggle = (row) => run(row.id, () => apiFetch(/* … */))
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
```vue
|
|
107
|
+
<Spinner v-if="isPending(row.id)" />
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
One boolean per component answers "is anything saving"; a list needs "is
|
|
111
|
+
*this row* saving", or every row spins together. `run` also guards
|
|
112
|
+
re-entry — a key already in flight refuses a second call and resolves
|
|
113
|
+
`undefined`, which is what a double-click should mean.
|
|
114
|
+
|
|
115
|
+
## `useUnsavedChangesWarning()` — don't discard edits silently
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
const { allowNextNavigation } = useUnsavedChangesWarning(form)
|
|
119
|
+
|
|
120
|
+
function submit() {
|
|
121
|
+
allowNextNavigation() // this navigation *is* the save
|
|
122
|
+
form.post(url)
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Hooks Inertia's `before` event, so it covers every ordinary visit —
|
|
127
|
+
including logout, which is a POST visit like any other — plus `beforeunload`
|
|
128
|
+
for tab closes.
|
|
129
|
+
|
|
130
|
+
Takes Inertia's form object, or **a getter** for anything else that knows
|
|
131
|
+
whether it is dirty:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
useUnsavedChangesWarning(() => draft.isDirty.value || other.isDirty.value)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The getter is read at event time, never captured, so state that flips after
|
|
138
|
+
setup is still seen. `allowNextNavigation()` skips exactly one prompt and
|
|
139
|
+
re-arms, so a save that leaves the form dirty does not open a hole.
|
|
140
|
+
|
|
141
|
+
## `moduleSingleton()` — state that survives a duplicated module
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
const state = moduleSingleton('toast-store', () => ({
|
|
145
|
+
items: ref([]),
|
|
146
|
+
nextId: 0,
|
|
147
|
+
}))
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
A bundler can instantiate the same module twice — Vite's dep pre-bundler,
|
|
151
|
+
for example, inlines a package's TS while leaving its `.vue` SFCs as
|
|
152
|
+
external raw-source imports. A store imported from both sides of that
|
|
153
|
+
boundary splits: one copy is written, the other is rendered, and nothing
|
|
154
|
+
appears. It fails silently, which is what makes it expensive to find.
|
|
155
|
+
|
|
156
|
+
Keying the state off `globalThis` with `Symbol.for` hands every copy the
|
|
157
|
+
same object. Use it **only** for process-wide state. Anything scoped to a
|
|
158
|
+
component or an app instance belongs in provide/inject — a global would leak
|
|
159
|
+
across SSR requests.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Custom field types
|
|
2
|
+
|
|
3
|
+
Blueprint forms resolve field components through a registry. Built-ins cover
|
|
4
|
+
the standard inputs (`text`, `textarea`, `select`, `multiselect`, `toggle`,
|
|
5
|
+
`checkbox`, `date`, `date-range`, `file`, `color`, `belongs-to`, `repeater`,
|
|
6
|
+
`toggle-buttons`). Anything app-specific is registered at boot:
|
|
7
|
+
|
|
8
|
+
```js
|
|
9
|
+
createPanel({
|
|
10
|
+
fields: {
|
|
11
|
+
'writer': () => import('@/Fields/WriterField.vue'),
|
|
12
|
+
'block-editor': () => import('@/Fields/BlockEditorField.vue'),
|
|
13
|
+
},
|
|
14
|
+
})
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
or imperatively:
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
import { registerFieldType } from '@modufolio/panel'
|
|
21
|
+
registerFieldType('signature', () => import('./SignatureField.vue'))
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
A field component that needs the resolver itself (a container type
|
|
25
|
+
rendering its own sub-fields) should import it from `fieldRegistry`, not
|
|
26
|
+
from `useBlueprint` — the composable imports the registry too, and going
|
|
27
|
+
back the other way re-creates a cycle between a composable and a component.
|
|
28
|
+
|
|
29
|
+
Then use the type in any blueprint:
|
|
30
|
+
|
|
31
|
+
```js
|
|
32
|
+
const fields = [
|
|
33
|
+
{ type: 'signature', key: 'signature', label: 'Sign here' },
|
|
34
|
+
]
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Field component contract
|
|
38
|
+
|
|
39
|
+
A field component receives (via `useBlueprint`'s `fieldProps`):
|
|
40
|
+
|
|
41
|
+
| Prop | Type | Notes |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| `modelValue` | any | v-model value for the field's key |
|
|
44
|
+
| `label` | string | |
|
|
45
|
+
| `width` | string | grid width (`full`, `1/2`, ...) — use `useFieldWidth` |
|
|
46
|
+
| `required` | boolean | |
|
|
47
|
+
| `error` | string | validation message ('' when valid) |
|
|
48
|
+
| `help`, `placeholder` | string? | only when set in the FieldDef |
|
|
49
|
+
| `options` | OptionItem[]? | for choice-style fields |
|
|
50
|
+
|
|
51
|
+
plus anything passed through the FieldDef's `props` object. Emit
|
|
52
|
+
`update:modelValue` with the new value.
|
|
53
|
+
|
|
54
|
+
### Container fields
|
|
55
|
+
|
|
56
|
+
A field holding a list of rows (a repeater, a matrix) also receives:
|
|
57
|
+
|
|
58
|
+
| Prop | Type | Notes |
|
|
59
|
+
|---|---|---|
|
|
60
|
+
| `fields` | FieldDef[] | the child's own field declarations |
|
|
61
|
+
| `nestedErrors` | Record<string, string> | row-scoped messages keyed `{index}.{subKey}`, the container's own prefix already stripped |
|
|
62
|
+
|
|
63
|
+
Render sub-fields through `resolveFieldComponent` so custom types work
|
|
64
|
+
inside containers too. See [relation-fields.md](relation-fields.md) for the
|
|
65
|
+
server side of that contract.
|
|
66
|
+
|
|
67
|
+
Registering an existing name (e.g. `'file'`) **overrides** the built-in —
|
|
68
|
+
that's the supported way to swap in, say, a resumable-upload field.
|
|
69
|
+
|
|
70
|
+
Unknown types throw at resolve time with the list of known types, so typos
|
|
71
|
+
fail loudly rather than rendering nothing.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Drawer Stack — server/client protocol
|
|
2
|
+
|
|
3
|
+
The Drawer components render a stack of overlay panels driven by ordinary
|
|
4
|
+
Inertia visits ("URL = state"). Any backend can drive them by honoring this
|
|
5
|
+
contract; the reference implementation is appkit-portfolio's
|
|
6
|
+
`src/Inertia/DrawerStack.php`.
|
|
7
|
+
|
|
8
|
+
## Request side
|
|
9
|
+
|
|
10
|
+
Every visit initiated from inside a drawer (DrawerLink, useDrawerStack
|
|
11
|
+
navigation, Drawer record-pagination) carries the header:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
X-Inertia-Drawer-Stack: 1
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The server uses its presence to decide to render the *underlying page* with a
|
|
18
|
+
drawer stack on top, instead of a full page swap.
|
|
19
|
+
|
|
20
|
+
## Response side
|
|
21
|
+
|
|
22
|
+
The page component receives a **`stack`** prop: an ordered array of stack
|
|
23
|
+
items, bottom-most first. Each item:
|
|
24
|
+
|
|
25
|
+
| Field | Type | Meaning |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| `type` | string | Which drawer content to render — the client maps types to components (consumer-defined) |
|
|
28
|
+
| `data` | object | Payload for that drawer (the record, options, ...) |
|
|
29
|
+
| `title` | string | Header title |
|
|
30
|
+
| `description` | string | Optional header subtitle |
|
|
31
|
+
| `width` | string | `sm` \| `md` \| `lg` \| ... (Drawer width presets) |
|
|
32
|
+
| `href` | string | Canonical URL of this drawer (used when re-opening / deep-linking) |
|
|
33
|
+
| `nextRecordUrl` | string\|null | Optional record-pagination target (renders next/prev arrows) |
|
|
34
|
+
| `previousRecordUrl` | string\|null | Optional record-pagination target |
|
|
35
|
+
| `tabs` | array\|null | Optional sections for the drawer body — key, label, count, and per-type extras (`fields`, `sections`, `primary`, `empty`, …) |
|
|
36
|
+
| `presentation` | `'drawer'` \| `'dialog'` | Which frame renders this item. Absent means `drawer` — see [Two frames](#two-frames) |
|
|
37
|
+
|
|
38
|
+
An empty array (or absent prop) means no drawers are open.
|
|
39
|
+
|
|
40
|
+
## Two frames
|
|
41
|
+
|
|
42
|
+
A drawer is a side panel you read a record in and can page through. A dialog is
|
|
43
|
+
a centred modal for one decision or one short form. They are the **same stack**:
|
|
44
|
+
same header, same URL-is-state contract, same per-type slots — a frame declares
|
|
45
|
+
which one it wants and nothing else changes.
|
|
46
|
+
|
|
47
|
+
```php
|
|
48
|
+
DrawerStack::create()->push(
|
|
49
|
+
type: 'publish',
|
|
50
|
+
data: [...],
|
|
51
|
+
title: 'Publish post',
|
|
52
|
+
href: '/panel/posts/{uuid}/publish',
|
|
53
|
+
width: 'sm',
|
|
54
|
+
presentation: DrawerStack::AS_DIALOG,
|
|
55
|
+
);
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```vue
|
|
59
|
+
<DrawerStack :stack="stack" base-url="/panel/posts">
|
|
60
|
+
<template #publish="{ item }">…the dialog body…</template>
|
|
61
|
+
<template #footer-publish="{ item }">…its buttons…</template>
|
|
62
|
+
</DrawerStack>
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
A second protocol for dialogs would have been a second set of things to get
|
|
66
|
+
subtly wrong, and it would have bought nothing: because a dialog is a stack
|
|
67
|
+
item, it is addressable, deep-linkable and closed by navigating away — which a
|
|
68
|
+
dialog held in page state is not. `Dialog` draws its own overlay, so the
|
|
69
|
+
stack's shared overlay stands down when no item in the stack is a drawer.
|
|
70
|
+
|
|
71
|
+
**Frames are pointers, which is the point.** A row action can carry a dialog
|
|
72
|
+
URL (`RowAction::dialog()`) instead of naming a behaviour the table has to
|
|
73
|
+
implement, so a new interaction is a new route rather than a new case in the
|
|
74
|
+
client. See [table-schema.md](../../../docs/table-schema.md#actions).
|
|
75
|
+
|
|
76
|
+
## Client components
|
|
77
|
+
|
|
78
|
+
- `<DrawerStack :stack="stack" :base-url="...">` — renders the items;
|
|
79
|
+
`baseUrl` is where closing the whole stack navigates (Inertia visit).
|
|
80
|
+
Provides push/pop/close via `provide/inject` (`useDrawerStackContext`).
|
|
81
|
+
- `<DrawerLink href="...">` — opens a URL as a new drawer on the stack
|
|
82
|
+
(adds the header, keeps the underlying page).
|
|
83
|
+
- `useDrawerPage()` — inside drawer content: access the current item's
|
|
84
|
+
`data` with page-like ergonomics.
|
|
85
|
+
- `useIsDrawer()` — is this component rendering inside a drawer?
|
|
86
|
+
- `useFocusedStackRow(props, type, records)` — index of the listing row the
|
|
87
|
+
top stack item is showing, or -1. `SchemaTable` uses this internally when
|
|
88
|
+
given a `stack` prop, so the highlighted row follows record pagination;
|
|
89
|
+
reach for the composable directly only outside `SchemaTable`.
|
|
90
|
+
|
|
91
|
+
## Never hand-write the visit
|
|
92
|
+
|
|
93
|
+
The request side is three things at once — the header, `only: ['stack']`, and
|
|
94
|
+
the preserve flags that keep the page underneath mounted. Get one third wrong
|
|
95
|
+
and it still *looks* right: fetching only the stack renders a drawer even when
|
|
96
|
+
the server never took the drawer path. A hand-written `X-Drawer` header did
|
|
97
|
+
exactly that, and survived a manual check.
|
|
98
|
+
|
|
99
|
+
So there is one way to navigate the stack, and every caller uses it:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import { visitDrawer } from '@modufolio/panel'
|
|
103
|
+
|
|
104
|
+
visitDrawer('/panel/movies/42', { queryParams: { sort: 'title' } })
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`DrawerLink`, `Drawer`'s record pagination, `useDrawerStack` and `SchemaTable`'s
|
|
108
|
+
declared `drawer` action all route through it; `visit-drawer.spec.ts` asserts
|
|
109
|
+
the trio so a fifth caller cannot quietly drop a third of it. Reach for
|
|
110
|
+
`drawerVisitOptions()` only when Inertia needs the options themselves (a form
|
|
111
|
+
submission rather than a navigation).
|
|
112
|
+
|
|
113
|
+
`queryParams` keeps empty strings — the server distinguishes "this filter is
|
|
114
|
+
set to nothing" from "this filter was not sent", and dropping them silently
|
|
115
|
+
reset filters whenever a drawer opened.
|
|
116
|
+
|
|
117
|
+
## Server responsibilities (checklist for a new backend)
|
|
118
|
+
|
|
119
|
+
1. Detect the `X-Inertia-Drawer-Stack` header.
|
|
120
|
+
2. For drawer requests, resolve the *base* page (the URL under the drawer)
|
|
121
|
+
and its props as usual.
|
|
122
|
+
3. Build the stack array (deep-linkable: a fresh GET with a drawer URL should
|
|
123
|
+
reconstruct the same stack) and pass it as the `stack` prop.
|
|
124
|
+
4. Set `presentation` on any item that should render as a dialog; omit it for
|
|
125
|
+
drawers.
|
|
126
|
+
5. Closing is plain navigation — no special endpoint needed.
|
|
127
|
+
|
|
128
|
+
A dialog route is an ordinary route: it renders the *listing* underneath with
|
|
129
|
+
one extra frame on top. `PostController::publishDialog()` is the reference —
|
|
130
|
+
it differs from `show()` by one argument.
|
package/docs/overlays.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Overlay primitives
|
|
2
|
+
|
|
3
|
+
Dialogs, drawers, dropdowns and popovers in this package share a small set of
|
|
4
|
+
primitives in `src/Primitives/` rather than each carrying its own copy of the
|
|
5
|
+
behaviour. They are exported, so an application can build its own overlays on
|
|
6
|
+
the same foundations.
|
|
7
|
+
|
|
8
|
+
## The layer stack — `useDismissableLayer`
|
|
9
|
+
|
|
10
|
+
Every open overlay registers itself while it is open. Escape and outside
|
|
11
|
+
presses go to the **top of the stack only**.
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
useDismissableLayer(isOpen, {
|
|
15
|
+
// Everything that counts as "inside": a teleported panel and the trigger
|
|
16
|
+
// that opened it live in different parts of the DOM.
|
|
17
|
+
elements: () => [triggerRef.value, panelRef.value],
|
|
18
|
+
onDismiss: (reason) => { isOpen.value = false },
|
|
19
|
+
dismissOnOutsidePointer: true,
|
|
20
|
+
// Supplying this marks the layer modal: the rest of the page is hidden from
|
|
21
|
+
// assistive technology while it is on top.
|
|
22
|
+
modalElement: () => panelRef.value,
|
|
23
|
+
})
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The layer decides what to do with a dismissal, so a non-closable dialog can
|
|
27
|
+
ignore Escape without the press falling through to the drawer beneath it.
|
|
28
|
+
|
|
29
|
+
`modalElement` exists because `aria-modal="true"` is not reliably honoured: a
|
|
30
|
+
screen reader can otherwise walk out of an open dialog and read a page the user
|
|
31
|
+
cannot reach. The page behind is marked `inert` rather than `aria-hidden` —
|
|
32
|
+
the browser refuses the latter over the focused element ("Blocked aria-hidden
|
|
33
|
+
on an element because its descendant retained focus"), while `inert` moves
|
|
34
|
+
focus out on its own and takes the page out of the tab order too. Only the
|
|
35
|
+
highest modal layer applies, and the other registered layers are exempt — so a
|
|
36
|
+
dropdown opened inside a dialog is not silenced by the dialog containing it,
|
|
37
|
+
and a toast region keeps announcing.
|
|
38
|
+
|
|
39
|
+
**An overlay the host app renders itself must register too.** A raw
|
|
40
|
+
`<Teleport to="body">` panel is invisible to the stack: Escape closes the
|
|
41
|
+
drawer beneath it instead of the panel, and that drawer's focus trap reaches
|
|
42
|
+
into it — which shows up as a combobox inside the panel closing the instant it
|
|
43
|
+
opens. Registering it is the whole fix:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
const panelRef = ref<HTMLElement | null>(null)
|
|
47
|
+
|
|
48
|
+
useDismissableLayer(() => isVisible.value, {
|
|
49
|
+
elements: () => [panelRef.value],
|
|
50
|
+
onDismiss: (reason) => { if (reason === 'escape') close() },
|
|
51
|
+
dismissOnOutsidePointer: false, // the panel draws its own scrim
|
|
52
|
+
modalElement: () => panelRef.value,
|
|
53
|
+
})
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Scroll lock — `useBodyScrollLock`
|
|
57
|
+
|
|
58
|
+
Reference counted across every overlay. The page scrolls again only once the
|
|
59
|
+
last holder releases, so a dialog opened from inside a drawer cannot unlock the
|
|
60
|
+
page while the drawer is still covering it. It also compensates for the
|
|
61
|
+
scrollbar's width, so the page behind does not jump sideways, and exposes that
|
|
62
|
+
width as `--panel-scrollbar-width`.
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
const locked = useBodyScrollLock(props.isOpen)
|
|
66
|
+
watch(() => props.isOpen, (open) => { locked.value = open })
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Positioning — `useAnchoredPosition`
|
|
70
|
+
|
|
71
|
+
A thin wrapper over `@floating-ui/vue`: flipping, viewport clamping, optional
|
|
72
|
+
width matching and height capping, and — through `autoUpdate` — following the
|
|
73
|
+
anchor through scrolls and resizes without per-component listeners.
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
const { floatingStyles } = useAnchoredPosition(triggerRef, panelRef, isOpen, {
|
|
77
|
+
placement: 'bottom-end',
|
|
78
|
+
matchWidth: true,
|
|
79
|
+
})
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The available height is applied as `max-height` and published as
|
|
83
|
+
`--panel-available-height` for panels that scroll an inner region instead.
|
|
84
|
+
|
|
85
|
+
## Keyboard — `useArrowNavigation`, `useTypeahead`
|
|
86
|
+
|
|
87
|
+
`useArrowNavigation(container)` gives a menu the WAI-ARIA keyboard model:
|
|
88
|
+
arrows move focus between items, Home/End jump to the ends, and typing jumps to
|
|
89
|
+
an item by name. `resolveNavigationIndex()` is the same key model as a pure
|
|
90
|
+
function, for lists driven by a highlighted index — a combobox, where focus
|
|
91
|
+
stays in the text field and `aria-activedescendant` points at the row.
|
|
92
|
+
|
|
93
|
+
## Ids — `useId`
|
|
94
|
+
|
|
95
|
+
Wraps Vue's own `useId()`, which is stable across a server render and its
|
|
96
|
+
hydration. Ids count per application, so two components in the same app always
|
|
97
|
+
differ; two independently created apps restart the sequence.
|
|
98
|
+
|
|
99
|
+
## Where overlays render — `teleportTarget`
|
|
100
|
+
|
|
101
|
+
Overlays teleport to `body` by default. An application whose overlays must live
|
|
102
|
+
inside a themed or transformed subtree sets it once:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
app.use(createPanel({ teleportTarget: '#panel-overlays' }))
|
|
106
|
+
```
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Relation fields: lookups and repeaters
|
|
2
|
+
|
|
3
|
+
Two field types talk to the server while the form is open: `belongs-to`
|
|
4
|
+
(pick one related record) and `repeater` (edit a list of child rows). Both
|
|
5
|
+
work from props the backend sends, so a page declares nothing.
|
|
6
|
+
|
|
7
|
+
## `belongs-to` — a lookup
|
|
8
|
+
|
|
9
|
+
Small relations ship their whole option list and behave like a select.
|
|
10
|
+
Larger ones ship no options and a `searchUrl` instead:
|
|
11
|
+
|
|
12
|
+
```js
|
|
13
|
+
{
|
|
14
|
+
type: 'belongs-to',
|
|
15
|
+
key: 'author_id',
|
|
16
|
+
label: 'Author',
|
|
17
|
+
props: {
|
|
18
|
+
searchUrl: '/panel/posts/relations/author_id',
|
|
19
|
+
valueKey: 'value',
|
|
20
|
+
labelKey: 'label',
|
|
21
|
+
clearable: true, // offer a way back to "no selection"
|
|
22
|
+
allowCreate: true, // offer "Create …" for an unmatched name
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### Endpoint contract
|
|
28
|
+
|
|
29
|
+
One path answers three things:
|
|
30
|
+
|
|
31
|
+
| Request | Returns |
|
|
32
|
+
|---|---|
|
|
33
|
+
| `GET ?q=term` | `{ data: [{ value, label }], meta: { total, limit, truncated } }` |
|
|
34
|
+
| `GET ?values=id[,id]` | `{ data: [...] }` — labels for identifiers the field already holds |
|
|
35
|
+
| `POST { label }` | `{ data: { value, label } }` — create from a typed name |
|
|
36
|
+
|
|
37
|
+
`?values=` exists because a searchable field arrives with an identifier but
|
|
38
|
+
no label: the list it came from was never sent, so the control asks what to
|
|
39
|
+
call its current value before rendering.
|
|
40
|
+
|
|
41
|
+
`meta.truncated` is rendered as a visible notice. A silently capped list
|
|
42
|
+
reads as "that is everything", which is how someone concludes a record does
|
|
43
|
+
not exist.
|
|
44
|
+
|
|
45
|
+
### Behaviour worth knowing
|
|
46
|
+
|
|
47
|
+
**The input always names what is actually held.** Typing filters the list,
|
|
48
|
+
but typed-and-never-chosen text is not a selection: dismissing the dropdown
|
|
49
|
+
(Escape, blur, clicking away) reverts the input to the held value's label.
|
|
50
|
+
Without that, the display and the value disagree and the next submit proves
|
|
51
|
+
it. Emptying the input still means deselect.
|
|
52
|
+
|
|
53
|
+
**`allowCreate` is an offer, not a permission.** The row is hidden when the
|
|
54
|
+
exact name is already listed, and the `POST` must re-check server-side that
|
|
55
|
+
the target really can be created from a label alone — a forged offer buys
|
|
56
|
+
nothing. A refusal is shown verbatim under the field. Without a `searchUrl`,
|
|
57
|
+
the field emits a `create` event instead for the page to handle.
|
|
58
|
+
|
|
59
|
+
## `repeater` — child rows
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
{
|
|
63
|
+
type: 'repeater',
|
|
64
|
+
key: 'lines',
|
|
65
|
+
label: 'Lines',
|
|
66
|
+
fields: [ // the child's own fields
|
|
67
|
+
{ key: 'item_id', type: 'belongs-to', label: 'Item', width: '1/2' },
|
|
68
|
+
{ key: 'quantity', type: 'text', label: 'Quantity', width: '1/2',
|
|
69
|
+
props: { type: 'number', step: '0.001' } },
|
|
70
|
+
],
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Rows are plain objects keyed by the sub-field keys, plus an `id` for rows
|
|
75
|
+
that already exist. **A row with no `id` is new** — that is how the server
|
|
76
|
+
tells create from update, and how it knows which rows were removed.
|
|
77
|
+
|
|
78
|
+
Sub-fields render through the same registry as top-level fields, so a
|
|
79
|
+
`belongs-to` inside a row behaves exactly like one outside it (including
|
|
80
|
+
its own `searchUrl`, usually addressed with a dotted path such as
|
|
81
|
+
`lines.item_id`).
|
|
82
|
+
|
|
83
|
+
## Row-addressed errors
|
|
84
|
+
|
|
85
|
+
Server validation for a child row comes back under a dotted key:
|
|
86
|
+
|
|
87
|
+
```json
|
|
88
|
+
{ "errors": { "lines.1.quantity": "Another row already uses this." } }
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`BlueprintForm` fans those out to the container field as `nestedErrors`,
|
|
92
|
+
keyed relative to it (`1.quantity`), and the repeater pins each message to
|
|
93
|
+
its row. A container field type that wants this only has to accept a
|
|
94
|
+
`nestedErrors` prop — see [custom-fields.md](custom-fields.md).
|
|
95
|
+
|
|
96
|
+
Two lifetime rules, both deliberate:
|
|
97
|
+
|
|
98
|
+
- **Editing the container clears its row messages.** Any edit may have been
|
|
99
|
+
the fix, and a stale message pinned to a reordered list points at the
|
|
100
|
+
wrong row.
|
|
101
|
+
- **A fresh batch of server errors resets what counts as "already edited".**
|
|
102
|
+
Server errors only show on untouched fields — but every field the user
|
|
103
|
+
filled in before submitting is touched, so without this reset the server's
|
|
104
|
+
answer *about that edit* would be suppressed on arrival. After the message
|
|
105
|
+
appears, editing again hides it: that edit is a response to it.
|
|
106
|
+
|
|
107
|
+
## Field widths
|
|
108
|
+
|
|
109
|
+
Sub-fields honour the same `width` values as top-level fields (`1/4`,
|
|
110
|
+
`1/3`, `1/2`, `2/3`, `3/4`, `full`) against a twelve-column grid, so a
|
|
111
|
+
two-field row reads as halves and a three-field row as thirds. Past three,
|
|
112
|
+
columns get too narrow for a label and each field takes its own row.
|
package/docs/roadmap.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
|
|
2
|
+
### Correction: the panel package always had tests (2026-08-25)
|
|
3
|
+
|
|
4
|
+
Several entries above claim the panel package had "zero tests" and count a
|
|
5
|
+
package suite growing from nothing. **That was wrong.** The package has its
|
|
6
|
+
own `tests/` directory and its own `vitest.config.ts`, and has carried 32
|
|
7
|
+
spec files / 479 tests throughout — including `useQuery.spec.ts`,
|
|
8
|
+
`useAsyncData.spec.ts`, `reconcile.spec.ts` and `optimistic.spec.ts`, the
|
|
9
|
+
very modules described here as untested.
|
|
10
|
+
|
|
11
|
+
Two scoped-wrong checks produced the error and agreed with each other: a
|
|
12
|
+
`find src -name "*.test.ts"` that never looked outside `src/`, and a root
|
|
13
|
+
`vitest run` whose `include` only covered `assets/**`. Neither would have
|
|
14
|
+
caught it; running the package's own suite would have.
|
|
15
|
+
|
|
16
|
+
Consequences cleaned up:
|
|
17
|
+
|
|
18
|
+
- The duplicate specs written on that premise (`useQuery`, `useAsyncData`,
|
|
19
|
+
`reconcile`) are deleted — the existing ones cover the same ground, down
|
|
20
|
+
to the invalidation-coalescing case.
|
|
21
|
+
- Genuinely new specs (writeGate, moduleSingleton, usePendingKeys,
|
|
22
|
+
useFieldSaver ordering, optimistic ordering, unsaved-changes, belongs-to
|
|
23
|
+
revert and create, blueprint row errors) moved from `src/` into `tests/`
|
|
24
|
+
where the package's own runner sees them: 42 files, 530 tests.
|
|
25
|
+
- The root `vitest.config.ts` include added for the misplaced files is
|
|
26
|
+
reverted.
|
|
27
|
+
|
|
28
|
+
**And the consolidation found a real bug.** Adding files changed timing
|
|
29
|
+
enough to expose an intermittently failing pre-existing spec: `useQuery`
|
|
30
|
+
skipped a revalidation when a subscriber arrived in the *same millisecond*
|
|
31
|
+
as the previous fetch, because `Date.now() - updatedAt > staleTime` reads as
|
|
32
|
+
"still fresh" at zero elapsed — so the default `staleTime: 0` did not
|
|
33
|
+
reliably mean "always revalidate". Now `>=`. Three consecutive full runs
|
|
34
|
+
green.
|
|
35
|
+
|
|
36
|
+
The claim to keep from those entries is narrower but still true: the package
|
|
37
|
+
had **no component (`.vue`) mounting tests** before this work, and now has
|
|
38
|
+
them for BlueprintForm and BelongsToSelect.
|