@stonecrop/desktop 0.4.36 → 0.5.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,8 +1,1072 @@
1
1
  <template>
2
- <router-view></router-view>
3
- <SheetNav />
2
+ <div class="desktop" @click="handleClick">
3
+ <!-- Action Set -->
4
+ <ActionSet :elements="actionElements" @action-click="handleActionClick" />
5
+
6
+ <!-- Main content using AForm -->
7
+ <AForm v-if="writableSchema.length > 0" v-model="writableSchema" :data="currentViewData" />
8
+ <div v-else-if="!stonecrop" class="loading"><p>Initializing Stonecrop...</p></div>
9
+ <div v-else class="loading">
10
+ <p>Loading {{ currentView }} data...</p>
11
+ </div>
12
+
13
+ <!-- Sheet Navigation -->
14
+ <SheetNav :breadcrumbs="navigationBreadcrumbs" />
15
+
16
+ <!-- Command Palette -->
17
+ <CommandPalette
18
+ :is-open="commandPaletteOpen"
19
+ :search="searchCommands"
20
+ placeholder="Type a command or search..."
21
+ @select="executeCommand"
22
+ @close="commandPaletteOpen = false">
23
+ <template #title="{ result }">
24
+ {{ result.title }}
25
+ </template>
26
+ <template #content="{ result }">
27
+ {{ result.description }}
28
+ </template>
29
+ </CommandPalette>
30
+ </div>
4
31
  </template>
5
32
 
6
33
  <script setup lang="ts">
34
+ import { useStonecrop } from '@stonecrop/stonecrop'
35
+ import { AForm, type SchemaTypes, type TableColumn, type TableConfig } from '@stonecrop/aform'
36
+ import { computed, nextTick, onMounted, provide, ref, unref, watch } from 'vue'
37
+
38
+ import ActionSet from './ActionSet.vue'
7
39
  import SheetNav from './SheetNav.vue'
