@stonecrop/desktop 0.30.0 → 0.32.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.
@@ -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>