@stonecrop/desktop 0.16.5 → 0.17.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.
@@ -14,6 +14,9 @@
14
14
  <p>Loading {{ currentView }} data...</p>
15
15
  </div>
16
16
 
17
+ <!-- No row count and no "of N": Desktop asks for neither, so stating one would be a guess. -->
18
+ <p v-if="listIsTruncated" class="truncation-note">This is a partial list — more records exist on the server.</p>
19
+
17
20
  <!-- Sheet Navigation -->
18
21
  <SheetNav :breadcrumbs="navigationBreadcrumbs" />
19
22
 
@@ -35,7 +38,10 @@
35
38
  </template>
36
39
 
37
40
  <script setup lang="ts">
38
- import { useStonecrop, useValidationStore } from '@stonecrop/stonecrop'
41
+ // The draft segment comes from @stonecrop/stonecrop rather than being spelled out here: that
42
+ // package guards fetching, field initialization and workflow readiness on the same question, and
43
+ // when the two were written separately they disagreed and every guard over there went dead.
44
+ import { DRAFT_RECORD_ID, isDraftRecordId, useStonecrop, useValidationStore } from '@stonecrop/stonecrop'
39
45
  import { AForm, type AFormLinkNavigator, type ResolvedField, type ResolvedTable } from '@stonecrop/aform'
40
46
  import type { ColumnSchema } from '@stonecrop/schema'
41
47
  import { computed, onMounted, onUnmounted, provide, ref, unref, watch } from 'vue'
@@ -53,12 +59,7 @@ import type {
53
59
  LoadRecordEventPayload,
54
60
  } from '../types'
55
61
 
