@stonecrop/desktop 0.10.1 → 0.10.2
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 +184 -7
- package/dist/desktop.d.ts +49 -0
- package/dist/desktop.js +1485 -1544
- package/dist/desktop.js.map +1 -1
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/types/index.d.ts +45 -0
- package/dist/src/types/index.d.ts.map +1 -1
- package/package.json +15 -6
- package/src/components/Desktop.vue +157 -251
- package/src/index.ts +1 -0
- package/src/types/index.ts +49 -0
|
@@ -33,20 +33,58 @@
|
|
|
33
33
|
<script setup lang="ts">
|
|
34
34
|
import { useStonecrop } from '@stonecrop/stonecrop'
|
|
35
35
|
import { AForm, type SchemaTypes, type TableColumn, type TableConfig } from '@stonecrop/aform'
|
|
36
|
-
import { computed,
|
|
36
|
+
import { computed, onMounted, provide, ref, unref, watch } from 'vue'
|
|
37
37
|
|
|
38
38
|
import ActionSet from './ActionSet.vue'
|
|
39
39
|
import SheetNav from './SheetNav.vue'
|
|
40
40
|
import CommandPalette from './CommandPalette.vue'
|
|
41
|
-
import type {
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
import type {
|
|
42
|
+
ActionElements,
|
|
43
|
+
RouteAdapter,
|
|
44
|
+
NavigationTarget,
|
|
45
|
+
ActionEventPayload,
|
|
46
|
+
RecordOpenEventPayload,
|
|
47
|
+
} from '../types'
|
|
48
|
+
|
|
49
|
+
const props = defineProps<{
|
|
50
|
+
availableDoctypes?: string[]
|
|
51
|
+
/**
|
|
52
|
+
* Pluggable router adapter. When provided, Desktop uses these functions for all
|
|
53
|
+
* routing instead of reaching into the registry's internal Vue Router instance.
|
|
54
|
+
* Nuxt hosts (or any host with custom route conventions) should supply this.
|
|
55
|
+
*/
|
|
56
|
+
routeAdapter?: RouteAdapter
|
|
57
|
+
/**
|
|
58
|
+
* Replacement for the native `confirm()` dialog. Desktop calls this before
|
|
59
|
+
* performing a destructive action. Return `true` to proceed.
|
|
60
|
+
* Defaults to the native `window.confirm` if omitted.
|
|
61
|
+
*/
|
|
62
|
+
confirmFn?: (message: string) => boolean | Promise<boolean>
|
|
63
|
+
}>()
|
|
64
|
+
|
|
65
|
+
const emit = defineEmits<{
|
|
66
|
+
/**
|
|
67
|
+
* Fired when the user triggers an FSM transition (action button click).
|
|
68
|
+
* The host app is responsible for calling the server, persisting state, etc.
|
|
69
|
+
*/
|
|
70
|
+
action: [payload: ActionEventPayload]
|
|
71
|
+
/**
|
|
72
|
+
* Fired when Desktop wants to navigate to a different view.
|
|
73
|
+
* Also calls routeAdapter.navigate() if an adapter is provided.
|
|
74
|
+
*/
|
|
75
|
+
navigate: [target: NavigationTarget]
|
|
76
|
+
/**
|
|
77
|
+
* Fired when the user opens a specific record.
|
|
78
|
+
*/
|
|
79
|
+
'record:open': [payload: RecordOpenEventPayload]
|
|
80
|
+
}>()
|
|
81
|
+
|
|
82
|
+
const { availableDoctypes = [] } = props
|
|
44
83
|
|
|
45
84
|
const { stonecrop } = useStonecrop()
|
|
46
85
|
|
|
47
86
|
// State
|
|
48
87
|
const loading = ref(false)
|
|
49
|
-
const saving = ref(false)
|
|
50
88
|
const commandPaletteOpen = ref(false)
|
|
51
89
|
|
|
52
90
|
// HST-based form data management - field triggers are handled automatically by HST
|
|
@@ -90,12 +128,12 @@ const currentViewData = computed<Record<string, any>>({
|
|
|
90
128
|
},
|
|
91
129
|
})
|
|
92
130
|
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
const router = computed(() => stonecrop.value?.registry.router)
|
|
131
|
+
// Computed properties for current route context.
|
|
132
|
+
// When a routeAdapter is provided it takes full precedence over the registry's internal router.
|
|
133
|
+
const route = computed(() => (props.routeAdapter ? null : unref(stonecrop.value?.registry.router?.currentRoute)))
|
|
134
|
+
const router = computed(() => (props.routeAdapter ? null : stonecrop.value?.registry.router))
|
|
98
135
|
const currentDoctype = computed(() => {
|
|
136
|
+
if (props.routeAdapter) return props.routeAdapter.getCurrentDoctype()
|
|
99
137
|
if (!route.value) return ''
|
|
100
138
|
|
|
101
139
|
// First check if we have actualDoctype in meta (from registered routes)
|
|
@@ -119,6 +157,7 @@ const currentDoctype = computed(() => {
|
|
|
119
157
|
|
|
120
158
|
// The route doctype for display and navigation (e.g., 'todo')
|
|
121
159
|
const routeDoctype = computed(() => {
|
|
160
|
+
if (props.routeAdapter) return props.routeAdapter.getCurrentDoctype()
|
|
122
161
|
if (!route.value) return ''
|
|
123
162
|
|
|
124
163
|
// Check route meta first
|
|
@@ -141,6 +180,7 @@ const routeDoctype = computed(() => {
|
|
|
141
180
|
})
|
|
142
181
|
|
|
143
182
|
const currentRecordId = computed(() => {
|
|
183
|
+
if (props.routeAdapter) return props.routeAdapter.getCurrentRecordId()
|
|
144
184
|
if (!route.value) return ''
|
|
145
185
|
|
|
146
186
|
// For named routes, use params.recordId
|
|
@@ -160,6 +200,7 @@ const isNewRecord = computed(() => currentRecordId.value?.startsWith('new-'))
|
|
|
160
200
|
|
|
161
201
|
// Determine current view based on route
|
|
162
202
|
const currentView = computed(() => {
|
|
203
|
+
if (props.routeAdapter) return props.routeAdapter.getCurrentView()
|
|
163
204
|
if (!route.value) {
|
|
164
205
|
return 'doctypes'
|
|
165
206
|
}
|
|
@@ -190,58 +231,39 @@ const currentView = computed(() => {
|
|
|
190
231
|
})
|
|
191
232
|
|
|
192
233
|
// Computed properties (now that all helper functions are defined)
|
|
193
|
-
// Helper function to get available transitions for current record
|
|
234
|
+
// Helper function to get available transitions for current record.
|
|
235
|
+
// Reads the actual FSM state from the record's `status` field (or falls back to the
|
|
236
|
+
// workflow initial state) so the available action buttons always reflect reality.
|
|
194
237
|
const getAvailableTransitions = () => {
|
|
195
238
|
if (!stonecrop.value || !currentDoctype.value || !currentRecordId.value) {
|
|
196
239
|
return []
|
|
197
240
|
}
|
|
198
241
|
|
|
199
242
|
try {
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const actionFn = async () => {
|
|
225
|
-
const node = stonecrop.value?.getRecordById(currentDoctype.value, currentRecordId.value)
|
|
226
|
-
if (node) {
|
|
227
|
-
const recordData = currentViewData.value || {}
|
|
228
|
-
await node.triggerTransition(transition, {
|
|
229
|
-
currentState,
|
|
230
|
-
targetState: targetStateName,
|
|
231
|
-
fsmContext: recordData,
|
|
232
|
-
})
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
const element = {
|
|
237
|
-
label: `${transition} (→ ${targetStateName})`,
|
|
238
|
-
action: actionFn,
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
return element
|
|
242
|
-
})
|
|
243
|
-
|
|
244
|
-
return actionElements
|
|
243
|
+
const meta = stonecrop.value.registry.getDoctype(currentDoctype.value)
|
|
244
|
+
if (!meta?.workflow) return []
|
|
245
|
+
|
|
246
|
+
// Delegate state resolution to Stonecrop — reads record 'status', falls back to workflow.initial
|
|
247
|
+
const currentState = stonecrop.value.getRecordState(currentDoctype.value, currentRecordId.value)
|
|
248
|
+
|
|
249
|
+
// Delegate transition lookup to DoctypeMeta — no more manual workflow introspection
|
|
250
|
+
const transitions = meta.getAvailableTransitions(currentState)
|
|
251
|
+
|
|
252
|
+
const recordData = currentViewData.value || {}
|
|
253
|
+
|
|
254
|
+
// Each transition emits an 'action' event. The host app decides what to do
|
|
255
|
+
// (call the server, trigger an FSM actor, update HST, etc.).
|
|
256
|
+
return transitions.map(({ name, targetState }) => ({
|
|
257
|
+
label: `${name} (→ ${targetState})`,
|
|
258
|
+
action: () => {
|
|
259
|
+
emit('action', {
|
|
260
|
+
name,
|
|
261
|
+
doctype: currentDoctype.value,
|
|
262
|
+
recordId: currentRecordId.value,
|
|
263
|
+
data: recordData,
|
|
264
|
+
})
|
|
265
|
+
},
|
|
266
|
+
}))
|
|
245
267
|
} catch (error) {
|
|
246
268
|
// eslint-disable-next-line no-console
|
|
247
269
|
console.warn('Error getting available transitions:', error)
|
|
@@ -249,40 +271,20 @@ const getAvailableTransitions = () => {
|
|
|
249
271
|
}
|
|
250
272
|
}
|
|
251
273
|
|
|
252
|
-
// New component reactive properties// New component reactive properties
|
|
253
274
|
const actionElements = computed<ActionElements[]>(() => {
|
|
254
275
|
const elements: ActionElements[] = []
|
|
255
276
|
|
|
256
277
|
switch (currentView.value) {
|
|
257
|
-
case '
|
|
278
|
+
case 'records':
|
|
258
279
|
elements.push({
|
|
259
280
|
type: 'button',
|
|
260
|
-
label: '
|
|
261
|
-
action: () =>
|
|
262
|
-
// Refresh doctypes
|
|
263
|
-
window.location.reload()
|
|
264
|
-
},
|
|
281
|
+
label: 'New Record',
|
|
282
|
+
action: () => void createNewRecord(),
|
|
265
283
|
})
|
|
266
284
|
break
|
|
267
|
-
case 'records':
|
|
268
|
-
elements.push(
|
|
269
|
-
{
|
|
270
|
-
type: 'button',
|
|
271
|
-
label: 'New Record',
|
|
272
|
-
action: () => void createNewRecord(),
|
|
273
|
-
},
|
|
274
|
-
{
|
|
275
|
-
type: 'button',
|
|
276
|
-
label: 'Refresh',
|
|
277
|
-
action: () => {
|
|
278
|
-
// Refresh records
|
|
279
|
-
window.location.reload()
|
|
280
|
-
},
|
|
281
|
-
}
|
|
282
|
-
)
|
|
283
|
-
break
|
|
284
285
|
case 'record': {
|
|
285
|
-
//
|
|
286
|
+
// Populate the Actions dropdown with every FSM transition available in the
|
|
287
|
+
// record's current state. Clicking a transition emits 'action'.
|
|
286
288
|
const transitionActions = getAvailableTransitions()
|
|
287
289
|
if (transitionActions.length > 0) {
|
|
288
290
|
elements.push({
|
|
@@ -307,10 +309,13 @@ const navigationBreadcrumbs = computed(() => {
|
|
|
307
309
|
{ title: formatDoctypeName(routeDoctype.value), to: `/${routeDoctype.value}` }
|
|
308
310
|
)
|
|
309
311
|
} else if (currentView.value === 'record' && routeDoctype.value) {
|
|
312
|
+
const recordPath = currentRecordId.value
|
|
313
|
+
? `/${routeDoctype.value}/${currentRecordId.value}`
|
|
314
|
+
: route.value?.fullPath ?? ''
|
|
310
315
|
breadcrumbs.push(
|
|
311
316
|
{ title: 'Home', to: '/' },
|
|
312
317
|
{ title: formatDoctypeName(routeDoctype.value), to: `/${routeDoctype.value}` },
|
|
313
|
-
{ title: isNewRecord.value ? 'New Record' : 'Edit Record', to:
|
|
318
|
+
{ title: isNewRecord.value ? 'New Record' : 'Edit Record', to: recordPath }
|
|
314
319
|
)
|
|
315
320
|
}
|
|
316
321
|
|
|
@@ -329,7 +334,7 @@ const searchCommands = (query: string): Command[] => {
|
|
|
329
334
|
{
|
|
330
335
|
title: 'Go Home',
|
|
331
336
|
description: 'Navigate to the home page',
|
|
332
|
-
action: () => void
|
|
337
|
+
action: () => void doNavigate({ view: 'doctypes' }),
|
|
333
338
|
},
|
|
334
339
|
{
|
|
335
340
|
title: 'Toggle Command Palette',
|
|
@@ -343,7 +348,7 @@ const searchCommands = (query: string): Command[] => {
|
|
|
343
348
|
commands.push({
|
|
344
349
|
title: `View ${formatDoctypeName(routeDoctype.value)} Records`,
|
|
345
350
|
description: `Navigate to ${routeDoctype.value} list`,
|
|
346
|
-
action: () => void
|
|
351
|
+
action: () => void doNavigate({ view: 'records', doctype: routeDoctype.value }),
|
|
347
352
|
})
|
|
348
353
|
|
|
349
354
|
commands.push({
|
|
@@ -358,7 +363,7 @@ const searchCommands = (query: string): Command[] => {
|
|
|
358
363
|
commands.push({
|
|
359
364
|
title: `View ${formatDoctypeName(doctype)}`,
|
|
360
365
|
description: `Navigate to ${doctype} list`,
|
|
361
|
-
action: () => void
|
|
366
|
+
action: () => void doNavigate({ view: 'records', doctype }),
|
|
362
367
|
})
|
|
363
368
|
})
|
|
364
369
|
|
|
@@ -391,30 +396,36 @@ const getRecordCount = (doctype: string): number => {
|
|
|
391
396
|
return recordIds.length
|
|
392
397
|
}
|
|
393
398
|
|
|
399
|
+
// Internal navigation helper: emits 'navigate', then calls the adapter (if any)
|
|
400
|
+
// or falls back to the registry's Vue Router instance.
|
|
401
|
+
const doNavigate = async (target: NavigationTarget) => {
|
|
402
|
+
emit('navigate', target)
|
|
403
|
+
if (props.routeAdapter) {
|
|
404
|
+
await props.routeAdapter.navigate(target)
|
|
405
|
+
} else {
|
|
406
|
+
if (target.view === 'doctypes') {
|
|
407
|
+
await router.value?.push('/')
|
|
408
|
+
} else if (target.view === 'records' && target.doctype) {
|
|
409
|
+
await router.value?.push(`/${target.doctype}`)
|
|
410
|
+
} else if (target.view === 'record' && target.doctype && target.recordId) {
|
|
411
|
+
await router.value?.push(`/${target.doctype}/${target.recordId}`)
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
394
416
|
const navigateToDoctype = async (doctype: string) => {
|
|
395
|
-
await
|
|
417
|
+
await doNavigate({ view: 'records', doctype })
|
|
396
418
|
}
|
|
397
419
|
|
|
398
420
|
const openRecord = async (recordId: string) => {
|
|
399
|
-
|
|
421
|
+
const doctype = routeDoctype.value
|
|
422
|
+
emit('record:open', { doctype, recordId })
|
|
423
|
+
await doNavigate({ view: 'record', doctype, recordId })
|
|
400
424
|
}
|
|
401
425
|
|
|
402
426
|
const createNewRecord = async () => {
|
|
403
427
|
const newId = `new-${Date.now()}`
|
|
404
|
-
await
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
// Doctype metadata loader - simplified since router handles most of this
|
|
408
|
-
const loadDoctypeMetadata = (doctype: string) => {
|
|
409
|
-
if (!stonecrop.value) return
|
|
410
|
-
|
|
411
|
-
// Ensure the doctype structure exists in HST
|
|
412
|
-
// The router should have already loaded the metadata, but this ensures the HST structure exists
|
|
413
|
-
try {
|
|
414
|
-
stonecrop.value.records(doctype)
|
|
415
|
-
} catch {
|
|
416
|
-
// Silent error handling - structure will be created if needed
|
|
417
|
-
}
|
|
428
|
+
await doNavigate({ view: 'record', doctype: routeDoctype.value, recordId: newId })
|
|
418
429
|
}
|
|
419
430
|
|
|
420
431
|
// Schema generator functions - moved here to be available to computed properties
|
|
@@ -598,101 +609,30 @@ const currentViewSchema = computed<SchemaTypes[]>(() => {
|
|
|
598
609
|
}
|
|
599
610
|
})
|
|
600
611
|
|
|
601
|
-
// Action handlers (will be triggered by button clicks in the UI)
|
|
602
|
-
const handleSave = async () => {
|
|
603
|
-
if (!stonecrop.value) return
|
|
604
|
-
|
|
605
|
-
saving.value = true
|
|
606
|
-
|
|
607
|
-
try {
|
|
608
|
-
const formData = currentViewData.value || {}
|
|
609
|
-
|
|
610
|
-
if (isNewRecord.value) {
|
|
611
|
-
const newId = `record-${Date.now()}`
|
|
612
|
-
const recordData = { id: newId, ...formData }
|
|
613
|
-
|
|
614
|
-
stonecrop.value.addRecord(currentDoctype.value, newId, recordData)
|
|
615
|
-
|
|
616
|
-
// Trigger SAVE transition for new record
|
|
617
|
-
const node = stonecrop.value.getRecordById(currentDoctype.value, newId)
|
|
618
|
-
if (node) {
|
|
619
|
-
await node.triggerTransition('SAVE', {
|
|
620
|
-
currentState: 'creating',
|
|
621
|
-
targetState: 'saved',
|
|
622
|
-
fsmContext: recordData,
|
|
623
|
-
})
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
await router.value?.replace(`/${routeDoctype.value}/${newId}`)
|
|
627
|
-
} else {
|
|
628
|
-
const recordData = { id: currentRecordId.value, ...formData }
|
|
629
|
-
stonecrop.value.addRecord(currentDoctype.value, currentRecordId.value, recordData)
|
|
630
|
-
|
|
631
|
-
// Trigger SAVE transition for existing record
|
|
632
|
-
const node = stonecrop.value.getRecordById(currentDoctype.value, currentRecordId.value)
|
|
633
|
-
if (node) {
|
|
634
|
-
await node.triggerTransition('SAVE', {
|
|
635
|
-
currentState: 'editing',
|
|
636
|
-
targetState: 'saved',
|
|
637
|
-
fsmContext: recordData,
|
|
638
|
-
})
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
} catch {
|
|
642
|
-
// Silently handle error
|
|
643
|
-
} finally {
|
|
644
|
-
saving.value = false
|
|
645
|
-
}
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
const handleCancel = async () => {
|
|
649
|
-
if (isNewRecord.value) {
|
|
650
|
-
// For new records, we don't have a specific record node yet
|
|
651
|
-
// Just navigate back without triggering transition
|
|
652
|
-
await router.value?.push(`/${routeDoctype.value}`)
|
|
653
|
-
} else {
|
|
654
|
-
// Trigger CANCEL transition for existing record
|
|
655
|
-
if (stonecrop.value) {
|
|
656
|
-
const node = stonecrop.value.getRecordById(currentDoctype.value, currentRecordId.value)
|
|
657
|
-
if (node) {
|
|
658
|
-
await node.triggerTransition('CANCEL', {
|
|
659
|
-
currentState: 'editing',
|
|
660
|
-
targetState: 'cancelled',
|
|
661
|
-
})
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
// Reload current record data
|
|
665
|
-
loadRecordData()
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
|
|
669
612
|
const handleActionClick = (_label: string, action: (() => void | Promise<void>) | undefined) => {
|
|
670
613
|
if (action) {
|
|
671
614
|
void action()
|
|
672
615
|
}
|
|
673
616
|
}
|
|
674
617
|
|
|
618
|
+
// Desktop does NOT own the delete lifecycle — it asks for confirmation, then emits
|
|
619
|
+
// an 'action' event. The host app is responsible for removing the record from HST
|
|
620
|
+
// and calling the server.
|
|
675
621
|
const handleDelete = async (recordId?: string) => {
|
|
676
|
-
if (!stonecrop.value) return
|
|
677
|
-
|
|
678
622
|
const targetRecordId = recordId || currentRecordId.value
|
|
679
623
|
if (!targetRecordId) return
|
|
680
624
|
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
if (node) {
|
|
685
|
-
await node.triggerTransition('DELETE', {
|
|
686
|
-
currentState: 'editing',
|
|
687
|
-
targetState: 'deleted',
|
|
688
|
-
})
|
|
689
|
-
}
|
|
625
|
+
const confirmed = props.confirmFn
|
|
626
|
+
? await props.confirmFn('Are you sure you want to delete this record?')
|
|
627
|
+
: confirm('Are you sure you want to delete this record?')
|
|
690
628
|
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
629
|
+
if (confirmed) {
|
|
630
|
+
emit('action', {
|
|
631
|
+
name: 'DELETE',
|
|
632
|
+
doctype: currentDoctype.value,
|
|
633
|
+
recordId: targetRecordId,
|
|
634
|
+
data: currentViewData.value || {},
|
|
635
|
+
})
|
|
696
636
|
}
|
|
697
637
|
}
|
|
698
638
|
|
|
@@ -701,21 +641,8 @@ const handleClick = async (event: Event) => {
|
|
|
701
641
|
const target = event.target as HTMLElement
|
|
702
642
|
const action = target.getAttribute('data-action')
|
|
703
643
|
|
|
704
|
-
if (action) {
|
|
705
|
-
|
|
706
|
-
case 'create':
|
|
707
|
-
await createNewRecord()
|
|
708
|
-
break
|
|
709
|
-
case 'save':
|
|
710
|
-
await handleSave()
|
|
711
|
-
break
|
|
712
|
-
case 'cancel':
|
|
713
|
-
await handleCancel()
|
|
714
|
-
break
|
|
715
|
-
case 'delete':
|
|
716
|
-
await handleDelete()
|
|
717
|
-
break
|
|
718
|
-
}
|
|
644
|
+
if (action === 'create') {
|
|
645
|
+
await createNewRecord()
|
|
719
646
|
}
|
|
720
647
|
|
|
721
648
|
// Handle table cell clicks for actions
|
|
@@ -758,41 +685,6 @@ const handleClick = async (event: Event) => {
|
|
|
758
685
|
}
|
|
759
686
|
}
|
|
760
687
|
|
|
761
|
-
// Watch for route changes to load appropriate data
|
|
762
|
-
watch(
|
|
763
|
-
[currentView, currentDoctype, currentRecordId],
|
|
764
|
-
() => {
|
|
765
|
-
if (currentView.value === 'record') {
|
|
766
|
-
loadRecordData()
|
|
767
|
-
}
|
|
768
|
-
},
|
|
769
|
-
{ immediate: true }
|
|
770
|
-
)
|
|
771
|
-
|
|
772
|
-
// Watch for Stonecrop instance to become available
|
|
773
|
-
watch(
|
|
774
|
-
stonecrop,
|
|
775
|
-
newStonecrop => {
|
|
776
|
-
if (newStonecrop) {
|
|
777
|
-
// Force a re-evaluation of the current view schema when Stonecrop becomes available
|
|
778
|
-
// This is handled automatically by the reactive computed properties
|
|
779
|
-
}
|
|
780
|
-
},
|
|
781
|
-
{ immediate: true }
|
|
782
|
-
)
|
|
783
|
-
|
|
784
|
-
// Watch for when we need to load data for records view
|
|
785
|
-
watch(
|
|
786
|
-
[currentView, currentDoctype, stonecrop],
|
|
787
|
-
([view, doctype, stonecropInstance]) => {
|
|
788
|
-
if (view === 'records' && doctype && stonecropInstance) {
|
|
789
|
-
// Ensure doctype metadata is loaded
|
|
790
|
-
loadDoctypeMetadata(doctype)
|
|
791
|
-
}
|
|
792
|
-
},
|
|
793
|
-
{ immediate: true }
|
|
794
|
-
)
|
|
795
|
-
|
|
796
688
|
const loadRecordData = () => {
|
|
797
689
|
if (!stonecrop.value || !currentDoctype.value) return
|
|
798
690
|
|
|
@@ -800,11 +692,11 @@ const loadRecordData = () => {
|
|
|
800
692
|
|
|
801
693
|
try {
|
|
802
694
|
if (!isNewRecord.value) {
|
|
803
|
-
// For existing records, ensure the record exists in HST
|
|
804
|
-
// The computed currentViewData will automatically read from HST
|
|
695
|
+
// For existing records, ensure the record exists in HST.
|
|
696
|
+
// The computed currentViewData will automatically read from HST.
|
|
805
697
|
stonecrop.value.getRecordById(currentDoctype.value, currentRecordId.value)
|
|
806
698
|
}
|
|
807
|
-
// For new records, currentViewData computed property will return {} automatically
|
|
699
|
+
// For new records, currentViewData computed property will return {} automatically.
|
|
808
700
|
} catch (error) {
|
|
809
701
|
// eslint-disable-next-line no-console
|
|
810
702
|
console.warn('Error loading record data:', error)
|
|
@@ -813,29 +705,43 @@ const loadRecordData = () => {
|
|
|
813
705
|
}
|
|
814
706
|
}
|
|
815
707
|
|
|
816
|
-
//
|
|
708
|
+
// Watch for route changes to load appropriate data
|
|
709
|
+
watch(
|
|
710
|
+
[currentView, currentDoctype, currentRecordId],
|
|
711
|
+
() => {
|
|
712
|
+
if (currentView.value === 'record') {
|
|
713
|
+
loadRecordData()
|
|
714
|
+
}
|
|
715
|
+
},
|
|
716
|
+
{ immediate: true }
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
// Stonecrop reactive computed properties update automatically when the instance
|
|
720
|
+
// becomes available — no manual watcher needed.
|
|
721
|
+
|
|
722
|
+
// Provide navigation helpers and an emitAction convenience function to child components.
|
|
817
723
|
const desktopMethods = {
|
|
818
724
|
navigateToDoctype,
|
|
819
725
|
openRecord,
|
|
820
726
|
createNewRecord,
|
|
821
|
-
handleSave,
|
|
822
|
-
handleCancel,
|
|
823
727
|
handleDelete,
|
|
728
|
+
/**
|
|
729
|
+
* Convenience wrapper so child components (e.g. slot content) can emit
|
|
730
|
+
* an action event without needing a direct reference to the emit function.
|
|
731
|
+
*/
|
|
732
|
+
emitAction: (name: string, data?: Record<string, any>) => {
|
|
733
|
+
emit('action', {
|
|
734
|
+
name,
|
|
735
|
+
doctype: currentDoctype.value,
|
|
736
|
+
recordId: currentRecordId.value,
|
|
737
|
+
data: data ?? currentViewData.value ?? {},
|
|
738
|
+
})
|
|
739
|
+
},
|
|
824
740
|
}
|
|
825
741
|
|
|
826
742
|
provide('desktopMethods', desktopMethods)
|
|
827
743
|
|
|
828
|
-
// Register action components in Vue app
|
|
829
744
|
onMounted(() => {
|
|
830
|
-
// Wait a tick for stonecrop to be ready, then load initial data
|
|
831
|
-
void nextTick(() => {
|
|
832
|
-
if (currentView.value === 'records' && currentDoctype.value && stonecrop.value) {
|
|
833
|
-
loadDoctypeMetadata(currentDoctype.value)
|
|
834
|
-
}
|
|
835
|
-
})
|
|
836
|
-
|
|
837
|
-
// Components will be automatically registered via the global component system
|
|
838
|
-
|
|
839
745
|
// Add keyboard shortcuts
|
|
840
746
|
const handleKeydown = (event: KeyboardEvent) => {
|
|
841
747
|
// Ctrl+K or Cmd+K to open command palette
|
package/src/index.ts
CHANGED
|
@@ -4,5 +4,6 @@ import Desktop from './components/Desktop.vue'
|
|
|
4
4
|
import SheetNav from './components/SheetNav.vue'
|
|
5
5
|
import StonecropDesktop from './plugins'
|
|
6
6
|
export type * from './types'
|
|
7
|
+
export type { RouteAdapter, NavigationTarget, ActionEventPayload, RecordOpenEventPayload } from './types'
|
|
7
8
|
|
|
8
9
|
export { ActionSet, CommandPalette, Desktop, SheetNav, StonecropDesktop }
|
package/src/types/index.ts
CHANGED
|
@@ -40,3 +40,52 @@ export type DropdownElement = BaseElement & {
|
|
|
40
40
|
* @public
|
|
41
41
|
*/
|
|
42
42
|
export type ActionElements = ButtonElement | DropdownElement
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Navigation target passed to RouteAdapter.navigate and emitted with the 'navigate' event
|
|
46
|
+
* @public
|
|
47
|
+
*/
|
|
48
|
+
export type NavigationTarget = {
|
|
49
|
+
view: 'doctypes' | 'records' | 'record'
|
|
50
|
+
doctype?: string
|
|
51
|
+
recordId?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Adapter that lets host applications (Nuxt, etc.) supply their own routing layer.
|
|
56
|
+
* When provided as a prop, Desktop uses these functions instead of reaching into
|
|
57
|
+
* the Vue Router instance baked into the Stonecrop registry.
|
|
58
|
+
* @public
|
|
59
|
+
*/
|
|
60
|
+
export type RouteAdapter = {
|
|
61
|
+
/** Returns the active doctype key (e.g. 'plan', 'recipe'). Called inside computed — should read reactive state. */
|
|
62
|
+
getCurrentDoctype: () => string
|
|
63
|
+
/** Returns the active record ID, or '' when viewing a list. Called inside computed. */
|
|
64
|
+
getCurrentRecordId: () => string
|
|
65
|
+
/** Returns which of the three views is currently active. Called inside computed. */
|
|
66
|
+
getCurrentView: () => 'doctypes' | 'records' | 'record'
|
|
67
|
+
/** Perform the navigation. Called after the host app has handled any side effects. */
|
|
68
|
+
navigate: (target: NavigationTarget) => void | Promise<void>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Payload emitted with the 'action' event when the user triggers an FSM transition
|
|
73
|
+
* @public
|
|
74
|
+
*/
|
|
75
|
+
export type ActionEventPayload = {
|
|
76
|
+
/** The FSM transition name (e.g. 'SAVE', 'SUBMIT', 'APPROVE') */
|
|
77
|
+
name: string
|
|
78
|
+
doctype: string
|
|
79
|
+
recordId: string
|
|
80
|
+
/** Snapshot of the form data at the time the action was triggered */
|
|
81
|
+
data: Record<string, any>
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Payload emitted with the 'record:open' event
|
|
86
|
+
* @public
|
|
87
|
+
*/
|
|
88
|
+
export type RecordOpenEventPayload = {
|
|
89
|
+
doctype: string
|
|
90
|
+
recordId: string
|
|
91
|
+
}
|