@stonecrop/desktop 0.31.0 → 0.33.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/README.md +39 -31
- package/dist/assets/index.css +410 -0
- package/dist/desktop.js +950 -833
- package/dist/desktop.js.map +1 -1
- package/dist/src/components/ActionSet.vue.d.ts +12 -0
- package/dist/src/components/ActionSet.vue.d.ts.map +1 -0
- package/dist/src/components/CommandPalette.vue.d.ts +35 -0
- package/dist/src/components/CommandPalette.vue.d.ts.map +1 -0
- package/dist/src/components/Desktop.vue.d.ts +36 -0
- package/dist/src/components/Desktop.vue.d.ts.map +1 -0
- package/dist/src/components/SheetNav.vue.d.ts +20 -0
- package/dist/src/components/SheetNav.vue.d.ts.map +1 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/sheet-nav-toolbar.d.ts +5 -0
- package/dist/src/sheet-nav-toolbar.d.ts.map +1 -0
- package/dist/{tsdoc-metadata.json → src/tsdoc-metadata.json} +1 -1
- package/package.json +37 -31
- package/dist/desktop.css +0 -1
- package/dist/desktop.d.ts +0 -119
- package/dist/desktop.tsbuildinfo +0 -1
- package/dist/src/index.js +0 -6
- package/dist/src/plugins/index.js +0 -17
- package/dist/src/types/index.js +0 -0
- package/src/components/ActionSet.vue +0 -211
- package/src/components/CommandPalette.vue +0 -235
- package/src/components/Desktop.vue +0 -982
- package/src/components/SheetNav.vue +0 -297
- package/src/index.ts +0 -9
- package/src/plugins/index.ts +0 -21
- package/src/shims-vue.d.ts +0 -5
- package/src/types/index.ts +0 -107
|
@@ -1,982 +0,0 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<div class="desktop" @click="handleClick">
|
|
3
|
-
<!-- Action Set -->
|
|
4
|
-
<ActionSet :elements="actionElements" @action-click="handleActionClick" />
|
|
5
|
-
|
|
6
|
-
<!-- Main content using AForm -->
|
|
7
|
-
<AForm
|
|
8
|
-
v-if="currentViewSchema.length > 0"
|
|
9
|
-
v-model:data="currentViewData"
|
|
10
|
-
:schema="currentViewSchema"
|
|
11
|
-
:errors="fieldErrors" />
|
|
12
|
-
<div v-else-if="!stonecrop" class="loading"><p>Initializing Stonecrop...</p></div>
|
|
13
|
-
<div v-else class="loading">
|
|
14
|
-
<p>Loading {{ currentView }} data...</p>
|
|
15
|
-
</div>
|
|
16
|
-
|
|
17
|
-
<!-- Sheet Navigation -->
|
|
18
|
-
<SheetNav :breadcrumbs="navigationBreadcrumbs" />
|
|
19
|
-
|
|
20
|
-
<!-- Command Palette -->
|
|
21
|
-
<CommandPalette
|
|
22
|
-
:is-open="commandPaletteOpen"
|
|
23
|
-
:search="searchCommands"
|
|
24
|
-
placeholder="Type a command or search..."
|
|
25
|
-
@select="executeCommand"
|
|
26
|
-
@close="commandPaletteOpen = false">
|
|
27
|
-
<template #title="{ result }">
|
|
28
|
-
{{ result.title }}
|
|
29
|
-
</template>
|
|
30
|
-
<template #content="{ result }">
|
|
31
|
-
{{ result.description }}
|
|
32
|
-
</template>
|
|
33
|
-
</CommandPalette>
|
|
34
|
-
</div>
|
|
35
|
-
</template>
|
|
36
|
-
|
|
37
|
-
<script setup lang="ts">
|
|
38
|
-
// The draft segment comes from @stonecrop/stonecrop rather than being spelled out here: that
|
|
39
|
-
// package guards fetching, field initialization and workflow readiness on the same question, and
|
|
40
|
-
// when the two were written separately they disagreed and every guard over there went dead.
|
|
41
|
-
import { DRAFT_RECORD_ID, isDraftRecordId, useStonecrop, useValidationStore } from '@stonecrop/stonecrop'
|
|
42
|
-
import {
|
|
43
|
-
AForm,
|
|
44
|
-
type AFormLinkNavigator,
|
|
45
|
-
resolvedFieldsToColumns,
|
|
46
|
-
type ResolvedField,
|
|
47
|
-
type ResolvedTable,
|
|
48
|
-
} from '@stonecrop/aform'
|
|
49
|
-
import { computed, onMounted, onUnmounted, provide, ref, unref, watch } from 'vue'
|
|
50
|
-
|
|
51
|
-
import ActionSet from './ActionSet.vue'
|
|
52
|
-
import SheetNav from './SheetNav.vue'
|
|
53
|
-
import CommandPalette from './CommandPalette.vue'
|
|
54
|
-
import type {
|
|
55
|
-
ActionElements,
|
|
56
|
-
RouteAdapter,
|
|
57
|
-
NavigationTarget,
|
|
58
|
-
ActionEventPayload,
|
|
59
|
-
RecordOpenEventPayload,
|
|
60
|
-
LoadRecordsEventPayload,
|
|
61
|
-
LoadRecordEventPayload,
|
|
62
|
-
} from '../types'
|
|
63
|
-
|
|
64
|
-
const { availableDoctypes = [], routeAdapter } = defineProps<{
|
|
65
|
-
availableDoctypes?: string[]
|
|
66
|
-
/**
|
|
67
|
-
* Pluggable router adapter. When provided, Desktop uses these functions for all
|
|
68
|
-
* routing instead of reaching into the registry's internal Vue Router instance.
|
|
69
|
-
* Nuxt hosts (or any host with custom route conventions) should supply this.
|
|
70
|
-
*/
|
|
71
|
-
routeAdapter?: RouteAdapter
|
|
72
|
-
}>()
|
|
73
|
-
|
|
74
|
-
const emit = defineEmits<{
|
|
75
|
-
/**
|
|
76
|
-
* Fired when the user triggers an FSM transition (action button click).
|
|
77
|
-
* The host app is responsible for calling the server, persisting state, etc.
|
|
78
|
-
*/
|
|
79
|
-
action: [payload: ActionEventPayload]
|
|
80
|
-
/**
|
|
81
|
-
* Fired when Desktop wants to navigate to a different view.
|
|
82
|
-
* Also calls routeAdapter.navigate() if an adapter is provided.
|
|
83
|
-
*/
|
|
84
|
-
navigate: [target: NavigationTarget]
|
|
85
|
-
/**
|
|
86
|
-
* Fired when the user opens a specific record.
|
|
87
|
-
*/
|
|
88
|
-
'record:open': [payload: RecordOpenEventPayload]
|
|
89
|
-
/**
|
|
90
|
-
* Fired when Desktop is about to read records for a list view. A notification, not a request:
|
|
91
|
-
* Desktop performs the read itself through `Stonecrop.getRecords`. A host that fetches here
|
|
92
|
-
* races that read into the same HST key.
|
|
93
|
-
*/
|
|
94
|
-
'load-records': [payload: LoadRecordsEventPayload]
|
|
95
|
-
/**
|
|
96
|
-
* Fired when Desktop is about to read a single record for a form view. A notification, not a
|
|
97
|
-
* request — see `load-records`. Not emitted for a draft, which has nothing to fetch.
|
|
98
|
-
*/
|
|
99
|
-
'load-record': [payload: LoadRecordEventPayload]
|
|
100
|
-
}>()
|
|
101
|
-
|
|
102
|
-
const { stonecrop } = useStonecrop()
|
|
103
|
-
|
|
104
|
-
// Field-validation store (advisory, client-side). Pinia is a declared peerDependency, kept external
|
|
105
|
-
// in this package's build (rollupOptions), so `useValidationStore()` resolves the host app's single
|
|
106
|
-
// active Pinia directly — the old `getCurrentInstance().$pinia` workaround for the bundled-Pinia bug
|
|
107
|
-
// is no longer needed. Still guarded: validation is optional and Desktop predates it, so a host that
|
|
108
|
-
// mounts Desktop without Pinia disables validation gracefully instead of crashing on mount.
|
|
109
|
-
let validationStore: ReturnType<typeof useValidationStore> | null = null
|
|
110
|
-
try {
|
|
111
|
-
validationStore = useValidationStore()
|
|
112
|
-
} catch {
|
|
113
|
-
validationStore = null
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Inline field errors handed to AForm. Only surfaced in the record form view (currentView is
|
|
117
|
-
// defined below); empty elsewhere so a previous record's errors never bleed into a list view.
|
|
118
|
-
const fieldErrors = computed<Record<string, string[]>>(() =>
|
|
119
|
-
currentView.value === 'record' ? (validationStore?.errorsByField ?? {}) : {}
|
|
120
|
-
)
|
|
121
|
-
|
|
122
|
-
// State
|
|
123
|
-
const loading = ref(false)
|
|
124
|
-
const commandPaletteOpen = ref(false)
|
|
125
|
-
|
|
126
|
-
// The record being composed on a `/{doctype}/new` route. Deliberately not in HST: a draft has no
|
|
127
|
-
// identity to be keyed by, and both ways of faking one fail — see `DRAFT_RECORD_ID`.
|
|
128
|
-
const draftRecord = ref<Record<string, any>>({})
|
|
129
|
-
|
|
130
|
-
// Form/list data management — each view produces a different data shape.
|
|
131
|
-
// List views (doctypes, records) return table row data keyed by fieldname.
|
|
132
|
-
// Record view returns the record's fields for two-way binding — from HST, or from `draftRecord`
|
|
133
|
-
// when the route is a draft.
|
|
134
|
-
const currentViewData = computed<Record<string, any>>({
|
|
135
|
-
get() {
|
|
136
|
-
// Doctypes list — rows come from availableDoctypes prop (reactive via availableDoctypes)
|
|
137
|
-
if (currentView.value === 'doctypes') {
|
|
138
|
-
return {
|
|
139
|
-
doctypes_table:
|
|
140
|
-
availableDoctypes?.map(doctype => ({
|
|
141
|
-
id: doctype,
|
|
142
|
-
doctype,
|
|
143
|
-
display_name: formatDoctypeName(doctype),
|
|
144
|
-
actions: 'View Records',
|
|
145
|
-
})) ?? [],
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Records list — rows come from HST store (reactive because HST is Vue reactive())
|
|
150
|
-
if (currentView.value === 'records') {
|
|
151
|
-
return {
|
|
152
|
-
records_table: getRecords().map(record =>
|
|
153
|
-
Object.assign({}, record, {
|
|
154
|
-
id: resolveRecordId(record) ?? '',
|
|
155
|
-
// A list row is navigation. Actions live on the record view, where the Actions
|
|
156
|
-
// dropdown is built from what the doctype declares and what the record's
|
|
157
|
-
// current state allows. This cell used to also offer Delete, which dispatched
|
|
158
|
-
// an action named `DELETE` that Desktop invented — no doctype in a
|
|
159
|
-
// WorkflowMeta app declares it, so it failed on every click. Removal is a
|
|
160
|
-
// workflow outcome (`archive`, `cancel`) and belongs in the doctype.
|
|
161
|
-
actions: 'Edit',
|
|
162
|
-
})
|
|
163
|
-
),
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// Record form — read single record from HST
|
|
168
|
-
if (!stonecrop.value || !currentDoctype.value || !currentRecordId.value) {
|
|
169
|
-
return {}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
try {
|
|
173
|
-
const source = isNewRecord.value
|
|
174
|
-
? draftRecord.value
|
|
175
|
-
: (stonecrop.value.getRecordById(currentDoctype.value, currentRecordId.value)?.get('') as
|
|
176
|
-
Record<string, any> | undefined)
|
|
177
|
-
// Return a plain shallow copy so AForm mutations don't propagate directly into
|
|
178
|
-
// the HST reactive object, which would bypass field-trigger diffing and cause
|
|
179
|
-
// setupDeepReactivity to fire triggers for all fields on every keystroke.
|
|
180
|
-
const flat: Record<string, any> = { ...source }
|
|
181
|
-
|
|
182
|
-
// AFieldset receives data[fieldsetFieldname] as its data prop, so the fieldset's
|
|
183
|
-
// children must be grouped under the fieldset key. The server returns flat SQL rows,
|
|
184
|
-
// so we nest fieldset children here before AForm renders them.
|
|
185
|
-
const doctype = stonecrop.value.registry.registry[currentDoctype.value]
|
|
186
|
-
if (doctype) {
|
|
187
|
-
for (const field of doctype.getSchemaArray()) {
|
|
188
|
-
if (field.kind === 'fieldset') {
|
|
189
|
-
const nested: Record<string, any> = {}
|
|
190
|
-
for (const child of field.schema) {
|
|
191
|
-
if (child.fieldname) nested[child.fieldname] = flat[child.fieldname]
|
|
192
|
-
}
|
|
193
|
-
flat[field.fieldname] = nested
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
return flat
|
|
198
|
-
} catch {
|
|
199
|
-
return {}
|
|
200
|
-
}
|
|
201
|
-
},
|
|
202
|
-
set(newData: Record<string, any>) {
|
|
203
|
-
// List views are read-only from AForm's perspective — HST writes only apply to record form
|
|
204
|
-
if (currentView.value !== 'record') return
|
|
205
|
-
if (!stonecrop.value || !currentDoctype.value || !currentRecordId.value) {
|
|
206
|
-
return
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
try {
|
|
210
|
-
// AForm emits nested data for fieldsets: { fieldsetKey: { childA: val } }.
|
|
211
|
-
// HST and the server both expect flat rows, so flatten fieldset values back before writing.
|
|
212
|
-
const doctype = stonecrop.value.registry.registry[currentDoctype.value]
|
|
213
|
-
const fieldsetNames = new Set<string>()
|
|
214
|
-
if (doctype) {
|
|
215
|
-
for (const field of doctype.getSchemaArray()) {
|
|
216
|
-
if (field.kind === 'fieldset') {
|
|
217
|
-
fieldsetNames.add(field.fieldname)
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
// Two-pass flatten: non-fieldset keys first, then fieldset children.
|
|
222
|
-
// Fieldset children must be applied last — AForm may emit stale flat copies
|
|
223
|
-
// of fieldset children alongside the updated nested value, and the nested
|
|
224
|
-
// value must win regardless of key insertion order.
|
|
225
|
-
const flatData: Record<string, any> = {}
|
|
226
|
-
const fieldsetValues: Record<string, any>[] = []
|
|
227
|
-
for (const [key, value] of Object.entries(newData)) {
|
|
228
|
-
if (fieldsetNames.has(key) && value && typeof value === 'object' && !Array.isArray(value)) {
|
|
229
|
-
fieldsetValues.push(value)
|
|
230
|
-
} else {
|
|
231
|
-
flatData[key] = value
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
for (const nestedValue of fieldsetValues) {
|
|
235
|
-
Object.assign(flatData, nestedValue)
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// Only update fields that actually changed. Never write undefined — AForm may emit
|
|
239
|
-
// schema fields absent from the record as undefined; writing them would silently
|
|
240
|
-
// clear values that exist. Explicit null is allowed (intentional clear).
|
|
241
|
-
const changedFields: string[] = []
|
|
242
|
-
if (isNewRecord.value) {
|
|
243
|
-
// Reassigned, not mutated, so the getter re-runs. Relying on in-place mutation of the
|
|
244
|
-
// cached object is what made a draft's edits vanish on any invalidation.
|
|
245
|
-
const next = { ...draftRecord.value }
|
|
246
|
-
for (const [fieldname, value] of Object.entries(flatData)) {
|
|
247
|
-
if (value === undefined) continue
|
|
248
|
-
if (next[fieldname] !== value) {
|
|
249
|
-
next[fieldname] = value
|
|
250
|
-
changedFields.push(fieldname)
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
draftRecord.value = next
|
|
254
|
-
} else {
|
|
255
|
-
const hstStore = stonecrop.value.getStore()
|
|
256
|
-
for (const [fieldname, value] of Object.entries(flatData)) {
|
|
257
|
-
if (value === undefined) continue
|
|
258
|
-
const fieldPath = `${currentDoctype.value}.${currentRecordId.value}.${fieldname}`
|
|
259
|
-
const currentValue = hstStore.has(fieldPath) ? hstStore.get(fieldPath) : undefined
|
|
260
|
-
if (currentValue !== value) {
|
|
261
|
-
hstStore.set(fieldPath, value)
|
|
262
|
-
changedFields.push(fieldname)
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// Advisory field-validation runs on the fields that actually changed (honors each
|
|
268
|
-
// trigger's `on` set). Driven here, after the HST writes, so HST stays value-only.
|
|
269
|
-
if (changedFields.length > 0) {
|
|
270
|
-
driveFieldValidation(changedFields)
|
|
271
|
-
}
|
|
272
|
-
} catch (error) {
|
|
273
|
-
console.warn('HST update failed:', error)
|
|
274
|
-
}
|
|
275
|
-
},
|
|
276
|
-
})
|
|
277
|
-
|
|
278
|
-
// Advisory field-validation: run the doctype's triggers for the fields that changed on this edit.
|
|
279
|
-
// Snapshots the post-edit record as a plain, flat object so validators read siblings read-only
|
|
280
|
-
// (the core store freezes it). Fire-and-forget — the reactive error store repaints AForm on resolve.
|
|
281
|
-
function driveFieldValidation(changedFields: string[]) {
|
|
282
|
-
if (!validationStore || !stonecrop.value || !currentDoctype.value || !currentRecordId.value) return
|
|
283
|
-
|
|
284
|
-
const doctype = stonecrop.value.registry.getDoctype(currentDoctype.value)
|
|
285
|
-
const triggers = doctype?.getTriggers()
|
|
286
|
-
if (!triggers || Object.keys(triggers).length === 0) return
|
|
287
|
-
|
|
288
|
-
// A draft's siblings come from the buffer; reading HST would hand every validator an empty record.
|
|
289
|
-
const record = isNewRecord.value
|
|
290
|
-
? { ...draftRecord.value }
|
|
291
|
-
: {
|
|
292
|
-
...(stonecrop.value.getRecordById(currentDoctype.value, currentRecordId.value)?.get('') as Record<
|
|
293
|
-
string,
|
|
294
|
-
unknown
|
|
295
|
-
>),
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
for (const field of changedFields) {
|
|
299
|
-
void validationStore.validateField(triggers, field, record)
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// Computed properties for current route context.
|
|
304
|
-
// When a routeAdapter is provided it takes full precedence over the registry's internal router.
|
|
305
|
-
const route = computed(() => (routeAdapter ? null : unref(stonecrop.value?.registry.router?.currentRoute)))
|
|
306
|
-
const router = computed(() => (routeAdapter ? null : stonecrop.value?.registry.router))
|
|
307
|
-
const currentDoctype = computed(() => {
|
|
308
|
-
if (routeAdapter) return routeAdapter.getCurrentDoctype()
|
|
309
|
-
if (!route.value) return ''
|
|
310
|
-
|
|
311
|
-
// First check if we have actualDoctype in meta (from registered routes)
|
|
312
|
-
if (route.value.meta?.actualDoctype) {
|
|
313
|
-
return route.value.meta.actualDoctype as string
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
// For named routes, use params.doctype
|
|
317
|
-
if (route.value.params.doctype) {
|
|
318
|
-
return route.value.params.doctype.toString()
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// For catch-all routes that haven't been registered yet, extract from path
|
|
322
|
-
const pathMatch = route.value.params.pathMatch as string[] | undefined
|
|
323
|
-
if (pathMatch && pathMatch.length > 0) {
|
|
324
|
-
return pathMatch[0]
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
return ''
|
|
328
|
-
})
|
|
329
|
-
|
|
330
|
-
// The route doctype for display and navigation (e.g., 'todo')
|
|
331
|
-
const routeDoctype = computed(() => {
|
|
332
|
-
if (routeAdapter) return routeAdapter.getCurrentDoctype()
|
|
333
|
-
if (!route.value) return ''
|
|
334
|
-
|
|
335
|
-
// Check route meta first
|
|
336
|
-
if (route.value.meta?.doctype) {
|
|
337
|
-
return route.value.meta.doctype as string
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// For named routes, use params.doctype
|
|
341
|
-
if (route.value.params.doctype) {
|
|
342
|
-
return route.value.params.doctype.toString()
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
// For catch-all routes, extract from path
|
|
346
|
-
const pathMatch = route.value.params.pathMatch as string[] | undefined
|
|
347
|
-
if (pathMatch && pathMatch.length > 0) {
|
|
348
|
-
return pathMatch[0]
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
return ''
|
|
352
|
-
})
|
|
353
|
-
|
|
354
|
-
const currentRecordId = computed(() => {
|
|
355
|
-
if (routeAdapter) return routeAdapter.getCurrentRecordId()
|
|
356
|
-
if (!route.value) return ''
|
|
357
|
-
|
|
358
|
-
// For named routes, use params.recordId
|
|
359
|
-
if (route.value.params.recordId) {
|
|
360
|
-
return route.value.params.recordId.toString()
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// For catch-all routes that haven't been registered yet, extract from path
|
|
364
|
-
const pathMatch = route.value.params.pathMatch as string[] | undefined
|
|
365
|
-
if (pathMatch && pathMatch.length > 1) {
|
|
366
|
-
return pathMatch[1]
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
return ''
|
|
370
|
-
})
|
|
371
|
-
const isNewRecord = computed(() => isDraftRecordId(currentRecordId.value))
|
|
372
|
-
|
|
373
|
-
// Determine current view based on route
|
|
374
|
-
const currentView = computed(() => {
|
|
375
|
-
if (routeAdapter) return routeAdapter.getCurrentView()
|
|
376
|
-
if (!route.value) {
|
|
377
|
-
return 'doctypes'
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
// Home route
|
|
381
|
-
if (route.value.name === 'home' || route.value.path === '/') {
|
|
382
|
-
return 'doctypes'
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
// Named routes from registered doctypes
|
|
386
|
-
if (route.value.name && route.value.name !== 'catch-all') {
|
|
387
|
-
const routeName = route.value.name as string
|
|
388
|
-
if (routeName.includes('form') || route.value.params.recordId) {
|
|
389
|
-
return 'record'
|
|
390
|
-
} else if (routeName.includes('list') || route.value.params.doctype) {
|
|
391
|
-
return 'records'
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// Catch-all route - determine from path structure
|
|
396
|
-
const pathMatch = route.value.params.pathMatch as string[] | undefined
|
|
397
|
-
if (pathMatch && pathMatch.length > 0) {
|
|
398
|
-
const view = pathMatch.length === 1 ? 'records' : 'record'
|
|
399
|
-
return view
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
return 'doctypes'
|
|
403
|
-
})
|
|
404
|
-
|
|
405
|
-
// Computed properties (now that all helper functions are defined)
|
|
406
|
-
// Helper function to get available transitions for current record.
|
|
407
|
-
// Reads the actual FSM state from the record's `status` field (or falls back to the
|
|
408
|
-
// workflow initial state) so the available action buttons always reflect reality.
|
|
409
|
-
const getAvailableTransitions = () => {
|
|
410
|
-
if (!stonecrop.value || !currentDoctype.value || !currentRecordId.value) {
|
|
411
|
-
return []
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
try {
|
|
415
|
-
const doctype = stonecrop.value.registry.getDoctype(currentDoctype.value)
|
|
416
|
-
if (!doctype?.workflow) return []
|
|
417
|
-
|
|
418
|
-
// Delegate state resolution to Stonecrop — reads record 'status', falls back to workflow.initial
|
|
419
|
-
const currentState = stonecrop.value.getRecordState(currentDoctype.value, currentRecordId.value)
|
|
420
|
-
|
|
421
|
-
// Delegate transition lookup to Doctype — no more manual workflow introspection
|
|
422
|
-
const transitions = doctype.getAvailableTransitions(currentState)
|
|
423
|
-
|
|
424
|
-
const recordData = currentViewData.value || {}
|
|
425
|
-
|
|
426
|
-
// Each transition emits an 'action' event. The host app decides what to do
|
|
427
|
-
// (call the server, trigger an FSM actor, update HST, etc.).
|
|
428
|
-
return transitions.map(({ name }) => ({
|
|
429
|
-
// Prefer the workflow action's human-readable label (WorkflowMeta format);
|
|
430
|
-
// fall back to the raw transition name for XState workflows with no action meta.
|
|
431
|
-
label: doctype.getActionMeta(name)?.label ?? name,
|
|
432
|
-
action: () => {
|
|
433
|
-
emit('action', {
|
|
434
|
-
name,
|
|
435
|
-
doctype: currentDoctype.value,
|
|
436
|
-
recordId: currentRecordId.value,
|
|
437
|
-
data: recordData,
|
|
438
|
-
})
|
|
439
|
-
},
|
|
440
|
-
}))
|
|
441
|
-
} catch (error) {
|
|
442
|
-
console.warn('Error getting available transitions:', error)
|
|
443
|
-
return []
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
// Helper: stateless Commands available for the current record — side-effect actions that
|
|
448
|
-
// change no workflow state. Surfaced in the same Actions dropdown as transitions; each emits
|
|
449
|
-
// the same 'action' event, so the host's handler runs a Command's clientHandler identically.
|
|
450
|
-
const getAvailableCommands = () => {
|
|
451
|
-
if (!stonecrop.value || !currentDoctype.value || !currentRecordId.value) {
|
|
452
|
-
return []
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
try {
|
|
456
|
-
const doctype = stonecrop.value.registry.getDoctype(currentDoctype.value)
|
|
457
|
-
if (!doctype?.workflow) return []
|
|
458
|
-
|
|
459
|
-
const currentState = stonecrop.value.getRecordState(currentDoctype.value, currentRecordId.value)
|
|
460
|
-
const commands = doctype.getAvailableCommands(currentState)
|
|
461
|
-
const recordData = currentViewData.value || {}
|
|
462
|
-
|
|
463
|
-
return commands.map(({ name }) => ({
|
|
464
|
-
label: doctype.getActionMeta(name)?.label ?? name,
|
|
465
|
-
action: () => {
|
|
466
|
-
emit('action', {
|
|
467
|
-
name,
|
|
468
|
-
doctype: currentDoctype.value,
|
|
469
|
-
recordId: currentRecordId.value,
|
|
470
|
-
data: recordData,
|
|
471
|
-
})
|
|
472
|
-
},
|
|
473
|
-
}))
|
|
474
|
-
} catch (error) {
|
|
475
|
-
console.warn('Error getting available commands:', error)
|
|
476
|
-
return []
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
const actionElements = computed(() => {
|
|
481
|
-
const elements: ActionElements[] = []
|
|
482
|
-
|
|
483
|
-
switch (currentView.value) {
|
|
484
|
-
case 'records':
|
|
485
|
-
elements.push({
|
|
486
|
-
type: 'button',
|
|
487
|
-
label: 'New Record',
|
|
488
|
-
action: () => void createNewRecord(),
|
|
489
|
-
})
|
|
490
|
-
break
|
|
491
|
-
case 'record': {
|
|
492
|
-
// Populate the Actions dropdown with every FSM transition AND stateless Command
|
|
493
|
-
// available in the record's current state. Clicking either emits 'action'.
|
|
494
|
-
const recordActions = [...getAvailableTransitions(), ...getAvailableCommands()]
|
|
495
|
-
if (recordActions.length > 0) {
|
|
496
|
-
elements.push({
|
|
497
|
-
type: 'dropdown',
|
|
498
|
-
label: 'Actions',
|
|
499
|
-
actions: recordActions,
|
|
500
|
-
})
|
|
501
|
-
}
|
|
502
|
-
break
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
return elements
|
|
507
|
-
})
|
|
508
|
-
|
|
509
|
-
const navigationBreadcrumbs = computed(() => {
|
|
510
|
-
const breadcrumbs: { title: string; to: string }[] = []
|
|
511
|
-
|
|
512
|
-
if (currentView.value === 'records' && routeDoctype.value) {
|
|
513
|
-
breadcrumbs.push(
|
|
514
|
-
{ title: 'Home', to: '/' },
|
|
515
|
-
{ title: formatDoctypeName(routeDoctype.value), to: `/${routeDoctype.value}` }
|
|
516
|
-
)
|
|
517
|
-
} else if (currentView.value === 'record' && routeDoctype.value) {
|
|
518
|
-
const recordPath = currentRecordId.value
|
|
519
|
-
? `/${routeDoctype.value}/${currentRecordId.value}`
|
|
520
|
-
: (route.value?.fullPath ?? '')
|
|
521
|
-
breadcrumbs.push(
|
|
522
|
-
{ title: 'Home', to: '/' },
|
|
523
|
-
{ title: formatDoctypeName(routeDoctype.value), to: `/${routeDoctype.value}` },
|
|
524
|
-
{ title: isNewRecord.value ? 'New Record' : 'Edit Record', to: recordPath }
|
|
525
|
-
)
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
return breadcrumbs
|
|
529
|
-
})
|
|
530
|
-
|
|
531
|
-
// Command palette functionality
|
|
532
|
-
type Command = {
|
|
533
|
-
title: string
|
|
534
|
-
description: string
|
|
535
|
-
action: () => void
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
const searchCommands = (query: string): Command[] => {
|
|
539
|
-
const commands: Command[] = [
|
|
540
|
-
{
|
|
541
|
-
title: 'Go Home',
|
|
542
|
-
description: 'Navigate to the home page',
|
|
543
|
-
action: () => void doNavigate({ view: 'doctypes' }),
|
|
544
|
-
},
|
|
545
|
-
{
|
|
546
|
-
title: 'Toggle Command Palette',
|
|
547
|
-
description: 'Open/close the command palette',
|
|
548
|
-
action: () => (commandPaletteOpen.value = !commandPaletteOpen.value),
|
|
549
|
-
},
|
|
550
|
-
]
|
|
551
|
-
|
|
552
|
-
// Add doctype-specific commands
|
|
553
|
-
if (routeDoctype.value) {
|
|
554
|
-
commands.push({
|
|
555
|
-
title: `View ${formatDoctypeName(routeDoctype.value)} Records`,
|
|
556
|
-
description: `Navigate to ${routeDoctype.value} list`,
|
|
557
|
-
action: () => void doNavigate({ view: 'records', doctype: routeDoctype.value }),
|
|
558
|
-
})
|
|
559
|
-
|
|
560
|
-
commands.push({
|
|
561
|
-
title: `Create New ${formatDoctypeName(routeDoctype.value)}`,
|
|
562
|
-
description: `Create a new ${routeDoctype.value} record`,
|
|
563
|
-
action: () => void createNewRecord(),
|
|
564
|
-
})
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
// Add available doctypes as commands
|
|
568
|
-
availableDoctypes.forEach(doctype => {
|
|
569
|
-
commands.push({
|
|
570
|
-
title: `View ${formatDoctypeName(doctype)}`,
|
|
571
|
-
description: `Navigate to ${doctype} list`,
|
|
572
|
-
action: () => void doNavigate({ view: 'records', doctype }),
|
|
573
|
-
})
|
|
574
|
-
})
|
|
575
|
-
|
|
576
|
-
// Filter commands based on query
|
|
577
|
-
if (!query) return commands
|
|
578
|
-
|
|
579
|
-
return commands.filter(
|
|
580
|
-
cmd =>
|
|
581
|
-
cmd.title.toLowerCase().includes(query.toLowerCase()) ||
|
|
582
|
-
cmd.description.toLowerCase().includes(query.toLowerCase())
|
|
583
|
-
)
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
const executeCommand = (command: Command) => {
|
|
587
|
-
command.action()
|
|
588
|
-
commandPaletteOpen.value = false
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
// List reads are wired on the records table; ATable owns the fetch via getRecords.
|
|
592
|
-
const listRecordsFetcher = (options?: import('@stonecrop/schema').GetRecordsOptions) => {
|
|
593
|
-
if (!stonecrop.value || !currentDoctype.value) {
|
|
594
|
-
return Promise.resolve({ data: [], hasMore: false })
|
|
595
|
-
}
|
|
596
|
-
const doctype = stonecrop.value.registry.getDoctype(currentDoctype.value)
|
|
597
|
-
if (!doctype) {
|
|
598
|
-
return Promise.resolve({ data: [], hasMore: false })
|
|
599
|
-
}
|
|
600
|
-
return stonecrop.value.getRecords(doctype, options)
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
// Helper functions - moved here to avoid "before initialization" errors
|
|
604
|
-
const formatDoctypeName = (doctype: string): string => {
|
|
605
|
-
return doctype
|
|
606
|
-
.split('-')
|
|
607
|
-
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
|
608
|
-
.join(' ')
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
// Internal navigation helper: emits 'navigate', then calls the adapter (if any)
|
|
612
|
-
// or falls back to the registry's Vue Router instance.
|
|
613
|
-
const doNavigate = async (target: NavigationTarget) => {
|
|
614
|
-
emit('navigate', target)
|
|
615
|
-
if (routeAdapter) {
|
|
616
|
-
await routeAdapter.navigate(target)
|
|
617
|
-
} else {
|
|
618
|
-
if (target.view === 'doctypes') {
|
|
619
|
-
await router.value?.push('/')
|
|
620
|
-
} else if (target.view === 'records' && target.doctype) {
|
|
621
|
-
await router.value?.push(`/${target.doctype}`)
|
|
622
|
-
} else if (target.view === 'record' && target.doctype && target.recordId) {
|
|
623
|
-
await router.value?.push(`/${target.doctype}/${target.recordId}`)
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
const navigateToDoctype = async (doctype: string) => {
|
|
629
|
-
await doNavigate({ view: 'records', doctype })
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
const openRecord = async (recordId: string) => {
|
|
633
|
-
const doctype = routeDoctype.value
|
|
634
|
-
emit('record:open', { doctype, recordId })
|
|
635
|
-
await doNavigate({ view: 'record', doctype, recordId })
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
const createNewRecord = async () => {
|
|
639
|
-
await doNavigate({ view: 'record', doctype: routeDoctype.value, recordId: DRAFT_RECORD_ID })
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
// Schema generator functions - moved here to be available to computed properties
|
|
643
|
-
const getDoctypesSchema = (): ResolvedField[] => {
|
|
644
|
-
if (!availableDoctypes?.length) return []
|
|
645
|
-
|
|
646
|
-
return [
|
|
647
|
-
{
|
|
648
|
-
kind: 'table' as const,
|
|
649
|
-
fieldname: 'doctypes_table',
|
|
650
|
-
component: 'ATable',
|
|
651
|
-
label: 'Doctypes',
|
|
652
|
-
schema: [
|
|
653
|
-
{
|
|
654
|
-
fieldname: 'doctype',
|
|
655
|
-
label: 'Doctype',
|
|
656
|
-
component: 'ATextInput',
|
|
657
|
-
align: 'left' as const,
|
|
658
|
-
edit: false,
|
|
659
|
-
width: '20ch',
|
|
660
|
-
},
|
|
661
|
-
{
|
|
662
|
-
fieldname: 'display_name',
|
|
663
|
-
label: 'Name',
|
|
664
|
-
component: 'ATextInput',
|
|
665
|
-
align: 'left' as const,
|
|
666
|
-
edit: false,
|
|
667
|
-
width: '30ch',
|
|
668
|
-
},
|
|
669
|
-
// No record count column. It read `getRecordIds(doctype).length`, which is how many
|
|
670
|
-
// records HST happens to hold — zero for a doctype never opened, and the page size
|
|
671
|
-
// for one that was. The real total belongs to the backend and Desktop never asks
|
|
672
|
-
// for it, so this shell cannot answer it. Same reason the `recordIdField` prop went.
|
|
673
|
-
{
|
|
674
|
-
fieldname: 'actions',
|
|
675
|
-
label: 'Actions',
|
|
676
|
-
component: 'ATextInput',
|
|
677
|
-
align: 'center' as const,
|
|
678
|
-
edit: false,
|
|
679
|
-
width: '20ch',
|
|
680
|
-
},
|
|
681
|
-
],
|
|
682
|
-
config: { view: 'list' as const, fullWidth: true },
|
|
683
|
-
} satisfies ResolvedTable,
|
|
684
|
-
]
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
const getRecordsSchema = (): ResolvedField[] => {
|
|
688
|
-
if (!currentDoctype.value) return []
|
|
689
|
-
if (!stonecrop.value) return []
|
|
690
|
-
|
|
691
|
-
const registry = stonecrop.value.registry
|
|
692
|
-
const doctype = registry.registry[currentDoctype.value]
|
|
693
|
-
|
|
694
|
-
if (!doctype) return []
|
|
695
|
-
|
|
696
|
-
const schema = registry.resolveSchema(doctype)
|
|
697
|
-
|
|
698
|
-
// If no schema is available, let the template fallback handle the loading state
|
|
699
|
-
if (schema.length === 0) return []
|
|
700
|
-
|
|
701
|
-
const doctypeSlug = currentDoctype.value
|
|
702
|
-
const clientConfigured = Boolean(stonecrop.value.getClient())
|
|
703
|
-
|
|
704
|
-
return [
|
|
705
|
-
{
|
|
706
|
-
kind: 'table' as const,
|
|
707
|
-
fieldname: 'records_table',
|
|
708
|
-
component: 'ATable',
|
|
709
|
-
// Which resolved fields a cell can render is answered once, in @stonecrop/aform, and
|
|
710
|
-
// called by the child-table builder too. Flattening alone is not the rule: it kept the
|
|
711
|
-
// expanding links, whose value is a nested record or an array of them.
|
|
712
|
-
schema: [...resolvedFieldsToColumns(schema), { fieldname: 'actions', label: 'Actions', component: 'ATextInput' }],
|
|
713
|
-
config: { view: 'list' as const, fullWidth: true },
|
|
714
|
-
...(clientConfigured ? { getRecords: listRecordsFetcher, sourceKey: doctypeSlug } : {}),
|
|
715
|
-
} satisfies ResolvedTable,
|
|
716
|
-
]
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
const getRecordFormSchema = (): ResolvedField[] => {
|
|
720
|
-
if (!currentDoctype.value) return []
|
|
721
|
-
if (!stonecrop.value) return []
|
|
722
|
-
|
|
723
|
-
try {
|
|
724
|
-
const registry = stonecrop.value?.registry
|
|
725
|
-
const doctype = registry?.registry[currentDoctype.value]
|
|
726
|
-
|
|
727
|
-
if (!doctype?.schema) {
|
|
728
|
-
// Let the template fallback handle the loading state
|
|
729
|
-
return []
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
return registry.resolveSchema(doctype)
|
|
733
|
-
} catch {
|
|
734
|
-
return []
|
|
735
|
-
}
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
// Additional data helper functions
|
|
739
|
-
const getRecords = () => {
|
|
740
|
-
if (!stonecrop.value || !currentDoctype.value) {
|
|
741
|
-
return []
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
const recordsNode = stonecrop.value.records(currentDoctype.value)
|
|
745
|
-
const recordsData = recordsNode?.get('')
|
|
746
|
-
|
|
747
|
-
if (recordsData && typeof recordsData === 'object' && !Array.isArray(recordsData)) {
|
|
748
|
-
return Object.values(recordsData as Record<string, any>)
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
return []
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
// Schema for different views - defined here after all helper functions are available
|
|
755
|
-
const currentViewSchema = computed(() => {
|
|
756
|
-
switch (currentView.value) {
|
|
757
|
-
case 'doctypes':
|
|
758
|
-
return getDoctypesSchema()
|
|
759
|
-
case 'records':
|
|
760
|
-
return getRecordsSchema()
|
|
761
|
-
case 'record':
|
|
762
|
-
return getRecordFormSchema()
|
|
763
|
-
default:
|
|
764
|
-
return []
|
|
765
|
-
}
|
|
766
|
-
})
|
|
767
|
-
|
|
768
|
-
const handleActionClick = (_label: string, action: (() => void | Promise<void>) | undefined) => {
|
|
769
|
-
if (action) {
|
|
770
|
-
void action()
|
|
771
|
-
}
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
/**
|
|
775
|
-
* Resolve a record's identity for links and navigation.
|
|
776
|
-
*
|
|
777
|
-
* Identity is declared once, on the doctype: `primaryKey`, or `id` when nothing is declared.
|
|
778
|
-
* Delegating to `Doctype.getRecordId` is what guarantees this matches the key
|
|
779
|
-
* `Stonecrop.getRecords` stored the record under — resolving it independently here would let a
|
|
780
|
-
* row render a link to an HST path that does not exist.
|
|
781
|
-
*
|
|
782
|
-
* There is deliberately no per-shell override. Identity is a per-doctype fact and one shell
|
|
783
|
-
* renders many doctypes, so a single prop cannot answer it; a shell that named a field would
|
|
784
|
-
* also be overriding the very declaration the store keyed on, which is the bug above.
|
|
785
|
-
*/
|
|
786
|
-
const resolveRecordId = (record: Record<string, unknown>): string | undefined => {
|
|
787
|
-
if (!stonecrop.value || !currentDoctype.value) return undefined
|
|
788
|
-
return stonecrop.value.registry.registry[currentDoctype.value]?.getRecordId(record)
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
// Event handlers
|
|
792
|
-
const getRecordIdFromRow = (rowElement: HTMLTableRowElement): string | null => {
|
|
793
|
-
const cell = rowElement.querySelector('td[data-rowindex]')
|
|
794
|
-
if (!cell) return null
|
|
795
|
-
|
|
796
|
-
const rowIndexAttr = cell.getAttribute('data-rowindex')
|
|
797
|
-
if (rowIndexAttr === null) return null
|
|
798
|
-
|
|
799
|
-
const rowIndex = parseInt(rowIndexAttr, 10)
|
|
800
|
-
if (isNaN(rowIndex)) return null
|
|
801
|
-
|
|
802
|
-
const records = getRecords()
|
|
803
|
-
const record = records[rowIndex]
|
|
804
|
-
if (!record) return null
|
|
805
|
-
|
|
806
|
-
return resolveRecordId(record) ?? null
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
const handleClick = async (event: Event) => {
|
|
810
|
-
const target = event.target as HTMLElement
|
|
811
|
-
const action = target.getAttribute('data-action')
|
|
812
|
-
|
|
813
|
-
if (action === 'create') {
|
|
814
|
-
await createNewRecord()
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
const cell = target.closest('td, th')
|
|
818
|
-
if (cell) {
|
|
819
|
-
const cellText = cell.textContent?.trim()
|
|
820
|
-
const row = cell.closest('tr')
|
|
821
|
-
|
|
822
|
-
if (cellText === 'View Records' && row) {
|
|
823
|
-
// Get the doctype from the row data
|
|
824
|
-
const cells = row.querySelectorAll('td')
|
|
825
|
-
if (cells.length > 0) {
|
|
826
|
-
const doctypeCell = cells[1] // Assuming doctype is in second column (first column is index)
|
|
827
|
-
const doctype = doctypeCell.textContent?.trim()
|
|
828
|
-
if (doctype) {
|
|
829
|
-
await navigateToDoctype(doctype)
|
|
830
|
-
}
|
|
831
|
-
}
|
|
832
|
-
} else if (cellText === 'Edit' && row) {
|
|
833
|
-
// Matched exactly, not by substring. This handler is bound to the whole desktop, so a
|
|
834
|
-
// substring match fired on any cell whose *data* happened to contain the word — a task
|
|
835
|
-
// titled "Delete old backups" popped a delete confirmation when you clicked it.
|
|
836
|
-
const recordId = getRecordIdFromRow(row)
|
|
837
|
-
if (recordId) {
|
|
838
|
-
await openRecord(recordId)
|
|
839
|
-
}
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
// Reads go through Stonecrop, which owns whether to fetch, how to key the result, and where to
|
|
845
|
-
// put it. Desktop asks for data and renders what arrives; it decides none of that itself.
|
|
846
|
-
//
|
|
847
|
-
// Both loaders are no-ops without a client, so a host that populates HST some other way keeps
|
|
848
|
-
// working unchanged rather than taking a thrown error on every navigation.
|
|
849
|
-
const loadRecordData = async () => {
|
|
850
|
-
if (!stonecrop.value || !currentDoctype.value || !stonecrop.value.getClient()) return
|
|
851
|
-
|
|
852
|
-
loading.value = true
|
|
853
|
-
try {
|
|
854
|
-
await stonecrop.value.getRecord(currentDoctype.value, currentRecordId.value)
|
|
855
|
-
} catch (error) {
|
|
856
|
-
console.warn('Error fetching record:', error)
|
|
857
|
-
} finally {
|
|
858
|
-
loading.value = false
|
|
859
|
-
}
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
// Watch for route changes to load appropriate data
|
|
863
|
-
watch(
|
|
864
|
-
[currentView, currentDoctype, currentRecordId],
|
|
865
|
-
() => {
|
|
866
|
-
// The events are notifications, not fetch requests: they announce what Desktop is about to
|
|
867
|
-
// read so a host can hang analytics or a prefetch off them. The read itself is Stonecrop's.
|
|
868
|
-
if (currentView.value === 'records' && currentDoctype.value) {
|
|
869
|
-
emit('load-records', { doctype: currentDoctype.value })
|
|
870
|
-
} else if (currentView.value === 'record' && currentDoctype.value && currentRecordId.value) {
|
|
871
|
-
// A draft has nothing to fetch — the record does not exist on the server yet. Desktop
|
|
872
|
-
// used to emit anyway and leave the host to work it out, which meant every host had to
|
|
873
|
-
// recognise the private draft-id scheme above just to suppress a doomed request; all of
|
|
874
|
-
// them did, identically. `getRecord` declines the same case for the same reason.
|
|
875
|
-
if (isNewRecord.value) return
|
|
876
|
-
|
|
877
|
-
emit('load-record', { doctype: currentDoctype.value, recordId: currentRecordId.value })
|
|
878
|
-
void loadRecordData()
|
|
879
|
-
}
|
|
880
|
-
},
|
|
881
|
-
{ immediate: true }
|
|
882
|
-
)
|
|
883
|
-
|
|
884
|
-
// Clear advisory validation errors when the target record changes, so errors from a previously
|
|
885
|
-
// edited record never bleed into a newly opened one.
|
|
886
|
-
watch([currentDoctype, currentRecordId], () => {
|
|
887
|
-
validationStore?.clearAll()
|
|
888
|
-
})
|
|
889
|
-
|
|
890
|
-
// Seeding on entry gives a new record the doctype's declared defaults, which it never used to get.
|
|
891
|
-
// Discarding on exit matters as much: the draft segment is one shared literal, so a stale buffer
|
|
892
|
-
// would open the next New Record pre-filled with the abandoned one's values.
|
|
893
|
-
watch(
|
|
894
|
-
[currentDoctype, currentRecordId],
|
|
895
|
-
() => {
|
|
896
|
-
if (!isNewRecord.value) {
|
|
897
|
-
draftRecord.value = {}
|
|
898
|
-
return
|
|
899
|
-
}
|
|
900
|
-
const registry = stonecrop.value?.registry
|
|
901
|
-
draftRecord.value = registry ? registry.initializeRecord(getRecordFormSchema()) : {}
|
|
902
|
-
},
|
|
903
|
-
{ immediate: true }
|
|
904
|
-
)
|
|
905
|
-
|
|
906
|
-
// Stonecrop reactive computed properties update automatically when the instance
|
|
907
|
-
// becomes available — no manual watcher needed.
|
|
908
|
-
|
|
909
|
-
// Provide navigation helpers and an emitAction convenience function to child components.
|
|
910
|
-
const desktopMethods = {
|
|
911
|
-
navigateToDoctype,
|
|
912
|
-
openRecord,
|
|
913
|
-
createNewRecord,
|
|
914
|
-
/**
|
|
915
|
-
* Convenience wrapper so child components (e.g. slot content) can emit
|
|
916
|
-
* an action event without needing a direct reference to the emit function.
|
|
917
|
-
*/
|
|
918
|
-
emitAction: (name: string, data?: Record<string, any>) => {
|
|
919
|
-
emit('action', {
|
|
920
|
-
name,
|
|
921
|
-
doctype: currentDoctype.value,
|
|
922
|
-
recordId: currentRecordId.value,
|
|
923
|
-
data: data ?? currentViewData.value ?? {},
|
|
924
|
-
})
|
|
925
|
-
},
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
provide('desktopMethods', desktopMethods)
|
|
929
|
-
|
|
930
|
-
// Provide a navigator for AFormLink so the arrow button navigates to the linked record.
|
|
931
|
-
provide('aformLinkNavigator', {
|
|
932
|
-
navigate: (doctype: string, id: string | number) => {
|
|
933
|
-
void doNavigate({ view: 'record', doctype, recordId: String(id) })
|
|
934
|
-
},
|
|
935
|
-
} satisfies AFormLinkNavigator)
|
|
936
|
-
|
|
937
|
-
// Provide a resolver for AFormLink to look up display text by doctype + id.
|
|
938
|
-
// Checks HST first (sync); falls back to an async client fetch if not cached.
|
|
939
|
-
// Uses the target doctype's declared displayField — no heuristic field guessing.
|
|
940
|
-
provide('aformLinkResolver', async (doctypeSlug: string, id: string): Promise<string | undefined> => {
|
|
941
|
-
if (!stonecrop.value) return undefined
|
|
942
|
-
try {
|
|
943
|
-
const meta = await stonecrop.value.getMeta({ path: `/${doctypeSlug}`, segments: [doctypeSlug] })
|
|
944
|
-
const displayField = meta?.displayField
|
|
945
|
-
if (!displayField) return undefined
|
|
946
|
-
|
|
947
|
-
const readDisplay = (rec: Record<string, unknown> | undefined): string | undefined => {
|
|
948
|
-
if (!rec) return undefined
|
|
949
|
-
const val = rec[displayField]
|
|
950
|
-
return typeof val === 'string' || typeof val === 'number' ? String(val) : undefined
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
const cached = stonecrop.value.getRecordById(doctypeSlug, id)?.get('') as Record<string, unknown> | undefined
|
|
954
|
-
const cachedDisplay = readDisplay(cached)
|
|
955
|
-
if (cachedDisplay != null) return cachedDisplay
|
|
956
|
-
|
|
957
|
-
await stonecrop.value.getRecord(doctypeSlug, id)
|
|
958
|
-
const fetched = stonecrop.value.getRecordById(doctypeSlug, id)?.get('') as Record<string, unknown> | undefined
|
|
959
|
-
return readDisplay(fetched)
|
|
960
|
-
} catch {
|
|
961
|
-
return undefined
|
|
962
|
-
}
|
|
963
|
-
})
|
|
964
|
-
|
|
965
|
-
const handleKeydown = (event: KeyboardEvent) => {
|
|
966
|
-
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
|
|
967
|
-
event.preventDefault()
|
|
968
|
-
commandPaletteOpen.value = true
|
|
969
|
-
}
|
|
970
|
-
if (event.key === 'Escape' && commandPaletteOpen.value) {
|
|
971
|
-
commandPaletteOpen.value = false
|
|
972
|
-
}
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
onMounted(() => {
|
|
976
|
-
document.addEventListener('keydown', handleKeydown)
|
|
977
|
-
})
|
|
978
|
-
|
|
979
|
-
onUnmounted(() => {
|
|
980
|
-
document.removeEventListener('keydown', handleKeydown)
|
|
981
|
-
})
|
|
982
|
-
</script>
|