@stonecrop/desktop 0.13.8 → 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.8",
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.8",
36
- "@stonecrop/atable": "0.13.8",
37
- "@stonecrop/stonecrop": "0.13.8",
38
- "@stonecrop/themes": "0.13.8",
39
- "@stonecrop/schema": "0.13.8"
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
  }
@@ -128,14 +159,9 @@ const currentViewData = computed<Record<string, any>>({
128
159
  const doctype = stonecrop.value.registry.registry[currentDoctype.value]
129
160
  if (doctype) {
130
161
  for (const field of doctype.getSchemaArray()) {
131
- if (
132
- 'fieldtype' in field &&
133
- field.fieldtype === 'Fieldset' &&
134
- 'schema' in field &&
135
- Array.isArray(field.schema)
136
- ) {
162
+ if (field.kind === 'fieldset') {
137
163
  const nested: Record<string, any> = {}
138
- for (const child of field.schema as any[]) {
164
+ for (const child of field.schema) {
139
165
  if (child.fieldname) nested[child.fieldname] = flat[child.fieldname]
140
166
  }
141
167
  flat[field.fieldname] = nested
@@ -148,6 +174,8 @@ const currentViewData = computed<Record<string, any>>({
148
174
  }
149
175
  },
150
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
151
179
  if (!stonecrop.value || !currentDoctype.value || !currentRecordId.value) {
152
180
  return
153
181
  }
@@ -159,7 +187,7 @@ const currentViewData = computed<Record<string, any>>({
159
187
  const fieldsetNames = new Set<string>()
160
188
  if (doctype) {
161
189
  for (const field of doctype.getSchemaArray()) {
162
- if ('fieldtype' in field && field.fieldtype === 'Fieldset') {
190
+ if (field.kind === 'fieldset') {
163
191
  fieldsetNames.add(field.fieldname)
164
192
  }
165
193
  }
@@ -182,9 +210,12 @@ const currentViewData = computed<Record<string, any>>({
182
210
  Object.assign(flatData, nestedValue)
183
211
  }
184
212
 
185
- // Only update fields that actually changed to avoid triggering actions for unchanged fields
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).
186
216
  const hstStore = stonecrop.value.getStore()
187
217
  for (const [fieldname, value] of Object.entries(flatData)) {
218
+ if (value === undefined) continue
188
219
  const fieldPath = `${currentDoctype.value}.${currentRecordId.value}.${fieldname}`
189
220
  const currentValue = hstStore.has(fieldPath) ? hstStore.get(fieldPath) : undefined
190
221
  if (currentValue !== value) {
@@ -199,10 +230,10 @@ const currentViewData = computed<Record<string, any>>({
199
230
 
200
231
  // Computed properties for current route context.
201
232
  // When a routeAdapter is provided it takes full precedence over the registry's internal router.
202
- const route = computed(() => (props.routeAdapter ? null : unref(stonecrop.value?.registry.router?.currentRoute)))
203
- 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))
204
235
  const currentDoctype = computed(() => {
205
- if (props.routeAdapter) return props.routeAdapter.getCurrentDoctype()
236
+ if (routeAdapter) return routeAdapter.getCurrentDoctype()
206
237
  if (!route.value) return ''
207
238
 
208
239
  // First check if we have actualDoctype in meta (from registered routes)
@@ -226,7 +257,7 @@ const currentDoctype = computed(() => {
226
257
 
227
258
  // The route doctype for display and navigation (e.g., 'todo')
228
259
  const routeDoctype = computed(() => {
229
- if (props.routeAdapter) return props.routeAdapter.getCurrentDoctype()
260
+ if (routeAdapter) return routeAdapter.getCurrentDoctype()
230
261
  if (!route.value) return ''
231
262
 
232
263
  // Check route meta first
@@ -249,7 +280,7 @@ const routeDoctype = computed(() => {
249
280
  })
250
281
 
251
282
  const currentRecordId = computed(() => {
252
- if (props.routeAdapter) return props.routeAdapter.getCurrentRecordId()
283
+ if (routeAdapter) return routeAdapter.getCurrentRecordId()
253
284
  if (!route.value) return ''
254
285
 
255
286
  // For named routes, use params.recordId
@@ -269,7 +300,7 @@ const isNewRecord = computed(() => currentRecordId.value?.startsWith('new-'))
269
300
 
270
301
  // Determine current view based on route
271
302
  const currentView = computed(() => {
272
- if (props.routeAdapter) return props.routeAdapter.getCurrentView()
303
+ if (routeAdapter) return routeAdapter.getCurrentView()
273
304
  if (!route.value) {
274
305
  return 'doctypes'
275
306
  }
@@ -468,8 +499,8 @@ const getRecordCount = (doctype: string): number => {
468
499
  // or falls back to the registry's Vue Router instance.
469
500
  const doNavigate = async (target: NavigationTarget) => {
470
501
  emit('navigate', target)
471
- if (props.routeAdapter) {
472
- await props.routeAdapter.navigate(target)
502
+ if (routeAdapter) {
503
+ await routeAdapter.navigate(target)
473
504
  } else {
474
505
  if (target.view === 'doctypes') {
475
506
  await router.value?.push('/')
@@ -499,11 +530,11 @@ const createNewRecord = async () => {
499
530
  // Flatten Fieldset containers into individual columns for list/table views.
500
531
  // A Fieldset groups form fields visually but has no DB column; its children are the
501
532
  // actual data fields and should appear as flat columns in a list view.
502
- const flattenFieldsets = (fields: SchemaTypes[]): SchemaTypes[] => {
503
- const result: SchemaTypes[] = []
533
+ const flattenFieldsets = (fields: ResolvedField[]): ResolvedField[] => {
534
+ const result: ResolvedField[] = []
504
535
  for (const field of fields) {
505
- if ('fieldtype' in field && field.fieldtype === 'Fieldset' && 'schema' in field && Array.isArray(field.schema)) {
506
- result.push(...flattenFieldsets(field.schema as SchemaTypes[]))
536
+ if (field.kind === 'fieldset') {
537
+ result.push(...flattenFieldsets(field.schema))
507
538
  } else {
508
539
  result.push(field)
509
540
  }
@@ -512,65 +543,55 @@ const flattenFieldsets = (fields: SchemaTypes[]): SchemaTypes[] => {
512
543
  }
513
544
 
514
545
  // Schema generator functions - moved here to be available to computed properties
515
- const getDoctypesSchema = (): SchemaTypes[] => {
516
- if (!availableDoctypes.length) return []
517
-
518
- const rows = availableDoctypes.map(doctype => ({
519
- id: doctype,
520
- doctype,
521
- display_name: formatDoctypeName(doctype),
522
- record_count: getRecordCount(doctype),
523
- actions: 'View Records',
524
- }))
546
+ const getDoctypesSchema = (): ResolvedField[] => {
547
+ if (!availableDoctypes?.length) return []
525
548
 
526
549
  return [
527
550
  {
551
+ kind: 'table' as const,
528
552
  fieldname: 'doctypes_table',
529
553
  component: 'ATable',
530
- columns: [
554
+ label: 'Doctypes',
555
+ schema: [
531
556
  {
557
+ fieldname: 'doctype',
532
558
  label: 'Doctype',
533
- name: 'doctype',
534
559
  fieldtype: 'Data',
535
- align: 'left',
560
+ align: 'left' as const,
536
561
  edit: false,
537
562
  width: '20ch',
538
563
  },
539
564
  {
565
+ fieldname: 'display_name',
540
566
  label: 'Name',
541
- name: 'display_name',
542
567
  fieldtype: 'Data',
543
- align: 'left',
568
+ align: 'left' as const,
544
569
  edit: false,
545
570
  width: '30ch',
546
571
  },
547
572
  {
573
+ fieldname: 'record_count',
548
574
  label: 'Records',
549
- name: 'record_count',
550
575
  fieldtype: 'Int',
551
- align: 'center',
576
+ align: 'center' as const,
552
577
  edit: false,
553
578
  width: '15ch',
554
579
  },
555
580
  {
581
+ fieldname: 'actions',
556
582
  label: 'Actions',
557
- name: 'actions',
558
583
  fieldtype: 'Data',
559
- align: 'center',
584
+ align: 'center' as const,
560
585
  edit: false,
561
586
  width: '20ch',
562
587
  },
563
588
  ],
564
- config: {
565
- view: 'list',
566
- fullWidth: true,
567
- },
568
- rows,
569
- },
589
+ config: { view: 'list' as const, fullWidth: true },
590
+ } satisfies ResolvedTable,
570
591
  ]
571
592
  }
572
593
 
573
- const getRecordsSchema = (): SchemaTypes[] => {
594
+ const getRecordsSchema = (): ResolvedField[] => {
574
595
  if (!currentDoctype.value) return []
575
596
  if (!stonecrop.value) return []
576
597
 
@@ -584,35 +605,21 @@ const getRecordsSchema = (): SchemaTypes[] => {
584
605
  // If no schema is available, let the template fallback handle the loading state
585
606
  if (schema.length === 0) return []
586
607
 
587
- const records = getRecords()
588
- const idField = props.recordIdField || 'id'
589
-
590
- const rows = records.map(record =>
591
- Object.assign({}, record, {
592
- id: record[idField] || record.id || '',
593
- actions: 'Edit | Delete',
594
- })
595
- )
596
-
597
608
  return [
598
609
  {
610
+ kind: 'table' as const,
599
611
  fieldname: 'records_table',
600
612
  component: 'ATable',
601
- kind: 'table',
602
613
  schema: [
603
614
  ...(flattenFieldsets(schema) as ColumnSchema[]),
604
615
  { fieldname: 'actions', label: 'Actions', fieldtype: 'Data' },
605
616
  ],
606
- config: {
607
- view: 'list',
608
- fullWidth: true,
609
- },
610
- rows,
611
- },
617
+ config: { view: 'list' as const, fullWidth: true },
618
+ } satisfies ResolvedTable,
612
619
  ]
613
620
  }
614
621
 
615
- const getRecordFormSchema = (): SchemaTypes[] => {
622
+ const getRecordFormSchema = (): ResolvedField[] => {
616
623
  if (!currentDoctype.value) return []
617
624
  if (!stonecrop.value) return []
618
625
 
@@ -674,8 +681,8 @@ const handleDelete = async (recordId?: string) => {
674
681
  const targetRecordId = recordId || currentRecordId.value
675
682
  if (!targetRecordId) return
676
683
 
677
- const confirmed = props.confirmFn
678
- ? 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?')
679
686
  : confirm('Are you sure you want to delete this record?')
680
687
 
681
688
  if (confirmed) {
@@ -703,7 +710,7 @@ const getRecordIdFromRow = (rowElement: HTMLTableRowElement): string | null => {
703
710
  const record = records[rowIndex]
704
711
  if (!record) return null
705
712
 
706
- const idField = props.recordIdField || 'id'
713
+ const idField = recordIdField || 'id'
707
714
  return record[idField] || record.id || null
708
715
  }
709
716
 
@@ -834,25 +841,21 @@ provide('aformLinkResolver', async (doctypeSlug: string, id: string): Promise<st
834
841
  }
835
842
  })
836
843
 
837
- onMounted(() => {
838
- // Add keyboard shortcuts
839
- const handleKeydown = (event: KeyboardEvent) => {
840
- // Ctrl+K or Cmd+K to open command palette
841
- if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
842
- event.preventDefault()
843
- commandPaletteOpen.value = true
844
- }
845
- // Escape to close command palette
846
- if (event.key === 'Escape' && commandPaletteOpen.value) {
847
- commandPaletteOpen.value = false
848
- }
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
849
851
  }
852
+ }
850
853
 
854
+ onMounted(() => {
851
855
  document.addEventListener('keydown', handleKeydown)
856
+ })
852
857
 
853
- // Cleanup event listener on unmount
854
- return () => {
855
- document.removeEventListener('keydown', handleKeydown)
856
- }
858
+ onUnmounted(() => {
859
+ document.removeEventListener('keydown', handleKeydown)
857
860
  })
858
861
  </script>