40
+ import CommandPalette from './CommandPalette.vue'
41
+ import type { ActionElements } from '../types'
42
+
43
+ const { availableDoctypes = [] } = defineProps<{ availableDoctypes?: string[] }>()
44
+
45
+ const { stonecrop } = useStonecrop()
46
+
47
+ // State
48
+ const loading = ref(false)
49
+ const saving = ref(false)
50
+ const commandPaletteOpen = ref(false)
51
+
52
+ // HST-based form data management - field triggers are handled automatically by HST
53
+
54
+ // Computed property that reads from HST store for reactive form data
55
+ const currentViewData = computed<Record<string, any>>({
56
+ get() {
57
+ if (!stonecrop.value || !currentDoctype.value || !currentRecordId.value) {
58
+ return {}
59
+ }
60
+
61
+ try {
62
+ const record = stonecrop.value.getRecordById(currentDoctype.value, currentRecordId.value)
63
+ return record?.get('') || {}
64
+ } catch {
65
+ return {}
66
+ }
67
+ },
68
+ set(newData: Record<string, any>) {
69
+ if (!stonecrop.value || !currentDoctype.value || !currentRecordId.value) {
70
+ return
71
+ }
72
+
73
+ try {
74
+ // Update each field in HST, which will automatically trigger field actions
75
+ const hstStore = stonecrop.value.getStore()
76
+ for (const [fieldname, value] of Object.entries(newData)) {
77
+ const fieldPath = `${currentDoctype.value}.${currentRecordId.value}.${fieldname}`
78
+ hstStore.set(fieldPath, value)
79
+ }
80
+ } catch (error) {
81
+ // eslint-disable-next-line no-console
82
+ console.warn('HST update failed:', error)
83
+ }
84
+ },
85
+ })
86
+
87
+ // HST-based form data management - field triggers are handled automatically by HST
88
+
89
+ // Computed properties for current route context
90
+ const route = computed(() => unref(stonecrop.value?.registry.router?.currentRoute))
91
+ const router = computed(() => stonecrop.value?.registry.router)
92
+ const currentDoctype = computed(() => {
93
+ if (!route.value) return ''
94
+
95
+ // First check if we have actualDoctype in meta (from registered routes)
96
+ if (route.value.meta?.actualDoctype) {
97
+ return route.value.meta.actualDoctype as string
98
+ }
99
+
100
+ // For named routes, use params.doctype
101
+ if (route.value.params.doctype) {
102
+ return route.value.params.doctype as string
103
+ }
104
+
105
+ // For catch-all routes that haven't been registered yet, extract from path
106
+ const pathMatch = route.value.params.pathMatch as string[] | undefined
107
+ if (pathMatch && pathMatch.length > 0) {
108
+ return pathMatch[0]
109
+ }
110
+
111
+ return ''
112
+ })
113
+
114
+ // The route doctype for display and navigation (e.g., 'todo')
115
+ const routeDoctype = computed(() => {
116
+ if (!route.value) return ''
117
+
118
+ // Check route meta first
119
+ if (route.value.meta?.doctype) {
120
+ return route.value.meta.doctype as string
121
+ }
122
+
123
+ // For named routes, use params.doctype
124
+ if (route.value.params.doctype) {
125
+ return route.value.params.doctype as string
126
+ }
127
+
128
+ // For catch-all routes, extract from path
129
+ const pathMatch = route.value.params.pathMatch as string[] | undefined
130
+ if (pathMatch && pathMatch.length > 0) {
131
+ return pathMatch[0]
132
+ }
133
+
134
+ return ''
135
+ })
136
+
137
+ const currentRecordId = computed(() => {
138
+ if (!route.value) return ''
139
+
140
+ // For named routes, use params.recordId
141
+ if (route.value.params.recordId) {
142
+ return route.value.params.recordId as string
143
+ }
144
+
145
+ // For catch-all routes that haven't been registered yet, extract from path
146
+ const pathMatch = route.value.params.pathMatch as string[] | undefined
147
+ if (pathMatch && pathMatch.length > 1) {
148
+ return pathMatch[1]
149
+ }
150
+
151
+ return ''
152
+ })
153
+ const isNewRecord = computed(() => currentRecordId.value?.startsWith('new-'))
154
+
155
+ // Determine current view based on route
156
+ const currentView = computed(() => {
157
+ if (!route.value) {
158
+ return 'doctypes'
159
+ }
160
+
161
+ // Home route
162
+ if (route.value.name === 'home' || route.value.path === '/') {
163
+ return 'doctypes'
164
+ }
165
+
166
+ // Named routes from registered doctypes
167
+ if (route.value.name && route.value.name !== 'catch-all') {
168
+ const routeName = route.value.name as string
169
+ if (routeName.includes('form') || route.value.params.recordId) {
170
+ return 'record'
171
+ } else if (routeName.includes('list') || route.value.params.doctype) {
172
+ return 'records'
173
+ }
174
+ }
175
+
176
+ // Catch-all route - determine from path structure
177
+ const pathMatch = route.value.params.pathMatch as string[] | undefined
178
+ if (pathMatch && pathMatch.length > 0) {
179
+ const view = pathMatch.length === 1 ? 'records' : 'record'
180
+ return view
181
+ }
182
+
183
+ return 'doctypes'
184
+ })
185
+
186
+ // Computed properties (now that all helper functions are defined)
187
+ // Helper function to get available transitions for current record
188
+ const getAvailableTransitions = () => {
189
+ if (!stonecrop.value || !currentDoctype.value || !currentRecordId.value) {
190
+ return []
191
+ }
192
+
193
+ try {
194
+ const registry = stonecrop.value.registry
195
+ const meta = registry.registry[currentDoctype.value]
196
+
197
+ if (!meta?.workflow?.states) {
198
+ return []
199
+ }
200
+
201
+ // Get current FSM state (for now, use workflow initial state or 'editing')
202
+ // In a full implementation, this would track actual FSM state
203
+ const currentState = isNewRecord.value ? 'creating' : 'editing'
204
+ const stateConfig = meta.workflow.states[currentState]
205
+
206
+ if (!stateConfig?.on) {
207
+ return []
208
+ }
209
+
210
+ // Get available transitions from current state
211
+ const transitions = Object.keys(stateConfig.on)
212
+
213
+ // Create action elements for each transition
214
+ const actionElements = transitions.map(transition => {
215
+ const targetState = stateConfig.on?.[transition]
216
+ const targetStateName = typeof targetState === 'string' ? targetState : 'unknown'
217
+
218
+ const actionFn = async () => {
219
+ const node = stonecrop.value?.getRecordById(currentDoctype.value, currentRecordId.value)
220
+ if (node) {
221
+ const recordData = currentViewData.value || {}
222
+ await node.triggerTransition(transition, {
223
+ currentState,
224
+ targetState: targetStateName,
225
+ fsmContext: recordData,
226
+ })
227
+ }
228
+ }
229
+
230
+ const element = {
231
+ label: `${transition} (→ ${targetStateName})`,
232
+ action: actionFn,
233
+ }
234
+
235
+ return element
236
+ })
237
+
238
+ return actionElements
239
+ } catch (error) {
240
+ // eslint-disable-next-line no-console
241
+ console.warn('Error getting available transitions:', error)
242
+ return []
243
+ }
244
+ }
245
+
246
+ // New component reactive properties// New component reactive properties
247
+ const actionElements = computed<ActionElements[]>(() => {
248
+ const elements: ActionElements[] = []
249
+
250
+ switch (currentView.value) {
251
+ case 'doctypes':
252
+ elements.push({
253
+ type: 'button',
254
+ label: 'Refresh',
255
+ action: () => {
256
+ // Refresh doctypes
257
+ window.location.reload()
258
+ },
259
+ })
260
+ break
261
+ case 'records':
262
+ elements.push(
263
+ {
264
+ type: 'button',
265
+ label: 'New Record',
266
+ action: () => void createNewRecord(),
267
+ },
268
+ {
269
+ type: 'button',
270
+ label: 'Refresh',
271
+ action: () => {
272
+ // Refresh records
273
+ window.location.reload()
274
+ },
275
+ }
276
+ )
277
+ break
278
+ case 'record': {
279
+ // Add XState Transitions dropdown for record view
280
+ const transitionActions = getAvailableTransitions()
281
+ if (transitionActions.length > 0) {
282
+ elements.push({
283
+ type: 'dropdown',
284
+ label: 'Actions',
285
+ actions: transitionActions,
286
+ })
287
+ }
288
+ break
289
+ }
290
+ }
291
+
292
+ return elements
293
+ })
294
+
295
+ const navigationBreadcrumbs = computed(() => {
296
+ const breadcrumbs: { title: string; to: string }[] = []
297
+
298
+ if (currentView.value === 'records' && routeDoctype.value) {
299
+ breadcrumbs.push(
300
+ { title: 'Home', to: '/' },
301
+ { title: formatDoctypeName(routeDoctype.value), to: `/${routeDoctype.value}` }
302
+ )
303
+ } else if (currentView.value === 'record' && routeDoctype.value) {
304
+ breadcrumbs.push(
305
+ { title: 'Home', to: '/' },
306
+ { title: formatDoctypeName(routeDoctype.value), to: `/${routeDoctype.value}` },
307
+ { title: isNewRecord.value ? 'New Record' : 'Edit Record', to: route.value?.fullPath || '' }
308
+ )
309
+ }
310
+
311
+ return breadcrumbs
312
+ })
313
+
314
+ // Command palette functionality
315
+ type Command = {
316
+ title: string
317
+ description: string
318
+ action: () => void
319
+ }
320
+
321
+ const searchCommands = (query: string): Command[] => {
322
+ const commands: Command[] = [
323
+ {
324
+ title: 'Go Home',
325
+ description: 'Navigate to the home page',
326
+ action: () => void router.value?.push('/'),
327
+ },
328
+ {
329
+ title: 'Toggle Command Palette',
330
+ description: 'Open/close the command palette',
331
+ action: () => (commandPaletteOpen.value = !commandPaletteOpen.value),
332
+ },
333
+ ]
334
+
335
+ // Add doctype-specific commands
336
+ if (routeDoctype.value) {
337
+ commands.push({
338
+ title: `View ${formatDoctypeName(routeDoctype.value)} Records`,
339
+ description: `Navigate to ${routeDoctype.value} list`,
340
+ action: () => void router.value?.push(`/${routeDoctype.value}`),
341
+ })
342
+
343
+ commands.push({
344
+ title: `Create New ${formatDoctypeName(routeDoctype.value)}`,
345
+ description: `Create a new ${routeDoctype.value} record`,
346
+ action: () => void createNewRecord(),
347
+ })
348
+ }
349
+
350
+ // Add available doctypes as commands
351
+ availableDoctypes.forEach(doctype => {
352
+ commands.push({
353
+ title: `View ${formatDoctypeName(doctype)}`,
354
+ description: `Navigate to ${doctype} list`,
355
+ action: () => void router.value?.push(`/${doctype}`),
356
+ })
357
+ })
358
+
359
+ // Filter commands based on query
360
+ if (!query) return commands
361
+
362
+ return commands.filter(
363
+ cmd =>
364
+ cmd.title.toLowerCase().includes(query.toLowerCase()) ||
365
+ cmd.description.toLowerCase().includes(query.toLowerCase())
366
+ )
367
+ }
368
+
369
+ const executeCommand = (command: Command) => {
370
+ command.action()
371
+ commandPaletteOpen.value = false
372
+ }
373
+
374
+ // Helper functions - moved here to avoid "before initialization" errors
375
+ const formatDoctypeName = (doctype: string): string => {
376
+ return doctype
377
+ .split('-')
378
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
379
+ .join(' ')
380
+ }
381
+
382
+ const getRecordCount = (doctype: string): number => {
383
+ if (!stonecrop.value) return 0
384
+ const recordIds = stonecrop.value.getRecordIds(doctype)
385
+ return recordIds.length
386
+ }
387
+
388
+ const navigateToDoctype = async (doctype: string) => {
389
+ await router.value?.push(`/${doctype}`)
390
+ }
391
+
392
+ const openRecord = async (recordId: string) => {
393
+ await router.value?.push(`/${routeDoctype.value}/${recordId}`)
394
+ }
395
+
396
+ const createNewRecord = async () => {
397
+ const newId = `new-${Date.now()}`
398
+ await router.value?.push(`/${routeDoctype.value}/${newId}`)
399
+ }
400
+
401
+ // Doctype metadata loader - simplified since router handles most of this
402
+ const loadDoctypeMetadata = (doctype: string) => {
403
+ if (!stonecrop.value) return
404
+
405
+ // Ensure the doctype structure exists in HST
406
+ // The router should have already loaded the metadata, but this ensures the HST structure exists
407
+ try {
408
+ stonecrop.value.records(doctype)
409
+ } catch (error) {
410
+ // Silent error handling - structure will be created if needed
411
+ }
412
+ }
413
+
414
+ // Schema generator functions - moved here to be available to computed properties
415
+ const getDoctypesSchema = (): SchemaTypes[] => {
416
+ if (!availableDoctypes.length) return []
417
+
418
+ const rows = availableDoctypes.map(doctype => ({
419
+ id: doctype,
420
+ doctype,
421
+ display_name: formatDoctypeName(doctype),
422
+ record_count: getRecordCount(doctype),
423
+ actions: 'View Records',
424
+ }))
425
+
426
+ return [
427
+ {
428
+ fieldname: 'header',
429
+ component: 'div',
430
+ value: `
431
+ <div class="view-header">
432
+ <h1>Available Doctypes</h1>
433
+ </div>
434
+ `,
435
+ },
436
+ {
437
+ fieldname: 'doctypes_table',
438
+ component: 'ATable',
439
+ columns: [
440
+ {
441
+ label: 'Doctype',
442
+ name: 'doctype',
443
+ type: 'Data',
444
+ align: 'left',
445
+ edit: false,
446
+ width: '20ch',
447
+ },
448
+ {
449
+ label: 'Name',
450
+ name: 'display_name',
451
+ type: 'Data',
452
+ align: 'left',
453
+ edit: false,
454
+ width: '30ch',
455
+ },
456
+ {
457
+ label: 'Records',
458
+ name: 'record_count',
459
+ type: 'Data',
460
+ align: 'center',
461
+ edit: false,
462
+ width: '15ch',
463
+ },
464
+ {
465
+ label: 'Actions',
466
+ name: 'actions',
467
+ type: 'Data',
468
+ align: 'center',
469
+ edit: false,
470
+ width: '20ch',
471
+ },
472
+ ] as TableColumn[],
473
+ config: {
474
+ view: 'list',
475
+ fullWidth: true,
476
+ } as TableConfig,
477
+ rows,
478
+ },
479
+ ]
480
+ }
481
+
482
+ const getRecordsSchema = (): SchemaTypes[] => {
483
+ if (!currentDoctype.value) return []
484
+ if (!stonecrop.value) return []
485
+
486
+ const records = getRecords()
487
+ const columns = getColumns()
488
+
489
+ // If no columns are available, show a loading or empty state
490
+ if (columns.length === 0) {
491
+ return [
492
+ {
493
+ fieldname: 'header',
494
+ component: 'div',
495
+ value: `
496
+ <div class="view-header">
497
+ <nav class="breadcrumbs">
498
+ <a href="/">Home</a>
499
+ <span class="separator">/</span>
500
+ <span class="current">${formatDoctypeName(routeDoctype.value || currentDoctype.value)}</span>
501
+ </nav>
502
+ <h1>${formatDoctypeName(routeDoctype.value || currentDoctype.value)} Records</h1>
503
+ </div>
504
+ `,
505
+ },
506
+ {
507
+ fieldname: 'loading',
508
+ component: 'div',
509
+ value: `
510
+ <div class="loading-state">
511
+ <p>Loading ${formatDoctypeName(routeDoctype.value || currentDoctype.value)} schema...</p>
512
+ </div>
513
+ `,
514
+ },
515
+ ]
516
+ }
517
+
518
+ const rows = records.map((record: any) => ({
519
+ ...record,
520
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
521
+ id: record.id || '',
522
+ actions: 'Edit | Delete',
523
+ }))
524
+
525
+ return [
526
+ {
527
+ fieldname: 'header',
528
+ component: 'div',
529
+ value: `
530
+ <div class="view-header">
531
+ <nav class="breadcrumbs">
532
+ <a href="/">Home</a>
533
+ <span class="separator">/</span>
534
+ <span class="current">${formatDoctypeName(routeDoctype.value || currentDoctype.value)}</span>
535
+ </nav>
536
+ <h1>${formatDoctypeName(routeDoctype.value || currentDoctype.value)} Records</h1>
537
+ </div>
538
+ `,
539
+ },
540
+ {
541
+ fieldname: 'actions',
542
+ component: 'div',
543
+ value: `
544
+ <div class="view-actions">
545
+ <button class="btn-primary" data-action="create">
546
+ New ${formatDoctypeName(routeDoctype.value || currentDoctype.value)}
547
+ </button>
548
+ </div>
549
+ `,
550
+ },
551
+ ...(records.length === 0
552
+ ? [
553
+ {
554
+ fieldname: 'empty_state',
555
+ component: 'div',
556
+ value: `
557
+ <div class="empty-state">
558
+ <p>No ${routeDoctype.value || currentDoctype.value} records found.</p>
559
+ <button class="btn-primary" data-action="create">
560
+ Create First Record
561
+ </button>
562
+ </div>
563
+ `,
564
+ },
565
+ ]
566
+ : [
567
+ {
568
+ fieldname: 'records_table',
569
+ component: 'ATable',
570
+ columns: [
571
+ ...columns.map(col => ({
572
+ label: col.label,
573
+ name: col.fieldname,
574
+ type: col.fieldtype,
575
+ align: 'left',
576
+ edit: false,
577
+ width: '20ch',
578
+ })),
579
+ {
580
+ label: 'Actions',
581
+ name: 'actions',
582
+ type: 'Data',
583
+ align: 'center',
584
+ edit: false,
585
+ width: '20ch',
586
+ },
587
+ ] as TableColumn[],
588
+ config: {
589
+ view: 'list',
590
+ fullWidth: true,
591
+ } as TableConfig,
592
+ rows,
593
+ },
594
+ ]),
595
+ ]
596
+ }
597
+
598
+ const getRecordFormSchema = (): SchemaTypes[] => {
599
+ if (!currentDoctype.value) return []
600
+ if (!stonecrop.value) return []
601
+
602
+ try {
603
+ const registry = stonecrop.value?.registry
604
+ const meta = registry?.registry[currentDoctype.value]
605
+
606
+ if (!meta?.schema) {
607
+ // Return loading state if schema isn't available yet
608
+ return [
609
+ {
610
+ fieldname: 'header',
611
+ component: 'div',
612
+ value: `
613
+ <div class="view-header">
614
+ <nav class="breadcrumbs">
615
+ <a href="/">Home</a>
616
+ <span class="separator">/</span>
617
+ <a href="/${routeDoctype.value || currentDoctype.value}">${formatDoctypeName(
618
+ routeDoctype.value || currentDoctype.value
619
+ )}</a>
620
+ <span class="separator">/</span>
621
+ <span class="current">${isNewRecord.value ? 'New Record' : currentRecordId.value}</span>
622
+ </nav>
623
+ <h1>${
624
+ isNewRecord.value
625
+ ? `New ${formatDoctypeName(routeDoctype.value || currentDoctype.value)}`
626
+ : `Edit ${formatDoctypeName(routeDoctype.value || currentDoctype.value)}`
627
+ }</h1>
628
+ </div>
629
+ `,
630
+ },
631
+ {
632
+ fieldname: 'loading',
633
+ component: 'div',
634
+ value: `
635
+ <div class="loading-state">
636
+ <p>Loading ${formatDoctypeName(routeDoctype.value || currentDoctype.value)} form...</p>
637
+ </div>
638
+ `,
639
+ },
640
+ ]
641
+ }
642
+
643
+ const schemaArray = 'toArray' in meta.schema ? meta.schema.toArray() : meta.schema
644
+ const currentRecord = getCurrentRecord()
645
+
646
+ return [
647
+ {
648
+ fieldname: 'header',
649
+ component: 'div',
650
+ value: `
651
+ <div class="view-header">
652
+ <nav class="breadcrumbs">
653
+ <a href="/">Home</a>
654
+ <span class="separator">/</span>
655
+ <a href="/${routeDoctype.value || currentDoctype.value}">${formatDoctypeName(
656
+ routeDoctype.value || currentDoctype.value
657
+ )}</a>
658
+ <span class="separator">/</span>
659
+ <span class="current">${isNewRecord.value ? 'New Record' : currentRecordId.value}</span>
660
+ </nav>
661
+ <h1>
662
+ ${
663
+ isNewRecord.value
664
+ ? `New ${formatDoctypeName(routeDoctype.value || currentDoctype.value)}`
665
+ : `Edit ${formatDoctypeName(routeDoctype.value || currentDoctype.value)}`
666
+ }
667
+ </h1>
668
+ </div>
669
+ `,
670
+ },
671
+ {
672
+ fieldname: 'actions',
673
+ component: 'div',
674
+ value: `
675
+ <div class="view-actions">
676
+ <button class="btn-primary" data-action="save" ${saving.value ? 'disabled' : ''}>
677
+ ${saving.value ? 'Saving...' : 'Save'}
678
+ </button>
679
+ <button class="btn-secondary" data-action="cancel">Cancel</button>
680
+ ${!isNewRecord.value ? '<button class="btn-danger" data-action="delete">Delete</button>' : ''}
681
+ </div>
682
+ `,
683
+ },
684
+ ...schemaArray.map(field => ({
685
+ ...field,
686
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
687
+ value: currentRecord[field.fieldname] || '',
688
+ })),
689
+ ]
690
+ } catch (error) {
691
+ return [
692
+ {
693
+ fieldname: 'error',
694
+ component: 'div',
695
+ value: `
696
+ <div class="error-state">
697
+ <p>Unable to load form schema for ${formatDoctypeName(routeDoctype.value || currentDoctype.value)}</p>
698
+ </div>
699
+ `,
700
+ },
701
+ ]
702
+ }
703
+ }
704
+
705
+ // Additional data helper functions
706
+ const getRecords = () => {
707
+ if (!stonecrop.value || !currentDoctype.value) {
708
+ return []
709
+ }
710
+
711
+ const recordsNode = stonecrop.value.records(currentDoctype.value)
712
+ const recordsData = recordsNode?.get('')
713
+
714
+ if (recordsData && typeof recordsData === 'object' && !Array.isArray(recordsData)) {
715
+ return Object.values(recordsData as Record<string, any>)
716
+ }
717
+
718
+ return []
719
+ }
720
+
721
+ const getColumns = () => {
722
+ if (!stonecrop.value || !currentDoctype.value) return []
723
+
724
+ try {
725
+ const registry = stonecrop.value.registry
726
+ const meta = registry.registry[currentDoctype.value]
727
+
728
+ if (meta?.schema) {
729
+ const schemaArray = 'toArray' in meta.schema ? meta.schema.toArray() : meta.schema
730
+ return schemaArray.map(field => ({
731
+ fieldname: field.fieldname,
732
+ label: ('label' in field && field.label) || field.fieldname,
733
+ fieldtype: ('fieldtype' in field && field.fieldtype) || 'Data',
734
+ }))
735
+ }
736
+ } catch (error) {
737
+ // Error getting schema - return empty array
738
+ }
739
+
740
+ return []
741
+ }
742
+
743
+ const getCurrentRecord = () => {
744
+ if (!stonecrop.value || !currentDoctype.value || isNewRecord.value) return {}
745
+
746
+ const record = stonecrop.value.getRecordById(currentDoctype.value, currentRecordId.value)
747
+ return record?.get('') || {}
748
+ }
749
+
750
+ // Schema for different views - defined here after all helper functions are available
751
+ const currentViewSchema = computed<SchemaTypes[]>(() => {
752
+ switch (currentView.value) {
753
+ case 'doctypes':
754
+ return getDoctypesSchema()
755
+ case 'records':
756
+ return getRecordsSchema()
757
+ case 'record':
758
+ return getRecordFormSchema()
759
+ default:
760
+ return []
761
+ }
762
+ })
763
+
764
+ // Writable schema for AForm v-model binding
765
+ const writableSchema = ref<SchemaTypes[]>([])
766
+
767
+ // Sync computed schema to writable schema when it changes
768
+ watch(
769
+ currentViewSchema,
770
+ newSchema => {
771
+ writableSchema.value = [...newSchema]
772
+ },
773
+ { immediate: true, deep: true }
774
+ )
775
+
776
+ // Watch for field changes in writable schema and sync to HST
777
+ watch(
778
+ writableSchema,
779
+ newSchema => {
780
+ if (!stonecrop.value || !currentDoctype.value || !currentRecordId.value || isNewRecord.value) {
781
+ return
782
+ }
783
+
784
+ try {
785
+ const hstStore = stonecrop.value.getStore()
786
+
787
+ // Process form field updates from schema
788
+ newSchema.forEach(field => {
789
+ // Only process fields that have a fieldname and value (form fields)
790
+ if (
791
+ field.fieldname &&
792
+ 'value' in field &&
793
+ !['header', 'actions', 'loading', 'error'].includes(field.fieldname)
794
+ ) {
795
+ const fieldPath = `${currentDoctype.value}.${currentRecordId.value}.${field.fieldname}`
796
+ const currentValue = hstStore.has(fieldPath) ? hstStore.get(fieldPath) : undefined
797
+
798
+ // Only update if value actually changed to avoid infinite loops
799
+ if (currentValue !== field.value) {
800
+ hstStore.set(fieldPath, field.value)
801
+ }
802
+ }
803
+ })
804
+ } catch (error) {
805
+ // eslint-disable-next-line no-console
806
+ console.warn('HST schema sync failed:', error)
807
+ }
808
+ },
809
+ { deep: true }
810
+ )
811
+
812
+ // Action handlers (will be triggered by button clicks in the UI)
813
+ const handleSave = async () => {
814
+ // eslint-disable-next-line no-console
815
+ if (!stonecrop.value) return
816
+
817
+ saving.value = true
818
+
819
+ try {
820
+ const formData = currentViewData.value || {}
821
+
822
+ if (isNewRecord.value) {
823
+ const newId = `record-${Date.now()}`
824
+ const recordData = { id: newId, ...formData }
825
+
826
+ stonecrop.value.addRecord(currentDoctype.value, newId, recordData)
827
+
828
+ // Trigger SAVE transition for new record
829
+ const node = stonecrop.value.getRecordById(currentDoctype.value, newId)
830
+ if (node) {
831
+ await node.triggerTransition('SAVE', {
832
+ currentState: 'creating',
833
+ targetState: 'saved',
834
+ fsmContext: recordData,
835
+ })
836
+ }
837
+
838
+ await router.value?.replace(`/${routeDoctype.value}/${newId}`)
839
+ } else {
840
+ const recordData = { id: currentRecordId.value, ...formData }
841
+ stonecrop.value.addRecord(currentDoctype.value, currentRecordId.value, recordData)
842
+
843
+ // Trigger SAVE transition for existing record
844
+ const node = stonecrop.value.getRecordById(currentDoctype.value, currentRecordId.value)
845
+ if (node) {
846
+ await node.triggerTransition('SAVE', {
847
+ currentState: 'editing',
848
+ targetState: 'saved',
849
+ fsmContext: recordData,
850
+ })
851
+ }
852
+ }
853
+ } catch (error) {
854
+ // Silently handle error
855
+ } finally {
856
+ saving.value = false
857
+ }
858
+ }
859
+
860
+ const handleCancel = async () => {
861
+ if (isNewRecord.value) {
862
+ // For new records, we don't have a specific record node yet
863
+ // Just navigate back without triggering transition
864
+ await router.value?.push(`/${routeDoctype.value}`)
865
+ } else {
866
+ // Trigger CANCEL transition for existing record
867
+ if (stonecrop.value) {
868
+ const node = stonecrop.value.getRecordById(currentDoctype.value, currentRecordId.value)
869
+ if (node) {
870
+ await node.triggerTransition('CANCEL', {
871
+ currentState: 'editing',
872
+ targetState: 'cancelled',
873
+ })
874
+ }
875
+ }
876
+ // Reload current record data
877
+ loadRecordData()
878
+ }
879
+ }
880
+
881
+ const handleActionClick = (label: string, action: (() => void | Promise<void>) | undefined) => {
882
+ // eslint-disable-next-line no-console
883
+ if (action) {
884
+ void action()
885
+ }
886
+ }
887
+
888
+ const handleDelete = async (recordId?: string) => {
889
+ if (!stonecrop.value) return
890
+
891
+ const targetRecordId = recordId || currentRecordId.value
892
+ if (!targetRecordId) return
893
+
894
+ if (confirm('Are you sure you want to delete this record?')) {
895
+ // Trigger DELETE transition before removing
896
+ const node = stonecrop.value.getRecordById(currentDoctype.value, targetRecordId)
897
+ if (node) {
898
+ await node.triggerTransition('DELETE', {
899
+ currentState: 'editing',
900
+ targetState: 'deleted',
901
+ })
902
+ }
903
+
904
+ stonecrop.value.removeRecord(currentDoctype.value, targetRecordId)
905
+
906
+ if (currentView.value === 'record') {
907
+ await router.value?.push(`/${routeDoctype.value}`)
908
+ }
909
+ }
910
+ }
911
+
912
+ // Event handlers
913
+ const handleClick = async (event: Event) => {
914
+ const target = event.target as HTMLElement
915
+ const action = target.getAttribute('data-action')
916
+
917
+ if (action) {
918
+ switch (action) {
919
+ case 'create':
920
+ await createNewRecord()
921
+ break
922
+ case 'save':
923
+ await handleSave()
924
+ break
925
+ case 'cancel':
926
+ await handleCancel()
927
+ break
928
+ case 'delete':
929
+ await handleDelete()
930
+ break
931
+ }
932
+ }
933
+
934
+ // Handle table cell clicks for actions
935
+ const cell = target.closest('td, th')
936
+ if (cell) {
937
+ const cellText = cell.textContent?.trim()
938
+ const row = cell.closest('tr')
939
+
940
+ if (cellText === 'View Records' && row) {
941
+ // Get the doctype from the row data
942
+ const cells = row.querySelectorAll('td')
943
+ if (cells.length > 0) {
944
+ const doctypeCell = cells[1] // Assuming doctype is in second column (first column is index)
945
+ const doctype = doctypeCell.textContent?.trim()
946
+ if (doctype) {
947
+ await navigateToDoctype(doctype)
948
+ }
949
+ }
950
+ } else if (cellText?.includes('Edit') && row) {
951
+ // Get the record ID from the row
952
+ const cells = row.querySelectorAll('td')
953
+ if (cells.length > 0) {
954
+ const idCell = cells[0] // Assuming ID is in first column
955
+ const recordId = idCell.textContent?.trim()
956
+ if (recordId) {
957
+ await openRecord(recordId)
958
+ }
959
+ }
960
+ } else if (cellText?.includes('Delete') && row) {
961
+ // Get the record ID from the row
962
+ const cells = row.querySelectorAll('td')
963
+ if (cells.length > 0) {
964
+ const idCell = cells[0] // Assuming ID is in first column
965
+ const recordId = idCell.textContent?.trim()
966
+ if (recordId) {
967
+ await handleDelete(recordId)
968
+ }
969
+ }
970
+ }
971
+ }
972
+ }
973
+
974
+ // Watch for route changes to load appropriate data
975
+ watch(
976
+ [currentView, currentDoctype, currentRecordId],
977
+ () => {
978
+ if (currentView.value === 'record') {
979
+ loadRecordData()
980
+ }
981
+ },
982
+ { immediate: true }
983
+ )
984
+
985
+ // Watch for Stonecrop instance to become available
986
+ watch(
987
+ stonecrop,
988
+ newStonecrop => {
989
+ if (newStonecrop) {
990
+ // Force a re-evaluation of the current view schema when Stonecrop becomes available
991
+ // This is handled automatically by the reactive computed properties
992
+ }
993
+ },
994
+ { immediate: true }
995
+ )
996
+
997
+ // Watch for when we need to load data for records view
998
+ watch(
999
+ [currentView, currentDoctype, stonecrop],
1000
+ ([view, doctype, stonecropInstance]) => {
1001
+ if (view === 'records' && doctype && stonecropInstance) {
1002
+ // Ensure doctype metadata is loaded
1003
+ loadDoctypeMetadata(doctype)
1004
+ }
1005
+ },
1006
+ { immediate: true }
1007
+ )
1008
+
1009
+ const loadRecordData = () => {
1010
+ if (!stonecrop.value || !currentDoctype.value) return
1011
+
1012
+ loading.value = true
1013
+
1014
+ try {
1015
+ if (!isNewRecord.value) {
1016
+ // For existing records, ensure the record exists in HST
1017
+ // The computed currentViewData will automatically read from HST
1018
+ stonecrop.value.getRecordById(currentDoctype.value, currentRecordId.value)
1019
+ }
1020
+ // For new records, currentViewData computed property will return {} automatically
1021
+ } catch (error) {
1022
+ // eslint-disable-next-line no-console
1023
+ console.warn('Error loading record data:', error)
1024
+ } finally {
1025
+ loading.value = false
1026
+ }
1027
+ }
1028
+
1029
+ // Provide methods for action components
1030
+ const desktopMethods = {
1031
+ navigateToDoctype,
1032
+ openRecord,
1033
+ createNewRecord,
1034
+ handleSave,
1035
+ handleCancel,
1036
+ handleDelete,
1037
+ }
1038
+
1039
+ provide('desktopMethods', desktopMethods)
1040
+
1041
+ // Register action components in Vue app
1042
+ onMounted(() => {
1043
+ // Wait a tick for stonecrop to be ready, then load initial data
1044
+ void nextTick(() => {
1045
+ if (currentView.value === 'records' && currentDoctype.value && stonecrop.value) {
1046
+ loadDoctypeMetadata(currentDoctype.value)
1047
+ }
1048
+ })
1049
+
1050
+ // Components will be automatically registered via the global component system
1051
+
1052
+ // Add keyboard shortcuts
1053
+ const handleKeydown = (event: KeyboardEvent) => {
1054
+ // Ctrl+K or Cmd+K to open command palette
1055
+ if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
1056
+ event.preventDefault()
1057
+ commandPaletteOpen.value = true
1058
+ }
1059
+ // Escape to close command palette
1060
+ if (event.key === 'Escape' && commandPaletteOpen.value) {
1061
+ commandPaletteOpen.value = false
1062
+ }
1063
+ }
1064
+
1065
+ document.addEventListener('keydown', handleKeydown)
1066
+
1067
+ // Cleanup event listener on unmount
1068
+ return () => {
1069
+ document.removeEventListener('keydown', handleKeydown)
1070
+ }
1071
+ })
8
1072
  </script>