@stonecrop/desktop 0.13.7 → 0.13.9
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/dist/desktop.css +1 -1
- package/dist/desktop.js +1705 -1689
- package/dist/desktop.js.map +1 -1
- package/package.json +6 -6
- package/src/components/Desktop.vue +140 -86
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stonecrop/desktop",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.9",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": {
|
|
@@ -32,11 +32,11 @@
|
|
|
32
32
|
"**/*.css"
|
|
33
33
|
],
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@stonecrop/aform": "0.13.
|
|
36
|
-
"@stonecrop/atable": "0.13.
|
|
37
|
-
"@stonecrop/schema": "0.13.
|
|
38
|
-
"@stonecrop/stonecrop": "0.13.
|
|
39
|
-
"@stonecrop/themes": "0.13.
|
|
35
|
+
"@stonecrop/aform": "0.13.9",
|
|
36
|
+
"@stonecrop/atable": "0.13.9",
|
|
37
|
+
"@stonecrop/schema": "0.13.9",
|
|
38
|
+
"@stonecrop/stonecrop": "0.13.9",
|
|
39
|
+
"@stonecrop/themes": "0.13.9"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
42
|
"vue": "^3.5.33"
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
|
|
33
33
|
<script setup lang="ts">
|
|
34
34
|
import { useStonecrop } from '@stonecrop/stonecrop'
|
|
35
|
-
import { AForm, type AFormLinkNavigator, type
|
|
35
|
+
import { AForm, type AFormLinkNavigator, type ResolvedField, type ResolvedTable } from '@stonecrop/aform'
|
|
36
36
|
import type { ColumnSchema } from '@stonecrop/schema'
|
|
37
|
-
import { computed, onMounted, provide, ref, unref, watch } from 'vue'
|
|
37
|
+
import { computed, onMounted, onUnmounted, provide, ref, unref, watch } from 'vue'
|
|
38
38
|
|
|
39
39
|
import ActionSet from './ActionSet.vue'
|
|
40
40
|
import SheetNav from './SheetNav.vue'
|
|
@@ -49,7 +49,12 @@ import type {
|
|
|
49
49
|
LoadRecordEventPayload,
|
|
50
50
|
} from '../types'
|
|
51
51
|
|
|
52
|
-
const
|
|
52
|
+
const {
|
|
53
|
+
availableDoctypes = [],
|
|
54
|
+
routeAdapter,
|
|
55
|
+
confirmFn,
|
|
56
|
+
recordIdField,
|
|
57
|
+
} = defineProps<{
|
|
53
58
|
availableDoctypes?: string[]
|
|
54
59
|
/**
|
|
55
60
|
* Pluggable router adapter. When provided, Desktop uses these functions for all
|
|
@@ -98,19 +103,45 @@ const emit = defineEmits<{
|
|
|
98
103
|
'load-record': [payload: LoadRecordEventPayload]
|
|
99
104
|
}>()
|
|
100
105
|
|
|
101
|
-
const { availableDoctypes = [] } = props
|
|
102
|
-
|
|
103
106
|
const { stonecrop } = useStonecrop()
|
|
104
107
|
|
|
105
108
|
// State
|
|
106
109
|
const loading = ref(false)
|
|
107
110
|
const commandPaletteOpen = ref(false)
|
|
108
111
|
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
//
|
|
112
|
+
// Form/list data management — each view produces a different data shape.
|
|
113
|
+
// List views (doctypes, records) return table row data keyed by fieldname.
|
|
114
|
+
// Record view returns HST record fields for two-way binding.
|
|
112
115
|
const currentViewData = computed<Record<string, any>>({
|
|
113
116
|
get() {
|
|
117
|
+
// Doctypes list — rows come from availableDoctypes prop (reactive via availableDoctypes)
|
|
118
|
+
if (currentView.value === 'doctypes') {
|
|
119
|
+
return {
|
|
120
|
+
doctypes_table:
|
|
121
|
+
availableDoctypes?.map(doctype => ({
|
|
122
|
+
id: doctype,
|
|
123
|
+
doctype,
|
|
124
|
+
display_name: formatDoctypeName(doctype),
|
|
125
|
+
record_count: getRecordCount(doctype),
|
|
126
|
+
actions: 'View Records',
|
|
127
|
+
})) ?? [],
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Records list — rows come from HST store (reactive because HST is Vue reactive())
|
|
132
|
+
if (currentView.value === 'records') {
|
|
133
|
+
const idField = recordIdField || 'id'
|
|
134
|
+
return {
|
|
135
|
+
records_table: getRecords().map(record =>
|
|
136
|
+
Object.assign({}, record, {
|
|
137
|
+
id: record[idField] || record.id || '',
|
|
138
|
+
actions: 'Edit | Delete',
|
|
139
|
+
})
|
|
140
|
+
),
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Record form — read single record from HST
|
|
114
145
|
if (!stonecrop.value || !currentDoctype.value || !currentRecordId.value) {
|
|
115
146
|
return {}
|
|
116
147
|
}
|
|
@@ -120,20 +151,71 @@ const currentViewData = computed<Record<string, any>>({
|
|
|
120
151
|
// Return a plain shallow copy so AForm mutations don't propagate directly into
|
|
121
152
|
// the HST reactive object, which would bypass field-trigger diffing and cause
|
|
122
153
|
// setupDeepReactivity to fire triggers for all fields on every keystroke.
|
|
123
|
-
|
|
154
|
+
const flat: Record<string, any> = { ...record?.get('') }
|
|
155
|
+
|
|
156
|
+
// AFieldset receives data[fieldsetFieldname] as its data prop, so the fieldset's
|
|
157
|
+
// children must be grouped under the fieldset key. The server returns flat SQL rows,
|
|
158
|
+
// so we nest fieldset children here before AForm renders them.
|
|
159
|
+
const doctype = stonecrop.value.registry.registry[currentDoctype.value]
|
|
160
|
+
if (doctype) {
|
|
161
|
+
for (const field of doctype.getSchemaArray()) {
|
|
162
|
+
if (field.kind === 'fieldset') {
|
|
163
|
+
const nested: Record<string, any> = {}
|
|
164
|
+
for (const child of field.schema) {
|
|
165
|
+
if (child.fieldname) nested[child.fieldname] = flat[child.fieldname]
|
|
166
|
+
}
|
|
167
|
+
flat[field.fieldname] = nested
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return flat
|
|
124
172
|
} catch {
|
|
125
173
|
return {}
|
|
126
174
|
}
|
|
127
175
|
},
|
|
128
176
|
set(newData: Record<string, any>) {
|
|
177
|
+
// List views are read-only from AForm's perspective — HST writes only apply to record form
|
|
178
|
+
if (currentView.value !== 'record') return
|
|
129
179
|
if (!stonecrop.value || !currentDoctype.value || !currentRecordId.value) {
|
|
130
180
|
return
|
|
131
181
|
}
|
|
132
182
|
|
|
133
183
|
try {
|
|
134
|
-
//
|
|
184
|
+
// AForm emits nested data for fieldsets: { fieldsetKey: { childA: val } }.
|
|
185
|
+
// HST and the server both expect flat rows, so flatten fieldset values back before writing.
|
|
186
|
+
const doctype = stonecrop.value.registry.registry[currentDoctype.value]
|
|
187
|
+
const fieldsetNames = new Set<string>()
|
|
188
|
+
if (doctype) {
|
|
189
|
+
for (const field of doctype.getSchemaArray()) {
|
|
190
|
+
if (field.kind === 'fieldset') {
|
|
191
|
+
fieldsetNames.add(field.fieldname)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Two-pass flatten: non-fieldset keys first, then fieldset children.
|
|
197
|
+
// Fieldset children must be applied last — AForm may emit stale flat copies
|
|
198
|
+
// of fieldset children alongside the updated nested value, and the nested
|
|
199
|
+
// value must win regardless of key insertion order.
|
|
200
|
+
const flatData: Record<string, any> = {}
|
|
201
|
+
const fieldsetValues: Record<string, any>[] = []
|
|
202
|
+
for (const [key, value] of Object.entries(newData)) {
|
|
203
|
+
if (fieldsetNames.has(key) && value && typeof value === 'object' && !Array.isArray(value)) {
|
|
204
|
+
fieldsetValues.push(value)
|
|
205
|
+
} else {
|
|
206
|
+
flatData[key] = value
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
for (const nestedValue of fieldsetValues) {
|
|
210
|
+
Object.assign(flatData, nestedValue)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Only update fields that actually changed. Never write undefined — AForm may emit
|
|
214
|
+
// schema fields absent from the record as undefined; writing them would silently
|
|
215
|
+
// clear values that exist in HST. Explicit null is allowed (intentional clear).
|
|
135
216
|
const hstStore = stonecrop.value.getStore()
|
|
136
|
-
for (const [fieldname, value] of Object.entries(
|
|
217
|
+
for (const [fieldname, value] of Object.entries(flatData)) {
|
|
218
|
+
if (value === undefined) continue
|
|
137
219
|
const fieldPath = `${currentDoctype.value}.${currentRecordId.value}.${fieldname}`
|
|
138
220
|
const currentValue = hstStore.has(fieldPath) ? hstStore.get(fieldPath) : undefined
|
|
139
221
|
if (currentValue !== value) {
|
|
@@ -148,10 +230,10 @@ const currentViewData = computed<Record<string, any>>({
|
|
|
148
230
|
|
|
149
231
|
// Computed properties for current route context.
|
|
150
232
|
// When a routeAdapter is provided it takes full precedence over the registry's internal router.
|
|
151
|
-
const route = computed(() => (
|
|
152
|
-
const router = computed(() => (
|
|
233
|
+
const route = computed(() => (routeAdapter ? null : unref(stonecrop.value?.registry.router?.currentRoute)))
|
|
234
|
+
const router = computed(() => (routeAdapter ? null : stonecrop.value?.registry.router))
|
|
153
235
|
const currentDoctype = computed(() => {
|
|
154
|
-
if (
|
|
236
|
+
if (routeAdapter) return routeAdapter.getCurrentDoctype()
|
|
155
237
|
if (!route.value) return ''
|
|
156
238
|
|
|
157
239
|
// First check if we have actualDoctype in meta (from registered routes)
|
|
@@ -175,7 +257,7 @@ const currentDoctype = computed(() => {
|
|
|
175
257
|
|
|
176
258
|
// The route doctype for display and navigation (e.g., 'todo')
|
|
177
259
|
const routeDoctype = computed(() => {
|
|
178
|
-
if (
|
|
260
|
+
if (routeAdapter) return routeAdapter.getCurrentDoctype()
|
|
179
261
|
if (!route.value) return ''
|
|
180
262
|
|
|
181
263
|
// Check route meta first
|
|
@@ -198,7 +280,7 @@ const routeDoctype = computed(() => {
|
|
|
198
280
|
})
|
|
199
281
|
|
|
200
282
|
const currentRecordId = computed(() => {
|
|
201
|
-
if (
|
|
283
|
+
if (routeAdapter) return routeAdapter.getCurrentRecordId()
|
|
202
284
|
if (!route.value) return ''
|
|
203
285
|
|
|
204
286
|
// For named routes, use params.recordId
|
|
@@ -218,7 +300,7 @@ const isNewRecord = computed(() => currentRecordId.value?.startsWith('new-'))
|
|
|
218
300
|
|
|
219
301
|
// Determine current view based on route
|
|
220
302
|
const currentView = computed(() => {
|
|
221
|
-
if (
|
|
303
|
+
if (routeAdapter) return routeAdapter.getCurrentView()
|
|
222
304
|
if (!route.value) {
|
|
223
305
|
return 'doctypes'
|
|
224
306
|
}
|
|
@@ -417,8 +499,8 @@ const getRecordCount = (doctype: string): number => {
|
|
|
417
499
|
// or falls back to the registry's Vue Router instance.
|
|
418
500
|
const doNavigate = async (target: NavigationTarget) => {
|
|
419
501
|
emit('navigate', target)
|
|
420
|
-
if (
|
|
421
|
-
await
|
|
502
|
+
if (routeAdapter) {
|
|
503
|
+
await routeAdapter.navigate(target)
|
|
422
504
|
} else {
|
|
423
505
|
if (target.view === 'doctypes') {
|
|
424
506
|
await router.value?.push('/')
|
|
@@ -448,11 +530,11 @@ const createNewRecord = async () => {
|
|
|
448
530
|
// Flatten Fieldset containers into individual columns for list/table views.
|
|
449
531
|
// A Fieldset groups form fields visually but has no DB column; its children are the
|
|
450
532
|
// actual data fields and should appear as flat columns in a list view.
|
|
451
|
-
const flattenFieldsets = (fields:
|
|
452
|
-
const result:
|
|
533
|
+
const flattenFieldsets = (fields: ResolvedField[]): ResolvedField[] => {
|
|
534
|
+
const result: ResolvedField[] = []
|
|
453
535
|
for (const field of fields) {
|
|
454
|
-
if (
|
|
455
|
-
result.push(...flattenFieldsets(field.schema
|
|
536
|
+
if (field.kind === 'fieldset') {
|
|
537
|
+
result.push(...flattenFieldsets(field.schema))
|
|
456
538
|
} else {
|
|
457
539
|
result.push(field)
|
|
458
540
|
}
|
|
@@ -461,65 +543,55 @@ const flattenFieldsets = (fields: SchemaTypes[]): SchemaTypes[] => {
|
|
|
461
543
|
}
|
|
462
544
|
|
|
463
545
|
// Schema generator functions - moved here to be available to computed properties
|
|
464
|
-
const getDoctypesSchema = ():
|
|
465
|
-
if (!availableDoctypes
|
|
466
|
-
|
|
467
|
-
const rows = availableDoctypes.map(doctype => ({
|
|
468
|
-
id: doctype,
|
|
469
|
-
doctype,
|
|
470
|
-
display_name: formatDoctypeName(doctype),
|
|
471
|
-
record_count: getRecordCount(doctype),
|
|
472
|
-
actions: 'View Records',
|
|
473
|
-
}))
|
|
546
|
+
const getDoctypesSchema = (): ResolvedField[] => {
|
|
547
|
+
if (!availableDoctypes?.length) return []
|
|
474
548
|
|
|
475
549
|
return [
|
|
476
550
|
{
|
|
551
|
+
kind: 'table' as const,
|
|
477
552
|
fieldname: 'doctypes_table',
|
|
478
553
|
component: 'ATable',
|
|
479
|
-
|
|
554
|
+
label: 'Doctypes',
|
|
555
|
+
schema: [
|
|
480
556
|
{
|
|
557
|
+
fieldname: 'doctype',
|
|
481
558
|
label: 'Doctype',
|
|
482
|
-
name: 'doctype',
|
|
483
559
|
fieldtype: 'Data',
|
|
484
|
-
align: 'left',
|
|
560
|
+
align: 'left' as const,
|
|
485
561
|
edit: false,
|
|
486
562
|
width: '20ch',
|
|
487
563
|
},
|
|
488
564
|
{
|
|
565
|
+
fieldname: 'display_name',
|
|
489
566
|
label: 'Name',
|
|
490
|
-
name: 'display_name',
|
|
491
567
|
fieldtype: 'Data',
|
|
492
|
-
align: 'left',
|
|
568
|
+
align: 'left' as const,
|
|
493
569
|
edit: false,
|
|
494
570
|
width: '30ch',
|
|
495
571
|
},
|
|
496
572
|
{
|
|
573
|
+
fieldname: 'record_count',
|
|
497
574
|
label: 'Records',
|
|
498
|
-
name: 'record_count',
|
|
499
575
|
fieldtype: 'Int',
|
|
500
|
-
align: 'center',
|
|
576
|
+
align: 'center' as const,
|
|
501
577
|
edit: false,
|
|
502
578
|
width: '15ch',
|
|
503
579
|
},
|
|
504
580
|
{
|
|
581
|
+
fieldname: 'actions',
|
|
505
582
|
label: 'Actions',
|
|
506
|
-
name: 'actions',
|
|
507
583
|
fieldtype: 'Data',
|
|
508
|
-
align: 'center',
|
|
584
|
+
align: 'center' as const,
|
|
509
585
|
edit: false,
|
|
510
586
|
width: '20ch',
|
|
511
587
|
},
|
|
512
588
|
],
|
|
513
|
-
config: {
|
|
514
|
-
|
|
515
|
-
fullWidth: true,
|
|
516
|
-
},
|
|
517
|
-
rows,
|
|
518
|
-
},
|
|
589
|
+
config: { view: 'list' as const, fullWidth: true },
|
|
590
|
+
} satisfies ResolvedTable,
|
|
519
591
|
]
|
|
520
592
|
}
|
|
521
593
|
|
|
522
|
-
const getRecordsSchema = ():
|
|
594
|
+
const getRecordsSchema = (): ResolvedField[] => {
|
|
523
595
|
if (!currentDoctype.value) return []
|
|
524
596
|
if (!stonecrop.value) return []
|
|
525
597
|
|
|
@@ -533,35 +605,21 @@ const getRecordsSchema = (): SchemaTypes[] => {
|
|
|
533
605
|
// If no schema is available, let the template fallback handle the loading state
|
|
534
606
|
if (schema.length === 0) return []
|
|
535
607
|
|
|
536
|
-
const records = getRecords()
|
|
537
|
-
const idField = props.recordIdField || 'id'
|
|
538
|
-
|
|
539
|
-
const rows = records.map(record =>
|
|
540
|
-
Object.assign({}, record, {
|
|
541
|
-
id: record[idField] || record.id || '',
|
|
542
|
-
actions: 'Edit | Delete',
|
|
543
|
-
})
|
|
544
|
-
)
|
|
545
|
-
|
|
546
608
|
return [
|
|
547
609
|
{
|
|
610
|
+
kind: 'table' as const,
|
|
548
611
|
fieldname: 'records_table',
|
|
549
612
|
component: 'ATable',
|
|
550
|
-
kind: 'table',
|
|
551
613
|
schema: [
|
|
552
614
|
...(flattenFieldsets(schema) as ColumnSchema[]),
|
|
553
615
|
{ fieldname: 'actions', label: 'Actions', fieldtype: 'Data' },
|
|
554
616
|
],
|
|
555
|
-
config: {
|
|
556
|
-
|
|
557
|
-
fullWidth: true,
|
|
558
|
-
},
|
|
559
|
-
rows,
|
|
560
|
-
},
|
|
617
|
+
config: { view: 'list' as const, fullWidth: true },
|
|
618
|
+
} satisfies ResolvedTable,
|
|
561
619
|
]
|
|
562
620
|
}
|
|
563
621
|
|
|
564
|
-
const getRecordFormSchema = ():
|
|
622
|
+
const getRecordFormSchema = (): ResolvedField[] => {
|
|
565
623
|
if (!currentDoctype.value) return []
|
|
566
624
|
if (!stonecrop.value) return []
|
|
567
625
|
|
|
@@ -623,8 +681,8 @@ const handleDelete = async (recordId?: string) => {
|
|
|
623
681
|
const targetRecordId = recordId || currentRecordId.value
|
|
624
682
|
if (!targetRecordId) return
|
|
625
683
|
|
|
626
|
-
const confirmed =
|
|
627
|
-
? await
|
|
684
|
+
const confirmed = confirmFn
|
|
685
|
+
? await confirmFn('Are you sure you want to delete this record?')
|
|
628
686
|
: confirm('Are you sure you want to delete this record?')
|
|
629
687
|
|
|
630
688
|
if (confirmed) {
|
|
@@ -652,7 +710,7 @@ const getRecordIdFromRow = (rowElement: HTMLTableRowElement): string | null => {
|
|
|
652
710
|
const record = records[rowIndex]
|
|
653
711
|
if (!record) return null
|
|
654
712
|
|
|
655
|
-
const idField =
|
|
713
|
+
const idField = recordIdField || 'id'
|
|
656
714
|
return record[idField] || record.id || null
|
|
657
715
|
}
|
|
658
716
|
|
|
@@ -783,25 +841,21 @@ provide('aformLinkResolver', async (doctypeSlug: string, id: string): Promise<st
|
|
|
783
841
|
}
|
|
784
842
|
})
|
|
785
843
|
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
}
|
|
794
|
-
// Escape to close command palette
|
|
795
|
-
if (event.key === 'Escape' && commandPaletteOpen.value) {
|
|
796
|
-
commandPaletteOpen.value = false
|
|
797
|
-
}
|
|
844
|
+
const handleKeydown = (event: KeyboardEvent) => {
|
|
845
|
+
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
|
|
846
|
+
event.preventDefault()
|
|
847
|
+
commandPaletteOpen.value = true
|
|
848
|
+
}
|
|
849
|
+
if (event.key === 'Escape' && commandPaletteOpen.value) {
|
|
850
|
+
commandPaletteOpen.value = false
|
|
798
851
|
}
|
|
852
|
+
}
|
|
799
853
|
|
|
854
|
+
onMounted(() => {
|
|
800
855
|
document.addEventListener('keydown', handleKeydown)
|
|
856
|
+
})
|
|
801
857
|
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
document.removeEventListener('keydown', handleKeydown)
|
|
805
|
-
}
|
|
858
|
+
onUnmounted(() => {
|
|
859
|
+
document.removeEventListener('keydown', handleKeydown)
|
|
806
860
|
})
|
|
807
861
|
</script>
|