@stonecrop/desktop 0.17.0 → 0.19.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.
@@ -43,7 +43,13 @@
43
43
  // when the two were written separately they disagreed and every guard over there went dead.
44
44
  import { DRAFT_RECORD_ID, isDraftRecordId, useStonecrop, useValidationStore } from '@stonecrop/stonecrop'
45
45
  import { AForm, type AFormLinkNavigator, type ResolvedField, type ResolvedTable } from '@stonecrop/aform'
46
- import type { ColumnSchema } from '@stonecrop/schema'
46
+ import {
47
+ type ColumnSchema,
48
+ componentLinkExpansion,
49
+ type DoctypeField,
50
+ flattenFields,
51
+ linkDisplayFieldname,
52
+ } from '@stonecrop/schema'
47
53
  import { computed, onMounted, onUnmounted, provide, ref, unref, watch } from 'vue'
48
54
 
49
55
  import ActionSet from './ActionSet.vue'
@@ -59,6 +65,77 @@ import type {
59
65
  LoadRecordEventPayload,
60
66
  } from '../types'
61
67
 
68
+ /**
69
+ * The link fields a doctype declares, split by what their value actually holds.
70
+ *
71
+ * Both sets come from the doctype rather than from the shape of a key or a value, because neither
72
+ * shape is decisive. A link's display text arrives as a sibling key (`customerId__display`) in the
73
+ * same payload the user is editing, so a key ending in `__display` is not necessarily one; and an
74
+ * *inline* link's value (`{ id, displayText }`) is indistinguishable by inspection from an
75
+ * *expanded* one (`{ id, ...the whole target record }`).
76
+ *
77
+ * `inlineLinks` is the narrower set and the load-bearing one. Only an inline link's value is a
78
+ * scalar id wearing a label, so only it may be unwrapped back to that id on write. Unwrapping an
79
+ * expanded link instead replaces the embedded record with its own id — destroying the nested data
80
+ * and discarding the edit that triggered the write.
81
+ *
82
+ * `componentLinkExpansion` is the same rule the adapter reads when it decides which links to
83
+ * expand, so the two sides agree on what a field's value contains. A component it does not
84
+ * recognise is left out: an unrecognised link is passed through untouched rather than rewritten on
85
+ * a guess.
86
+ */
87
+ function linkFieldSets(fields: DoctypeField[]): { inlineLinks: Set<string>; displayKeys: Set<string> } {
88
+ const links = flattenFields(fields).filter(field => field.kind === 'field' && Boolean(field.doctype))
89
+
90
+ return {
91
+ inlineLinks: new Set(
92
+ links.filter(field => componentLinkExpansion(field.component) === 'inline').map(field => field.fieldname)
93
+ ),
94
+ displayKeys: new Set(links.map(field => linkDisplayFieldname(field.fieldname))),
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Fold a link's pre-resolved `fieldname__display` sibling into the value AForm renders.
100
+ *
101
+ * Both this and the record payload are flat — a fieldset is a layout grouping, not a scope — so
102
+ * the fields come through `flattenFields` rather than a descent written here. `@stonecrop/schema`
103
+ * owns that descent and the adapter reads the same one, which is what keeps the keys this looks
104
+ * for and the keys the server writes from drifting apart.
105
+ */
106
+ function applyLinkDisplayFields(flat: Record<string, any>, fields: DoctypeField[]): void {
107
+ const { inlineLinks, displayKeys } = linkFieldSets(fields)
108
+
109
+ for (const fieldname of inlineLinks) {
110
+ const raw = flat[fieldname]
111
+ const display = flat[linkDisplayFieldname(fieldname)]
112
+ if (
113
+ raw != null &&
114
+ (typeof raw === 'string' || typeof raw === 'number') &&
115
+ display != null &&
116
+ (typeof display === 'string' || typeof display === 'number')
117
+ ) {
118
+ flat[fieldname] = { id: raw, displayText: String(display) }
119
+ }
120
+ }
121
+
122
+ // Dropped for every link, not just the inline ones this folded: the sibling is a delivery
123
+ // detail of the read and was never a field of the record.
124
+ for (const key of displayKeys) delete flat[key]
125
+ }
126
+
127
+ /**
128
+ * Unwrap an inline link's value back to the id that gets persisted. Applied only to fields in
129
+ * `inlineLinks` — the display text is a rendering concern and was never part of the record, while
130
+ * an expanded link's object is the record and must survive the round trip intact.
131
+ */
132
+ function unwrapLinkValue(value: unknown): unknown {
133
+ if (value !== null && typeof value === 'object' && !Array.isArray(value) && 'id' in value) {
134
+ return (value as { id: unknown }).id
135
+ }
136
+ return value
137
+ }
138
+
62
139
  const { availableDoctypes = [], routeAdapter } = defineProps<{
63
140
  availableDoctypes?: string[]
64
141
  /**
@@ -182,6 +259,7 @@ const currentViewData = computed<Record<string, any>>({
182
259
  // so we nest fieldset children here before AForm renders them.
183
260
  const doctype = stonecrop.value.registry.registry[currentDoctype.value]
184
261
  if (doctype) {
262
+ applyLinkDisplayFields(flat, doctype.getSchemaArray())
185
263
  for (const field of doctype.getSchemaArray()) {
186
264
  if (field.kind === 'fieldset') {
187
265
  const nested: Record<string, any> = {}
@@ -216,6 +294,7 @@ const currentViewData = computed<Record<string, any>>({
216
294
  }
217
295
  }
218
296
  }
297
+ const { inlineLinks, displayKeys } = linkFieldSets(doctype ? doctype.getSchemaArray() : [])
219
298
 
220
299
  // Two-pass flatten: non-fieldset keys first, then fieldset children.
221
300
  // Fieldset children must be applied last — AForm may emit stale flat copies
@@ -224,6 +303,7 @@ const currentViewData = computed<Record<string, any>>({
224
303
  const flatData: Record<string, any> = {}
225
304
  const fieldsetValues: Record<string, any>[] = []
226
305
  for (const [key, value] of Object.entries(newData)) {
306
+ if (displayKeys.has(key)) continue
227
307
  if (fieldsetNames.has(key) && value && typeof value === 'object' && !Array.isArray(value)) {
228
308
  fieldsetValues.push(value)
229
309
  } else {
@@ -243,9 +323,10 @@ const currentViewData = computed<Record<string, any>>({
243
323
  // cached object is what made a draft's edits vanish on any invalidation.
244
324
  const next = { ...draftRecord.value }
245
325
  for (const [fieldname, value] of Object.entries(flatData)) {
246
- if (value === undefined) continue
247
- if (next[fieldname] !== value) {
248
- next[fieldname] = value
326
+ if (value === undefined || displayKeys.has(fieldname)) continue
327
+ const normalized = inlineLinks.has(fieldname) ? unwrapLinkValue(value) : value
328
+ if (next[fieldname] !== normalized) {
329
+ next[fieldname] = normalized
249
330
  changedFields.push(fieldname)
250
331
  }
251
332
  }
@@ -253,11 +334,12 @@ const currentViewData = computed<Record<string, any>>({
253
334
  } else {
254
335
  const hstStore = stonecrop.value.getStore()
255
336
  for (const [fieldname, value] of Object.entries(flatData)) {
256
- if (value === undefined) continue
337
+ if (value === undefined || displayKeys.has(fieldname)) continue
338
+ const normalized = inlineLinks.has(fieldname) ? unwrapLinkValue(value) : value
257
339
  const fieldPath = `${currentDoctype.value}.${currentRecordId.value}.${fieldname}`
258
340
  const currentValue = hstStore.has(fieldPath) ? hstStore.get(fieldPath) : undefined
259
- if (currentValue !== value) {
260
- hstStore.set(fieldPath, value)
341
+ if (currentValue !== normalized) {
342
+ hstStore.set(fieldPath, normalized)
261
343
  changedFields.push(fieldname)
262
344
  }
263
345
  }
@@ -960,23 +1042,29 @@ provide('aformLinkNavigator', {
960
1042
  },
961
1043
  } satisfies AFormLinkNavigator)
962
1044
 
963
- function toDisplayString(rec: Record<string, unknown> | undefined): string | undefined {
964
- if (!rec) return undefined
965
- const val = rec.name ?? rec.title ?? rec.displayText
966
- return typeof val === 'string' || typeof val === 'number' ? String(val) : undefined
967
- }
968
-
969
1045
  // Provide a resolver for AFormLink to look up display text by doctype + id.
970
1046
  // Checks HST first (sync); falls back to an async client fetch if not cached.
1047
+ // Uses the target doctype's declared displayField — no heuristic field guessing.
971
1048
  provide('aformLinkResolver', async (doctypeSlug: string, id: string): Promise<string | undefined> => {
972
1049
  if (!stonecrop.value) return undefined
973
1050
  try {
1051
+ const meta = await stonecrop.value.getMeta({ path: `/${doctypeSlug}`, segments: [doctypeSlug] })
1052
+ const displayField = meta?.displayField
1053
+ if (!displayField) return undefined
1054
+
1055
+ const readDisplay = (rec: Record<string, unknown> | undefined): string | undefined => {
1056
+ if (!rec) return undefined
1057
+ const val = rec[displayField]
1058
+ return typeof val === 'string' || typeof val === 'number' ? String(val) : undefined
1059
+ }
1060
+
974
1061
  const cached = stonecrop.value.getRecordById(doctypeSlug, id)?.get('') as Record<string, unknown> | undefined
975
- const cachedDisplay = toDisplayString(cached)
1062
+ const cachedDisplay = readDisplay(cached)
976
1063
  if (cachedDisplay != null) return cachedDisplay
1064
+
977
1065
  await stonecrop.value.getRecord(doctypeSlug, id)
978
1066
  const fetched = stonecrop.value.getRecordById(doctypeSlug, id)?.get('') as Record<string, unknown> | undefined
979
- return toDisplayString(fetched)
1067
+ return readDisplay(fetched)
980
1068
  } catch {
981
1069
  return undefined
982
1070
  }