56
- const {
57
- availableDoctypes = [],
58
- routeAdapter,
59
- confirmFn,
60
- recordIdField,
61
- } = defineProps<{
62
+ const { availableDoctypes = [], routeAdapter } = defineProps<{
62
63
  availableDoctypes?: string[]
63
64
  /**
64
65
  * Pluggable router adapter. When provided, Desktop uses these functions for all
@@ -66,18 +67,6 @@ const {
66
67
  * Nuxt hosts (or any host with custom route conventions) should supply this.
67
68
  */
68
69
  routeAdapter?: RouteAdapter
69
- /**
70
- * Replacement for the native `confirm()` dialog. Desktop calls this before
71
- * performing a destructive action. Return `true` to proceed.
72
- * Defaults to the native `window.confirm` if omitted.
73
- */
74
- confirmFn?: (message: string) => boolean | Promise<boolean>
75
- /**
76
- * The field name that holds the canonical record ID (e.g., 'rowId' for UUID).
77
- * Used for navigation and table row identification.
78
- * Defaults to 'id' if not specified.
79
- */
80
- recordIdField?: string
81
70
  }>()
82
71
 
83
72
  const emit = defineEmits<{
@@ -96,13 +85,14 @@ const emit = defineEmits<{
96
85
  */
97
86
  'record:open': [payload: RecordOpenEventPayload]
98
87
  /**
99
- * Fired when Desktop needs records for a list view.
100
- * The host app should fetch and populate HST.
88
+ * Fired when Desktop is about to read records for a list view. A notification, not a request:
89
+ * Desktop performs the read itself through `Stonecrop.getRecords`. A host that fetches here
90
+ * races that read into the same HST key.
101
91
  */
102
92
  'load-records': [payload: LoadRecordsEventPayload]
103
93
  /**
104
- * Fired when Desktop needs a single record for a form view.
105
- * The host app should fetch and populate HST.
94
+ * Fired when Desktop is about to read a single record for a form view. A notification, not a
95
+ * request — see `load-records`. Not emitted for a draft, which has nothing to fetch.
106
96
  */
107
97
  'load-record': [payload: LoadRecordEventPayload]
108
98
  }>()
@@ -131,9 +121,14 @@ const fieldErrors = computed<Record<string, string[]>>(() =>
131
121
  const loading = ref(false)
132
122
  const commandPaletteOpen = ref(false)
133
123
 
124
+ // The record being composed on a `/{doctype}/new` route. Deliberately not in HST: a draft has no
125
+ // identity to be keyed by, and both ways of faking one fail — see `DRAFT_RECORD_ID`.
126
+ const draftRecord = ref<Record<string, any>>({})
127
+
134
128
  // Form/list data management — each view produces a different data shape.
135
129
  // List views (doctypes, records) return table row data keyed by fieldname.
136
- // Record view returns HST record fields for two-way binding.
130
+ // Record view returns the record's fields for two-way binding — from HST, or from `draftRecord`
131
+ // when the route is a draft.
137
132
  const currentViewData = computed<Record<string, any>>({
138
133
  get() {
139
134
  // Doctypes list — rows come from availableDoctypes prop (reactive via availableDoctypes)
@@ -144,7 +139,6 @@ const currentViewData = computed<Record<string, any>>({
144
139
  id: doctype,
145
140
  doctype,
146
141
  display_name: formatDoctypeName(doctype),
147
- record_count: getRecordCount(doctype),
148
142
  actions: 'View Records',
149
143
  })) ?? [],
150
144
  }
@@ -156,7 +150,13 @@ const currentViewData = computed<Record<string, any>>({
156
150
  records_table: getRecords().map(record =>
157
151
  Object.assign({}, record, {
158
152
  id: resolveRecordId(record) ?? '',
159
- actions: 'Edit | Delete',
153
+ // A list row is navigation. Actions live on the record view, where the Actions
154
+ // dropdown is built from what the doctype declares and what the record's
155
+ // current state allows. This cell used to also offer Delete, which dispatched
156
+ // an action named `DELETE` that Desktop invented — no doctype in a
157
+ // WorkflowMeta app declares it, so it failed on every click. Removal is a
158
+ // workflow outcome (`archive`, `cancel`) and belongs in the doctype.
159
+ actions: 'Edit',
160
160
  })
161
161
  ),
162
162
  }
@@ -168,11 +168,14 @@ const currentViewData = computed<Record<string, any>>({
168
168
  }
169
169
 
170
170
  try {
171
- const record = stonecrop.value.getRecordById(currentDoctype.value, currentRecordId.value)
171
+ const source = isNewRecord.value
172
+ ? draftRecord.value
173
+ : (stonecrop.value.getRecordById(currentDoctype.value, currentRecordId.value)?.get('') as
174
+ Record<string, any> | undefined)
172
175
  // Return a plain shallow copy so AForm mutations don't propagate directly into
173
176
  // the HST reactive object, which would bypass field-trigger diffing and cause
174
177
  // setupDeepReactivity to fire triggers for all fields on every keystroke.
175
- const flat: Record<string, any> = { ...record?.get('') }
178
+ const flat: Record<string, any> = { ...source }
176
179
 
177
180
  // AFieldset receives data[fieldsetFieldname] as its data prop, so the fieldset's
178
181
  // children must be grouped under the fieldset key. The server returns flat SQL rows,
@@ -233,16 +236,30 @@ const currentViewData = computed<Record<string, any>>({
233
236
 
234
237
  // Only update fields that actually changed. Never write undefined — AForm may emit
235
238
  // schema fields absent from the record as undefined; writing them would silently
236
- // clear values that exist in HST. Explicit null is allowed (intentional clear).
237
- const hstStore = stonecrop.value.getStore()
239
+ // clear values that exist. Explicit null is allowed (intentional clear).
238
240
  const changedFields: string[] = []
239
- for (const [fieldname, value] of Object.entries(flatData)) {
240
- if (value === undefined) continue
241
- const fieldPath = `${currentDoctype.value}.${currentRecordId.value}.${fieldname}`
242
- const currentValue = hstStore.has(fieldPath) ? hstStore.get(fieldPath) : undefined
243
- if (currentValue !== value) {
244
- hstStore.set(fieldPath, value)
245
- changedFields.push(fieldname)
241
+ if (isNewRecord.value) {
242
+ // Reassigned, not mutated, so the getter re-runs. Relying on in-place mutation of the
243
+ // cached object is what made a draft's edits vanish on any invalidation.
244
+ const next = { ...draftRecord.value }
245
+ for (const [fieldname, value] of Object.entries(flatData)) {
246
+ if (value === undefined) continue
247
+ if (next[fieldname] !== value) {
248
+ next[fieldname] = value
249
+ changedFields.push(fieldname)
250
+ }
251
+ }
252
+ draftRecord.value = next
253
+ } else {
254
+ const hstStore = stonecrop.value.getStore()
255
+ for (const [fieldname, value] of Object.entries(flatData)) {
256
+ if (value === undefined) continue
257
+ const fieldPath = `${currentDoctype.value}.${currentRecordId.value}.${fieldname}`
258
+ const currentValue = hstStore.has(fieldPath) ? hstStore.get(fieldPath) : undefined
259
+ if (currentValue !== value) {
260
+ hstStore.set(fieldPath, value)
261
+ changedFields.push(fieldname)
262
+ }
246
263
  }
247
264
  }
248
265
 
@@ -267,8 +284,15 @@ function driveFieldValidation(changedFields: string[]) {
267
284
  const triggers = doctype?.getTriggers()
268
285
  if (!triggers || Object.keys(triggers).length === 0) return
269
286
 
270
- const node = stonecrop.value.getRecordById(currentDoctype.value, currentRecordId.value)
271
- const record = { ...(node?.get('') as Record<string, unknown>) }
287
+ // A draft's siblings come from the buffer; reading HST would hand every validator an empty record.
288
+ const record = isNewRecord.value
289
+ ? { ...draftRecord.value }
290
+ : {
291
+ ...(stonecrop.value.getRecordById(currentDoctype.value, currentRecordId.value)?.get('') as Record<
292
+ string,
293
+ unknown
294
+ >),
295
+ }
272
296
 
273
297
  for (const field of changedFields) {
274
298
  void validationStore.validateField(triggers, field, record)
@@ -343,7 +367,7 @@ const currentRecordId = computed(() => {
343
367
 
344
368
  return ''
345
369
  })
346
- const isNewRecord = computed(() => currentRecordId.value?.startsWith('new-'))
370
+ const isNewRecord = computed(() => isDraftRecordId(currentRecordId.value))
347
371
 
348
372
  // Determine current view based on route
349
373
  const currentView = computed(() => {
@@ -571,11 +595,13 @@ const formatDoctypeName = (doctype: string): string => {
571
595
  .join(' ')
572
596
  }
573
597
 
574
- const getRecordCount = (doctype: string): number => {
575
- if (!stonecrop.value) return 0
576
- const recordIds = stonecrop.value.getRecordIds(doctype)
577
- return recordIds.length
578
- }
598
+ // Whether the list on screen is a page rather than the whole set. Read from the backend's own
599
+ // answer, not inferred from how many rows arrived: a page that happens to be exactly the limit
600
+ // is indistinguishable from a complete one by counting.
601
+ const listIsTruncated = computed(() => {
602
+ if (currentView.value !== 'records' || !currentDoctype.value) return false
603
+ return stonecrop.value?.getPageInfo(currentDoctype.value)?.hasMore === true
604
+ })
579
605
 
580
606
  // Internal navigation helper: emits 'navigate', then calls the adapter (if any)
581
607
  // or falls back to the registry's Vue Router instance.
@@ -605,8 +631,7 @@ const openRecord = async (recordId: string) => {
605
631
  }
606
632
 
607
633
  const createNewRecord = async () => {
608
- const newId = `new-${Date.now()}`
609
- await doNavigate({ view: 'record', doctype: routeDoctype.value, recordId: newId })
634
+ await doNavigate({ view: 'record', doctype: routeDoctype.value, recordId: DRAFT_RECORD_ID })
610
635
  }
611
636
 
612
637
  // Flatten Fieldset containers into individual columns for list/table views.
@@ -651,14 +676,10 @@ const getDoctypesSchema = (): ResolvedField[] => {
651
676
  edit: false,
652
677
  width: '30ch',
653
678
  },
654
- {
655
- fieldname: 'record_count',
656
- label: 'Records',
657
- component: 'ANumericInput',
658
- align: 'center' as const,
659
- edit: false,
660
- width: '15ch',
661
- },
679
+ // No record count column. It read `getRecordIds(doctype).length`, which is how many
680
+ // records HST happens to hold — zero for a doctype never opened, and the page size
681
+ // for one that was. The real total belongs to the backend and Desktop never asks
682
+ // for it, so this shell cannot answer it. Same reason the `recordIdField` prop went.
662
683
  {
663
684
  fieldname: 'actions',
664
685
  label: 'Actions',
@@ -756,44 +777,19 @@ const handleActionClick = (_label: string, action: (() => void | Promise<void>)
756
777
  }
757
778
  }
758
779
 
759
- // Desktop does NOT own the delete lifecycle — it asks for confirmation, then emits
760
- // an 'action' event. The host app is responsible for removing the record from HST
761
- // and calling the server.
762
- const handleDelete = async (recordId?: string) => {
763
- const targetRecordId = recordId || currentRecordId.value
764
- if (!targetRecordId) return
765
-
766
- const confirmed = confirmFn
767
- ? await confirmFn('Are you sure you want to delete this record?')
768
- : confirm('Are you sure you want to delete this record?')
769
-
770
- if (confirmed) {
771
- emit('action', {
772
- name: 'DELETE',
773
- doctype: currentDoctype.value,
774
- recordId: targetRecordId,
775
- data: currentViewData.value || {},
776
- })
777
- }
778
- }
779
-
780
780
  /**
781
781
  * Resolve a record's identity for links and navigation.
782
782
  *
783
- * Precedence: the explicit `recordIdField` prop wins (a host that names a field has said which
784
- * column it means), then the doctype's declared `primaryKey`, then `id`. Before this, the default
785
- * was a bare `record.id`, which produced empty links for every natural-keyed doctype.
783
+ * Identity is declared once, on the doctype: `primaryKey`, or `id` when nothing is declared.
784
+ * Delegating to `Doctype.getRecordId` is what guarantees this matches the key
785
+ * `Stonecrop.getRecords` stored the record under — resolving it independently here would let a
786
+ * row render a link to an HST path that does not exist.
786
787
  *
787
- * Delegates to `Doctype.getRecordId` so this matches the key `Stonecrop.getRecords` stored the
788
- * record under — resolving it independently here would let a row render a link to an HST path
789
- * that does not exist.
788
+ * There is deliberately no per-shell override. Identity is a per-doctype fact and one shell
789
+ * renders many doctypes, so a single prop cannot answer it; a shell that named a field would
790
+ * also be overriding the very declaration the store keyed on, which is the bug above.
790
791
  */
791
792
  const resolveRecordId = (record: Record<string, unknown>): string | undefined => {
792
- if (recordIdField) {
793
- const explicit = record[recordIdField]
794
- if (typeof explicit === 'number') return String(explicit)
795
- if (typeof explicit === 'string' && explicit !== '') return explicit
796
- }
797
793
  if (!stonecrop.value || !currentDoctype.value) return undefined
798
794
  return stonecrop.value.registry.registry[currentDoctype.value]?.getRecordId(record)
799
795
  }
@@ -839,37 +835,52 @@ const handleClick = async (event: Event) => {
839
835
  await navigateToDoctype(doctype)
840
836
  }
841
837
  }
842
- } else if (cellText?.includes('Edit') && row) {
838
+ } else if (cellText === 'Edit' && row) {
839
+ // Matched exactly, not by substring. This handler is bound to the whole desktop, so a
840
+ // substring match fired on any cell whose *data* happened to contain the word — a task
841
+ // titled "Delete old backups" popped a delete confirmation when you clicked it.
843
842
  const recordId = getRecordIdFromRow(row)
844
843
  if (recordId) {
845
844
  await openRecord(recordId)
846
845
  }
847
- } else if (cellText?.includes('Delete') && row) {
848
- const recordId = getRecordIdFromRow(row)
849
- if (recordId) {
850
- await handleDelete(recordId)
851
- }
852
846
  }
853
847
  }
854
848
  }
855
849
 
850
+ // Reads go through Stonecrop, which owns whether to fetch, how to key the result, and where to
851
+ // put it. Desktop asks for data and renders what arrives; it decides none of that itself.
852
+ //
853
+ // Both loaders are no-ops without a client, so a host that populates HST some other way keeps
854
+ // working unchanged rather than taking a thrown error on every navigation.
856
855
  const loadRecordData = async () => {
857
- if (!stonecrop.value || !currentDoctype.value || isNewRecord.value) return
856
+ if (!stonecrop.value || !currentDoctype.value || !stonecrop.value.getClient()) return
858
857
 
859
- // Record already in HST — nothing to fetch.
860
- if (stonecrop.value.getRecordById(currentDoctype.value, currentRecordId.value)) return
858
+ loading.value = true
859
+ try {
860
+ await stonecrop.value.getRecord(currentDoctype.value, currentRecordId.value)
861
+ } catch (error) {
862
+ console.warn('Error fetching record:', error)
863
+ } finally {
864
+ loading.value = false
865
+ }
866
+ }
861
867
 
862
- // Record absent and a client is configured — fetch directly so the form
863
- // populates even when the list view was never visited (direct URL navigation).
864
- if (stonecrop.value.getClient()) {
865
- loading.value = true
866
- try {
867
- await stonecrop.value.getRecord(currentDoctype.value, currentRecordId.value)
868
- } catch (error) {
869
- console.warn('Error fetching record:', error)
870
- } finally {
871
- loading.value = false
872
- }
868
+ const loadRecordsData = async () => {
869
+ if (!stonecrop.value || !currentDoctype.value || !stonecrop.value.getClient()) return
870
+
871
+ const doctype = stonecrop.value.registry.getDoctype(currentDoctype.value)
872
+ if (!doctype) return
873
+
874
+ // No row limit is passed. Desktop cannot know what is safe for the host's backend, and a
875
+ // single per-shell number could not serve doctypes of wildly different size anyway — the same
876
+ // reason the `recordIdField` prop was removed. The server decides the page.
877
+ loading.value = true
878
+ try {
879
+ await stonecrop.value.getRecords(doctype)
880
+ } catch (error) {
881
+ console.warn('Error fetching records:', error)
882
+ } finally {
883
+ loading.value = false
873
884
  }
874
885
  }
875
886
 
@@ -877,11 +888,18 @@ const loadRecordData = async () => {
877
888
  watch(
878
889
  [currentView, currentDoctype, currentRecordId],
879
890
  () => {
891
+ // The events are notifications, not fetch requests: they announce what Desktop is about to
892
+ // read so a host can hang analytics or a prefetch off them. The read itself is Stonecrop's.
880
893
  if (currentView.value === 'records' && currentDoctype.value) {
881
- // Emit load-records event so host app can populate HST
882
894
  emit('load-records', { doctype: currentDoctype.value })
895
+ void loadRecordsData()
883
896
  } else if (currentView.value === 'record' && currentDoctype.value && currentRecordId.value) {
884
- // Emit load-record event so host app can fetch and populate HST
897
+ // A draft has nothing to fetch — the record does not exist on the server yet. Desktop
898
+ // used to emit anyway and leave the host to work it out, which meant every host had to
899
+ // recognise the private draft-id scheme above just to suppress a doomed request; all of
900
+ // them did, identically. `getRecord` declines the same case for the same reason.
901
+ if (isNewRecord.value) return
902
+
885
903
  emit('load-record', { doctype: currentDoctype.value, recordId: currentRecordId.value })
886
904
  void loadRecordData()
887
905
  }
@@ -895,6 +913,22 @@ watch([currentDoctype, currentRecordId], () => {
895
913
  validationStore?.clearAll()
896
914
  })
897
915
 
916
+ // Seeding on entry gives a new record the doctype's declared defaults, which it never used to get.
917
+ // Discarding on exit matters as much: the draft segment is one shared literal, so a stale buffer
918
+ // would open the next New Record pre-filled with the abandoned one's values.
919
+ watch(
920
+ [currentDoctype, currentRecordId],
921
+ () => {
922
+ if (!isNewRecord.value) {
923
+ draftRecord.value = {}
924
+ return
925
+ }
926
+ const registry = stonecrop.value?.registry
927
+ draftRecord.value = registry ? registry.initializeRecord(getRecordFormSchema()) : {}
928
+ },
929
+ { immediate: true }
930
+ )
931
+
898
932
  // Stonecrop reactive computed properties update automatically when the instance
899
933
  // becomes available — no manual watcher needed.
900
934
 
@@ -903,7 +937,6 @@ const desktopMethods = {
903
937
  navigateToDoctype,
904
938
  openRecord,
905
939
  createNewRecord,
906
- handleDelete,
907
940
  /**
908
941
  * Convenience wrapper so child components (e.g. slot content) can emit
909
942
  * an action event without needing a direct reference to the emit function.
@@ -69,17 +69,16 @@ export type RouteAdapter = {
69
69
  }
70
70
 
71
71
  /**
72
- * Payload emitted with the 'action' event when the user triggers an FSM transition
72
+ * Payload emitted with the 'action' event when the user triggers a declared action.
73
+ *
74
+ * Re-exported, not defined here: the shell emits it and `useClientAction` consumes it, and those
75
+ * now live in different packages. It is declared in `@stonecrop/stonecrop` — which this package
76
+ * already depends on — so the two cannot drift. Importing it from `@stonecrop/desktop` still
77
+ * works and is still the natural place for a host to reach for it.
78
+ *
73
79
  * @public
74
80
  */
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
- }
81
+ export type { ActionEventPayload } from '@stonecrop/stonecrop'
83
82
 
84
83
  /**
85
84
  * Payload emitted with the 'record:open' event