@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stonecrop/desktop",
3
- "version": "0.13.7",
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.7",
36
- "@stonecrop/atable": "0.13.7",
37
- "@stonecrop/schema": "0.13.7",
38
- "@stonecrop/stonecrop": "0.13.7",
39
- "@stonecrop/themes": "0.13.7"
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 SchemaTypes } from '@stonecrop/aform'
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 props = defineProps<{
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
- // HST-based form data management - field triggers are handled automatically by HST
110
-
111
- // Computed property that reads from HST store for reactive form data
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
- return { ...record?.get('') }
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
- // Only update fields that actually changed to avoid triggering actions for unchanged fields
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(newData)) {
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(() => (props.routeAdapter ? null : unref(stonecrop.value?.registry.router?.currentRoute)))
152
- const router = computed(() => (props.routeAdapter ? null : stonecrop.value?.registry.router))
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 (props.routeAdapter) return props.routeAdapter.getCurrentDoctype()
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 (props.routeAdapter) return props.routeAdapter.getCurrentDoctype()
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 (props.routeAdapter) return props.routeAdapter.getCurrentRecordId()
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 (props.routeAdapter) return props.routeAdapter.getCurrentView()
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 (props.routeAdapter) {
421
- await props.routeAdapter.navigate(target)
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: SchemaTypes[]): SchemaTypes[] => {
452
- const result: SchemaTypes[] = []
533
+ const flattenFieldsets = (fields: ResolvedField[]): ResolvedField[] => {
534
+ const result: ResolvedField[] = []
453
535
  for (const field of fields) {
454
- if ('fieldtype' in field && field.fieldtype === 'Fieldset' && 'schema' in field && Array.isArray(field.schema)) {
455
- result.push(...flattenFieldsets(field.schema as SchemaTypes[]))
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 = (): SchemaTypes[] => {
465
- if (!availableDoctypes.length) return []
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
- columns: [
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
- view: 'list',
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 = (): SchemaTypes[] => {
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
- view: 'list',
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 = (): SchemaTypes[] => {
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 = props.confirmFn
627
- ? await props.confirmFn('Are you sure you want to delete this record?')
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 = props.recordIdField || 'id'
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
- onMounted(() => {
787
- // Add keyboard shortcuts
788
- const handleKeydown = (event: KeyboardEvent) => {
789
- // Ctrl+K or Cmd+K to open command palette
790
- if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
791
- event.preventDefault()
792
- commandPaletteOpen.value = true
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
- // Cleanup event listener on unmount
803
- return () => {
804
- document.removeEventListener('keydown', handleKeydown)
805
- }
858
+ onUnmounted(() => {
859
+ document.removeEventListener('keydown', handleKeydown)
806
860
  })
807
861
  </script>