@globalbrain/sefirot 4.59.0 → 4.60.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.
@@ -61,6 +61,16 @@ export interface FieldDataBase {
61
61
  showOnDetail?: boolean
62
62
  showOnCreate?: boolean
63
63
  showOnUpdate?: boolean
64
+ /**
65
+ * The server-declared blank for the field's input, as a wire-format
66
+ * payload value — the server owns this entirely: types whose blank isn't
67
+ * null (a multiple select's `[]`) declare it here, and an entity may set
68
+ * its own (e.g. `'draft'` for a status select). Feeds `inputEmptyValue`:
69
+ * the create form seeds from it, and an editor opening on a null stored
70
+ * value falls back to it. Null or absent (backends predating the key)
71
+ * means a null blank.
72
+ */
73
+ emptyValue?: any
64
74
  }
65
75
 
66
76
  export interface AvatarFieldData extends FieldDataBase {
@@ -74,6 +74,11 @@ export interface Props {
74
74
  // normally. The value remains accessible via `record[indexField]` in
75
75
  // `cell-clicked` events and through the `selected` model regardless.
76
76
  //
77
+ // When omitted, the identifier defaults to `id`, and the `selected`
78
+ // model is keyed by it on any catalog — editable or not — whenever the
79
+ // loaded rows carry it (server-default selects typically do). Only rows
80
+ // without the identifier fall back to positional selection keys.
81
+ //
77
82
  // If the caller's `select` is empty or `null` (i.e. "use server
78
83
  // defaults"), the index field is **not** auto-appended — that would
79
84
  // narrow the result to just that single column on backends that treat
@@ -584,33 +589,59 @@ const tableSelect = computed(() => {
584
589
  const idField = computed(() => props.indexField ?? 'id')
585
590
 
586
591
  // Whether the currently loaded rows actually carry the effective row identifier.
587
- // Editing / opening a row needs it (`resolveId`), but rows fetched while
588
- // `editable` was still false or before an `indexField` change settled — won't
589
- // have it until the triggered refetch lands. Editing is gated on this (see the
590
- // `editable` getter) so a quick save in that window can't post `id: undefined`.
591
- // An empty result has nothing to edit, so it doesn't block.
592
+ // Editing / opening a row needs it (`resolveId`), and the table's selection key
593
+ // only switches onto it when present (`tableIndexField` below). Rows fetched
594
+ // while `editable` was still false or before an `indexField` change settled
595
+ // won't have it until the triggered refetch lands. Editing is gated on this (see
596
+ // the `editable` getter) so a quick save in that window can't post
597
+ // `id: undefined`. An empty result has nothing to edit or select, so it
598
+ // doesn't block.
592
599
  const rowsCarryIndexField = computed(() => {
593
600
  const rows = result.value?.data
594
601
  if (!rows || rows.length === 0) { return true }
595
602
  return idField.value in rows[0]
596
603
  })
597
604
 
598
- // The row-identifier the table uses as its selection key. An explicit
599
- // `indexField`, or `id` for editable catalogs (which always carry it). Without
600
- // this, an editable catalog that relies on the default would leave the table on
601
- // positional selection keys, so an optimistic create/delete that shifts rows
602
- // would leave `selected` pointing at the wrong records. Mirrors `edit.indexField`.
605
+ // The row-identifier the table uses as its selection key: the effective
606
+ // `idField`, on any catalog whose loaded rows carry it. Without this, a catalog
607
+ // relying on the default `id` would leave the table on positional selection
608
+ // keys, so anything that shifts rows — an optimistic create/delete, a page
609
+ // change, a re-sort, a refresh-banner apply would leave `selected` pointing
610
+ // at the wrong records. Editable or not: a read-only catalog with
611
+ // `v-model:selected` (a picker dialog, say) needs identity keys just as much,
612
+ // and a server-default select typically already carries `id`. Mirrors
613
+ // `edit.indexField`.
603
614
  //
604
- // Gated on `rowsCarryIndexField`: when `editable` flips true (or `indexField`
605
- // changes) the on-screen rows don't carry the new key until the triggered refetch
606
- // lands. Switching STable onto it during that window keys every row's selection to
607
- // `undefined` wiping the current selection and making any row-select emit
608
- // `[undefined]` (so parent bulk actions act on no/wrong records). Stay on the
609
- // previous positional key until the loaded rows carry it, matching the
610
- // `edit.editable` gate so selection and editing flip together.
615
+ // Gated on `rowsCarryIndexField`: while the loaded rows don't carry the key —
616
+ // an explicit `select` that omits it, or an `indexField` change (on an editable
617
+ // catalog the triggered refetch below brings the new key in; a read-only
618
+ // catalog stays positional until a fetch happens to include it) — switching
619
+ // STable onto it would key every row's selection to `undefined`, wiping the
620
+ // current selection and making any row-select emit `[undefined]` (so parent
621
+ // bulk actions act on no/wrong records). Stay on the positional key until the
622
+ // loaded rows carry it.
611
623
  const tableIndexField = computed(() => {
612
- const field = props.indexField ?? (props.editable ? 'id' : undefined)
613
- return field && rowsCarryIndexField.value ? field : undefined
624
+ return rowsCarryIndexField.value ? idField.value : undefined
625
+ })
626
+
627
+ // `selected` keys live in `tableIndexField`'s domain — record identity vs row
628
+ // position. If that domain changes while rows are on screen (a view change
629
+ // dropping the identifier column, one re-adding it, a runtime `indexField`
630
+ // swap), the held keys mean nothing under the new domain — but STable's
631
+ // selection pruning keeps any that happen to *collide* with it (a numeric id
632
+ // surviving as a row position, or vice versa), silently re-pointing the
633
+ // selection at wrong rows for the parent's bulk actions. Clear it instead.
634
+ //
635
+ // A flip settling on a fetch that follows an empty table (`hadRows` false —
636
+ // first load, or a filter state with no matches) doesn't count: nothing was
637
+ // selectable under the previous domain, so anything already in `selected` is a
638
+ // parent-provided seed targeting the domain being settled, and must survive it.
639
+ let hadRows = false
640
+ watch([tableIndexField, () => result.value?.data], ([field], [prevField]) => {
641
+ if (field !== prevField && hadRows && selected.value?.length) {
642
+ selected.value = []
643
+ }
644
+ hadRows = (result.value?.data?.length ?? 0) > 0
614
645
  })
615
646
 
616
647
  // The `indexField` is appended to the request `select` so the server
@@ -622,10 +653,16 @@ const tableIndexField = computed(() => {
622
653
  //
623
654
  // Editable catalogs must carry a row identifier so updates / deletes can
624
655
  // address each row; when no `indexField` is configured the identifier
625
- // defaults to `id`. When the caller has no concrete select list, nothing
626
- // is added either leaving the request empty lets the server use its own
627
- // defaults (which include `id`, so the read-only sheet still opens). See
628
- // `indexField` prop docs above.
656
+ // defaults to `id`. Read-only catalogs get no *default* identifier appended
657
+ // (an explicit `indexField` is always included, per its prop doc): the
658
+ // selection key (`tableIndexField` above) is opportunistic it keys by the
659
+ // identifier when the rows already carry it (server defaults typically
660
+ // include `id`) and stays positional when an explicit `select` omits it,
661
+ // rather than growing every read-only request by an extra column. When the
662
+ // caller has no concrete select list, nothing is added either — leaving the
663
+ // request empty lets the server use its own defaults (which typically
664
+ // include `id`, so the read-only sheet still opens). See `indexField` prop
665
+ // docs above.
629
666
  function withIndexField(fields: string[]): string[] {
630
667
  if (fields.length === 0) { return [] }
631
668
  const field = props.indexField ?? (props.editable ? 'id' : null)
@@ -95,7 +95,7 @@ const formEl = ref<HTMLElement | null>(null)
95
95
 
96
96
  function start() {
97
97
  const raw = props.record[props.fieldKey]
98
- model.value = props.field.payloadToInput(raw ?? props.field.inputEmptyValue())
98
+ model.value = raw != null ? props.field.payloadToInput(raw) : props.field.inputEmptyValue()
99
99
  reset()
100
100
  editing.value = true
101
101
  // Focus the input on open (matching the inline table editor): better UX, and
@@ -236,9 +236,10 @@ const table = useTable({
236
236
  orders,
237
237
  columns,
238
238
  // A getter (not a snapshot) so the selection key stays reactive: `indexField`
239
- // can change after mount — e.g. an editable catalog resolves permissions async
240
- // and flips from positional keys to `id`. STable reads this inside a computed,
241
- // so the getter establishes the dependency and selection re-keys correctly.
239
+ // can change after mount — e.g. rows landing without the identifier flip the
240
+ // catalog from identity keys back to positional, and an `indexField` change
241
+ // settling re-keys it. STable reads this inside a computed, so the getter
242
+ // establishes the dependency and selection re-keys correctly.
242
243
  get indexField() { return props.indexField },
243
244
  borderless: true
244
245
  })
@@ -193,7 +193,8 @@ function startNames() {
193
193
  return
194
194
  }
195
195
  for (const entry of nameEntries.value) {
196
- nameModel[entry.key] = entry.field.payloadToInput(props.record[entry.key] ?? entry.field.inputEmptyValue())
196
+ const raw = props.record[entry.key]
197
+ nameModel[entry.key] = raw != null ? entry.field.payloadToInput(raw) : entry.field.inputEmptyValue()
197
198
  }
198
199
  reset()
199
200
  inline?.start(myKey.value)
@@ -156,7 +156,8 @@ function start() {
156
156
  }
157
157
 
158
158
  inputComponent.value = props.field.formInputComponent()
159
- model.value = props.field.payloadToInput(props.value ?? props.field.inputEmptyValue())
159
+ const raw = props.value
160
+ model.value = raw != null ? props.field.payloadToInput(raw) : props.field.inputEmptyValue()
160
161
  reset()
161
162
  inline?.start(myKey.value)
162
163
  nextTick(() => {
@@ -56,10 +56,6 @@ export class AvatarField extends Field<AvatarFieldData> {
56
56
  return false
57
57
  }
58
58
 
59
- override inputEmptyValue(): any {
60
- return null
61
- }
62
-
63
59
  override formInputComponent(): any {
64
60
  return this.defineFormInputComponent((props, { emit }) => {
65
61
  return () => h(LensInputAvatar, {
@@ -255,10 +255,21 @@ export abstract class Field<T extends FieldData> {
255
255
  }
256
256
 
257
257
  /**
258
- * Returns the value should be used when the input is empty.
258
+ * The value an empty input starts from: the definition's `emptyValue`, a
259
+ * wire-format payload value run through `payloadToInput`. The server owns
260
+ * the blank entirely — field types with a non-null blank (a multiple
261
+ * select's `[]`, say) declare it in their definition, so there are no
262
+ * per-type overrides here. Only null/undefined fall back to a null blank,
263
+ * so `false` and `0` are real values.
264
+ *
265
+ * Feeds the create-form seeding and the editors' null fallback alike, so
266
+ * a non-blank `emptyValue` also pre-fills the editor of a record whose
267
+ * stored value is null.
259
268
  */
260
269
  inputEmptyValue(): any {
261
- return null
270
+ return this.data.emptyValue != null
271
+ ? this.payloadToInput(this.data.emptyValue)
272
+ : null
262
273
  }
263
274
 
264
275
  /**
@@ -49,10 +49,6 @@ export class FileUploadField extends Field<FileUploadFieldData> {
49
49
  })
50
50
  }
51
51
 
52
- override inputEmptyValue(): any {
53
- return []
54
- }
55
-
56
52
  override formInputComponent(): any {
57
53
  return this.defineFormInputComponent((props, { emit }) => {
58
54
  return () => h(SInputFileUpload, {
@@ -145,17 +145,11 @@ export class SelectField extends Field<SelectFieldData> {
145
145
  }
146
146
 
147
147
  protected renderDataListItemValueForText(value: any): any {
148
- if (this.data.multiple) {
149
- value = Array.isArray(value) ? value : [value]
150
- }
148
+ value = Array.isArray(value) ? value : [value]
151
149
 
152
150
  return this.optionsForValues(value).map((o) => this.labelForOption(o)).join(', ')
153
151
  }
154
152
 
155
- override inputEmptyValue(): any {
156
- return this.data.multiple ? [] : null
157
- }
158
-
159
153
  override formInputComponent(): any {
160
154
  switch (this.data.inputAs) {
161
155
  case 'dropdown':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@globalbrain/sefirot",
3
- "version": "4.59.0",
3
+ "version": "4.60.0",
4
4
  "description": "Vue Components for Global Brain Design System.",
5
5
  "keywords": [
6
6
  "components",
@@ -59,8 +59,8 @@
59
59
  "@iconify-json/ph": "^1.2.2",
60
60
  "@iconify-json/ri": "^1.2.10",
61
61
  "@popperjs/core": "^2.11.8",
62
- "@sentry/browser": "^10.62.0",
63
- "@sentry/vue": "^10.62.0",
62
+ "@sentry/browser": "^10.63.0",
63
+ "@sentry/vue": "^10.63.0",
64
64
  "@tanstack/vue-virtual": "3.0.0-beta.62",
65
65
  "@tinyhttp/content-disposition": "^2.2.4",
66
66
  "@tinyhttp/cookie": "^2.1.1",
@@ -84,11 +84,11 @@
84
84
  "jsdom": "^29.1.1",
85
85
  "lodash-es": "^4.18.1",
86
86
  "magic-string": "^0.30.21",
87
- "markdown-it": "^14.2.0",
87
+ "markdown-it": "^14.3.0",
88
88
  "normalize.css": "^8.0.1",
89
89
  "ofetch": "^1.5.1",
90
90
  "pinia": "^3.0.4",
91
- "postcss": "^8.5.15",
91
+ "postcss": "^8.5.16",
92
92
  "postcss-nested": "^7.0.2",
93
93
  "punycode": "^2.3.1",
94
94
  "qs": "^6.15.3",
@@ -104,8 +104,8 @@
104
104
  "@histoire/plugin-vue": "1.0.0-beta.1",
105
105
  "@release-it/conventional-changelog": "^11.0.1",
106
106
  "@types/jsdom": "^28.0.3",
107
- "@types/node": "^26.0.1",
108
- "@typescript-eslint/rule-tester": "^8.62.0",
107
+ "@types/node": "^26.1.0",
108
+ "@typescript-eslint/rule-tester": "^8.62.1",
109
109
  "@vitest/coverage-v8": "^4.1.9",
110
110
  "@vue/test-utils": "^2.4.11",
111
111
  "eslint": "^9.39.4",
@@ -115,7 +115,7 @@
115
115
  "typescript": "~6.0.3",
116
116
  "vitepress": "^2.0.0-alpha.17",
117
117
  "vitest": "^4.1.9",
118
- "vue-tsc": "^3.3.5"
118
+ "vue-tsc": "^3.3.6"
119
119
  },
120
120
  "packageManager": "pnpm@11.9.0"
121
121
  }