@globalbrain/sefirot 4.49.0 → 4.51.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.
@@ -146,7 +146,7 @@ export interface DecimalFieldData extends FieldDataBase {
146
146
 
147
147
  export interface SelectFieldData extends FieldDataBase {
148
148
  type: 'select'
149
- displayAs: 'text' | 'state'
149
+ displayAs: 'text' | 'state' | 'pills'
150
150
  inputAs: 'dropdown' | 'radio'
151
151
  placeholderEn: string | null
152
152
  placeholderJa: string | null
@@ -147,24 +147,47 @@ export interface Props {
147
147
  // *custom* `indexField`, however, must be listed in `select` explicitly (an
148
148
  // empty/default `select` drops it from the rendered columns, leaving the rows
149
149
  // with no opener).
150
- editable?: boolean
151
-
152
- // Whether new records can be created (enables create mode in the sheet
153
- // and the exposed `openCreate()` method). Requires `editable`.
150
+ //
151
+ // Turning this on also enables `creatable`, `deletable`, and `inlineEditable`
152
+ // by default; pass `false` to any of them to opt out.
153
+ //
154
+ // Pass a predicate `(record) => boolean` instead of `true` to allow editing
155
+ // only some rows (e.g. from a per-record policy): the inline affordance and
156
+ // the sheet's per-field edit are hidden for the rows it rejects.
157
+ editable?: boolean | ((record: Record<string, any>) => boolean)
158
+
159
+ // Whether records can be deleted from the sheet. Defaults to enabled for an
160
+ // editable catalog. Pass `false` to hide the delete button entirely, or a
161
+ // predicate `(record) => boolean` to allow deleting only some rows (e.g. from
162
+ // a per-record policy). Delete is reachable only through the sheet, so this
163
+ // has no effect unless the catalog is `editable`.
164
+ deletable?: boolean | ((record: Record<string, any>) => boolean)
165
+
166
+ // Whether new records can be created (enables create mode in the sheet and
167
+ // the exposed `openCreate()` method). Defaults to enabled when the catalog is
168
+ // `editable`; pass `false` to disable.
154
169
  creatable?: boolean
155
170
 
156
171
  // Width of the record sheet (any valid CSS width). Defaults to `480px`.
157
172
  sheetWidth?: string
158
173
 
159
- // Enable inline editing directly in the table: cells for `showOnUpdate`
160
- // fields gain a hover edit affordance that opens an inline editor. Requires
161
- // `editable` (it reuses the same CRUD edit context as the sheet).
162
- inlineEdit?: boolean
174
+ // Enable inline editing directly in the table: cells for `showOnUpdate` fields
175
+ // gain a hover edit affordance that opens an inline editor (reusing the sheet's
176
+ // CRUD edit context). Defaults to enabled when the catalog is `editable`; pass
177
+ // `false` to restrict editing to the record sheet.
178
+ inlineEditable?: boolean
163
179
  }
164
180
 
165
181
  const props = withDefaults(defineProps<Props>(), {
166
182
  canFilter: true,
167
- canSort: true
183
+ canSort: true,
184
+ // Default these on so an editable catalog gets create / delete / inline edit
185
+ // without opting in to each. A boolean prop can't tell "omitted" from `false`
186
+ // (Vue casts an absent boolean to `false`), so they default on here and the
187
+ // resolvers below gate them on `editable`; pass the prop `false` to opt out.
188
+ creatable: true,
189
+ deletable: true,
190
+ inlineEditable: true
168
191
  })
169
192
 
170
193
  const selected = defineModel<any[]>('selected')
@@ -1121,9 +1144,10 @@ async function openSheet(record: Record<string, any>): Promise<void> {
1121
1144
  }
1122
1145
 
1123
1146
  function openCreate(): void {
1124
- // `creatable` is the prop that enables creation. Honor it here even though
1125
- // this method is exposed (and provided to children).
1126
- if (!props.creatable) {
1147
+ // Creation is enabled by `creatable`, which defaults to on for an editable
1148
+ // catalog. Honor it here even though this method is exposed (and provided to
1149
+ // children).
1150
+ if (!props.editable || !props.creatable) {
1127
1151
  return
1128
1152
  }
1129
1153
  // Supersede any in-flight openSheet `/show` so it can't open over the create
@@ -1136,6 +1160,27 @@ function openCreate(): void {
1136
1160
  sheet.on()
1137
1161
  }
1138
1162
 
1163
+ // Catalog-level editing gate: `editable` is set (boolean `true` or a predicate)
1164
+ // and the rows carry the index field needed to resolve a record's id.
1165
+ function editEnabled(): boolean {
1166
+ return !!props.editable && rowsCarryIndexField.value
1167
+ }
1168
+
1169
+ // Per-record refinement: a predicate `editable`/`deletable` decides each row, a
1170
+ // boolean applies to all.
1171
+ function canEdit(record: Record<string, any>): boolean {
1172
+ return editEnabled() && (typeof props.editable === 'function' ? props.editable(record) : true)
1173
+ }
1174
+
1175
+ // Delete is a stronger action than edit and rides the same editable sheet, so a
1176
+ // row must be editable before it can be deleted — building on `canEdit` makes a
1177
+ // per-record `editable` predicate gate delete too (a row it rejects is never
1178
+ // deletable). `deletable` then refines further: on unless explicitly `false`, or
1179
+ // its own per-record predicate.
1180
+ function canDelete(record: Record<string, any>): boolean {
1181
+ return canEdit(record) && (typeof props.deletable === 'function' ? props.deletable(record) : props.deletable)
1182
+ }
1183
+
1139
1184
  provideLensEdit({
1140
1185
  // Getters so the injected context tracks prop changes after mount (e.g.
1141
1186
  // permissions resolving async, or a flag toggling `editable` off): LensTable
@@ -1145,8 +1190,8 @@ provideLensEdit({
1145
1190
  // trigger a refetch to pull in the identifier, but it lands asynchronously —
1146
1191
  // keep editing off until the rows actually carry it, so a save in that window
1147
1192
  // can't `resolveId()` to `undefined`.
1148
- get editable() { return !!props.editable && rowsCarryIndexField.value },
1149
- get creatable() { return !!props.creatable },
1193
+ get editable() { return editEnabled() },
1194
+ get creatable() { return !!props.editable && props.creatable },
1150
1195
  // Use the same `__no_entity__` fallback as the search / CRUD requests so slot
1151
1196
  // side-channel saves (which read this) target the same entity, not an empty one.
1152
1197
  get entity() { return entityName.value },
@@ -1155,6 +1200,8 @@ provideLensEdit({
1155
1200
  // resolves (or changes) after mount — otherwise the new identifier column stays
1156
1201
  // non-clickable while the old field is still treated as the id.
1157
1202
  get indexField() { return props.indexField ?? 'id' },
1203
+ canEdit,
1204
+ canDelete,
1158
1205
  resolveId,
1159
1206
  save,
1160
1207
  create,
@@ -1288,7 +1335,7 @@ defineExpose({
1288
1335
  :index-field="tableIndexField"
1289
1336
  :selected
1290
1337
  :clickable-fields
1291
- :inline-edit
1338
+ :inline-editable
1292
1339
  @filter-updated="onInlineFilterUpdated"
1293
1340
  @sort-updated="onSortUpdated"
1294
1341
  @update:selected="onUpdateSelected"
@@ -126,6 +126,11 @@ const saving = ref(false)
126
126
  // sheet opened; `onCreate` re-checks it too.
127
127
  const creatable = computed(() => !!edit?.creatable)
128
128
 
129
+ // Whether the open record may be deleted. Reactive (a getter on the edit
130
+ // context) so the delete button hides if a per-record `deletable` predicate, or
131
+ // a permission change, rejects it after the sheet has opened.
132
+ const deletable = computed(() => !!props.record && !!edit?.canDelete(props.record))
133
+
129
134
  // Backend-only validation errors (e.g. `unique`) returned by a rejected
130
135
  // create, fed to Vuelidate via `$externalResults` so they surface on the
131
136
  // offending field. The create form's keys are the bare field keys, matching
@@ -255,7 +260,11 @@ function saveRecord(values: Record<string, any>): Promise<void> {
255
260
  // but saving against the partial row mid-`/show` (or after it failed) could
256
261
  // overwrite a not-yet-loaded detail field with an empty value — the built-in
257
262
  // fields avoid this by not rendering until the record is ready.
258
- if (props.loading || props.error || !props.record || !edit) {
263
+ //
264
+ // Also honor the per-record edit gate: a custom slot editor funnels through
265
+ // here, so a row a `editable` predicate rejects must not be saved through the
266
+ // slot any more than through the built-in cell / field editors.
267
+ if (props.loading || props.error || !props.record || !edit || !edit.canEdit(props.record)) {
259
268
  return Promise.resolve()
260
269
  }
261
270
  edit.save(props.record, values)
@@ -270,6 +279,10 @@ const slotProps = computed(() => ({
270
279
  // record has loaded; `save` also hard-refuses while loading/error as a guard.
271
280
  loading: props.loading ?? false,
272
281
  error: props.error ?? false,
282
+ // Whether a per-record `editable` predicate allows editing this row, so a slot
283
+ // editor can disable its own controls for a rejected row; `save` enforces it
284
+ // regardless, but otherwise the refusal is only visible as a silent no-op.
285
+ canEdit: !!props.record && !!edit?.canEdit(props.record),
273
286
  save: saveRecord
274
287
  }))
275
288
  </script>
@@ -338,7 +351,7 @@ const slotProps = computed(() => ({
338
351
  @click="onCreate"
339
352
  />
340
353
  </template>
341
- <template v-else-if="record && !loading && !error">
354
+ <template v-else-if="record && !loading && !error && deletable">
342
355
  <template v-if="confirmingDelete.state.value">
343
356
  <span class="confirm-text">{{ t.confirm_delete }}</span>
344
357
  <SButton size="medium" :label="t.cancel" @click="confirmingDelete.off" />
@@ -457,6 +470,10 @@ const slotProps = computed(() => ({
457
470
  border-top: 1px solid var(--c-divider);
458
471
  }
459
472
 
473
+ .footer:empty {
474
+ display: none;
475
+ }
476
+
460
477
  .confirm-text {
461
478
  margin-right: auto;
462
479
  font-size: 13px;
@@ -22,7 +22,7 @@ const { t } = useTrans({
22
22
 
23
23
  const edit = useLensEdit()
24
24
 
25
- const editable = computed(() => !!edit && (props.field as any).data?.showOnUpdate === true)
25
+ const editable = computed(() => !!edit?.canEdit(props.record) && (props.field as any).data?.showOnUpdate === true)
26
26
 
27
27
  // Some field types do not implement a detail renderer or an editable input
28
28
  // (they `throw new Error('Not implemented.')`). Resolve them defensively so a
@@ -121,6 +121,14 @@ async function apply() {
121
121
  return
122
122
  }
123
123
 
124
+ // A per-record `editable` predicate can flip to reject this row while the editor
125
+ // is open (e.g. a refresh marks it locked). Re-check before persisting so an
126
+ // already-open editor can't save a row the policy now rejects.
127
+ if (!canEdit.value) {
128
+ editing.value = false
129
+ return
130
+ }
131
+
124
132
  // Optimistic: patch + persist in the background, then close immediately.
125
133
  edit!.save(props.record, {
126
134
  [props.fieldKey]: props.field.inputToPayload(model.value)
@@ -27,7 +27,7 @@ const props = defineProps<{
27
27
  indexField?: string
28
28
  // When set (and editing is enabled), cells for `showOnUpdate` fields
29
29
  // render a hover edit affordance that opens an inline editor.
30
- inlineEdit?: boolean
30
+ inlineEditable?: boolean
31
31
  }>()
32
32
 
33
33
  const emit = defineEmits<{
@@ -140,7 +140,7 @@ const columns = computedAsync(async () => {
140
140
  } as TableCell<any, any>
141
141
  }
142
142
  } else if (
143
- props.inlineEdit
143
+ props.inlineEditable
144
144
  && edit?.editable
145
145
  && key !== edit.indexField
146
146
  && overriddenFieldData.showOnUpdate === true
@@ -3,9 +3,13 @@ import IconPencilSimple from '~icons/ph/pencil-simple'
3
3
  import { useElementBounding } from '@vueuse/core'
4
4
  import { computed, nextTick, onUnmounted, ref, shallowRef, watch } from 'vue'
5
5
  import SButton from '../../../components/SButton.vue'
6
+ import SLink from '../../../components/SLink.vue'
7
+ import SPill, { type Mode as PillMode } from '../../../components/SPill.vue'
8
+ import SState, { type Mode as StateMode } from '../../../components/SState.vue'
6
9
  import { useManualDropdownPosition } from '../../../composables/Dropdown'
7
10
  import { useTrans } from '../../../composables/Lang'
8
11
  import { useValidation } from '../../../composables/Validation'
12
+ import { day } from '../../../support/Day'
9
13
  import { type FieldData } from '../FieldData'
10
14
  import { useLensEdit } from '../composables/LensEdit'
11
15
  import { useLensInlineEdit } from '../composables/LensInlineEdit'
@@ -29,6 +33,10 @@ const inline = useLensInlineEdit()
29
33
  const myKey = computed(() => `${edit?.resolveId(props.record)}:${props.fieldKey}`)
30
34
  const editing = computed(() => inline?.activeKey.value === myKey.value)
31
35
 
36
+ // A predicate `editable` on the catalog can disable editing per row; hide the
37
+ // affordance and refuse to open the editor when this record is rejected.
38
+ const canEdit = computed(() => !!edit?.canEdit(props.record))
39
+
32
40
  // If the backing value is replaced while this editor is open — a refresh banner
33
41
  // apply, a parent `refresh()`, or a requery that keeps this row visible — the
34
42
  // `model` captured back in `start()` is now stale, and saving it would overwrite
@@ -54,21 +62,57 @@ onUnmounted(() => {
54
62
 
55
63
  // Reuse the field's own table-cell rendering for the display value so the
56
64
  // label mapping (e.g. select option labels) matches the read-only columns.
57
- // Falls back to a plain representation for non-text displays.
58
- const displayValue = computed(() => {
65
+ const resolvedCell = computed<any>(() => {
59
66
  try {
60
67
  const cell = props.field.tableColumn().cell
61
- const resolved: any = typeof cell === 'function' ? cell(props.value, props.record) : cell
62
- if (resolved && (resolved.type === 'text' || resolved.type === 'number')) {
63
- return resolved.value ?? ''
64
- }
65
- // `state` cells (e.g. a select with displayAs: 'state') carry the localized
66
- // label, not the raw payload — show that rather than falling through.
67
- if (resolved && resolved.type === 'state') {
68
- return resolved.label ?? ''
69
- }
68
+ return typeof cell === 'function' ? cell(props.value, props.record) : cell
70
69
  } catch {
71
- // Field types without a text cell fall through to the generic display.
70
+ // Field types without a usable cell fall through to the generic display.
71
+ return null
72
+ }
73
+ })
74
+
75
+ // A `pills` cell (e.g. a multi-select with displayAs: 'pills') renders as pills
76
+ // rather than text, mirroring the read-only `STableCellPills` column.
77
+ const displayPills = computed<{ label: string; color?: PillMode }[] | null>(() => {
78
+ const cell = resolvedCell.value
79
+ return cell && cell.type === 'pills' ? cell.pills : null
80
+ })
81
+
82
+ // A `state` cell (e.g. a select with displayAs: 'state') renders as a status
83
+ // badge rather than its bare label, mirroring the read-only `STableCellState`
84
+ // column.
85
+ const displayState = computed<{ label: string; mode?: StateMode } | null>(() => {
86
+ const cell = resolvedCell.value
87
+ return cell && cell.type === 'state' ? { label: cell.label, mode: cell.mode } : null
88
+ })
89
+
90
+ // A text cell carrying a `link` (or `onClick`) — e.g. a LinkField — renders as a
91
+ // link rather than bare text, mirroring the read-only STableCellText so a column
92
+ // that was clickable before inline editing was enabled stays clickable instead of
93
+ // degrading to plain text. The text itself comes from `displayValue` below.
94
+ const displayLink = computed<{ link: string | null; onClick?: (v: any, r: any) => void } | null>(() => {
95
+ const cell = resolvedCell.value
96
+ if (cell && cell.type === 'text' && (cell.link != null || typeof cell.onClick === 'function')) {
97
+ return { link: cell.link ?? null, onClick: cell.onClick }
98
+ }
99
+ return null
100
+ })
101
+
102
+ // Falls back to a plain representation for non-text displays (pills and state
103
+ // cells are rendered as their own components in the template above).
104
+ const displayValue = computed(() => {
105
+ const resolved: any = resolvedCell.value
106
+ if (resolved && (resolved.type === 'text' || resolved.type === 'number')) {
107
+ return resolved.value ?? ''
108
+ }
109
+
110
+ // A `day` cell (e.g. a DateField) carries a Day value plus a format; render the
111
+ // formatted day — mirroring the read-only STableCellDay — so an inline-editable
112
+ // date column shows e.g. `YYYY-MM-DD` rather than the raw ISO/timestamp string
113
+ // the generic fallback below would otherwise surface.
114
+ if (resolved && resolved.type === 'day') {
115
+ return resolved.value ? day(resolved.value).format(resolved.format ?? 'YYYY-MM-DD HH:mm:ss') : ''
72
116
  }
73
117
 
74
118
  const v = props.value
@@ -105,6 +149,10 @@ const editorStyle = computed(() => ({
105
149
  }))
106
150
 
107
151
  function start() {
152
+ if (!canEdit.value) {
153
+ return
154
+ }
155
+
108
156
  inputComponent.value = props.field.formInputComponent()
109
157
  model.value = props.field.payloadToInput(props.value ?? props.field.inputEmptyValue())
110
158
  reset()
@@ -143,6 +191,19 @@ async function apply() {
143
191
  return
144
192
  }
145
193
 
194
+ // A per-record `editable` predicate can flip to reject this row while the editor
195
+ // is open (e.g. a refresh marks it locked) — `start()` only gates opening, so
196
+ // re-check before persisting. Without this an already-open editor could save a
197
+ // row the policy now rejects. Close only if this cell is still the active one
198
+ // (mirrors the post-save close below) so bailing can't wipe an editor the user
199
+ // opened on another cell during the `await validate()`.
200
+ if (!canEdit.value) {
201
+ if (inline?.activeKey.value === myKey.value) {
202
+ inline.stop()
203
+ }
204
+ return
205
+ }
206
+
146
207
  // Optimistic: patch + persist in the background, then close immediately.
147
208
  edit!.save(props.record, {
148
209
  [props.fieldKey]: props.field.inputToPayload(model.value)
@@ -184,8 +245,33 @@ function isTextLikeInput(target: EventTarget | null): boolean {
184
245
 
185
246
  <template>
186
247
  <div ref="anchor" class="LensTableEditableCell" :class="{ editing }">
187
- <span class="value">{{ displayValue }}</span>
248
+ <div v-if="displayPills" class="pills">
249
+ <SPill
250
+ v-for="(pill, i) in displayPills"
251
+ :key="i"
252
+ size="mini"
253
+ :mode="pill.color"
254
+ :label="pill.label"
255
+ />
256
+ </div>
257
+ <SState
258
+ v-else-if="displayState"
259
+ size="mini"
260
+ :mode="displayState.mode"
261
+ :label="displayState.label"
262
+ />
263
+ <SLink
264
+ v-else-if="displayLink"
265
+ class="value link"
266
+ :href="displayLink.link"
267
+ :role="displayLink.onClick ? 'button' : null"
268
+ @click="() => displayLink?.onClick?.(value, record)"
269
+ >
270
+ {{ displayValue }}
271
+ </SLink>
272
+ <span v-else class="value">{{ displayValue }}</span>
188
273
  <button
274
+ v-if="canEdit"
189
275
  class="edit"
190
276
  type="button"
191
277
  :aria-label="`${t.edit} ${field.label()}`"
@@ -233,6 +319,24 @@ function isTextLikeInput(target: EventTarget | null): boolean {
233
319
  text-overflow: ellipsis;
234
320
  }
235
321
 
322
+ .value.link {
323
+ color: var(--c-text-info-1);
324
+ transition: color 0.1s;
325
+ }
326
+
327
+ .value.link:hover {
328
+ color: var(--c-text-info-2);
329
+ }
330
+
331
+ .pills {
332
+ display: flex;
333
+ flex-wrap: nowrap;
334
+ align-items: center;
335
+ gap: 4px;
336
+ min-width: 0;
337
+ overflow: hidden;
338
+ }
339
+
236
340
  .edit {
237
341
  position: absolute;
238
342
  top: 50%;
@@ -13,6 +13,12 @@ export interface LensEditContext {
13
13
  /** Whether new records may be created. */
14
14
  creatable: boolean
15
15
 
16
+ /** Whether the given record may be edited, inline or in the sheet. */
17
+ canEdit: (record: Record<string, any>) => boolean
18
+
19
+ /** Whether the given record may be deleted from the sheet. */
20
+ canDelete: (record: Record<string, any>) => boolean
21
+
16
22
  /** The entity key being edited (e.g. `topic`, `user`). */
17
23
  entity: string
18
24
 
@@ -1,5 +1,6 @@
1
1
  import { xor } from 'lodash-es'
2
2
  import { h } from 'vue'
3
+ import SDescPill from '../../../components/SDescPill.vue'
3
4
  import SInputCheckboxes from '../../../components/SInputCheckboxes.vue'
4
5
  import SInputDropdown from '../../../components/SInputDropdown.vue'
5
6
  import SInputRadios from '../../../components/SInputRadios.vue'
@@ -38,6 +39,8 @@ export class SelectField extends Field<SelectFieldData> {
38
39
  switch (this.data.displayAs) {
39
40
  case 'state':
40
41
  return this.tableCellState(v, _r)
42
+ case 'pills':
43
+ return this.tableCellPills(v, _r)
41
44
  case 'text':
42
45
  return this.tableCellText(v, _r)
43
46
  default:
@@ -45,6 +48,18 @@ export class SelectField extends Field<SelectFieldData> {
45
48
  }
46
49
  }
47
50
 
51
+ protected tableCellPills(v: any, _r: any): TableCell {
52
+ v = Array.isArray(v) ? v : [v]
53
+
54
+ return {
55
+ type: 'pills',
56
+ pills: this.optionsForValues(v).map((o) => ({
57
+ label: this.labelForOption(o),
58
+ color: o.mode
59
+ }))
60
+ }
61
+ }
62
+
48
63
  protected tableCellText(v: any, _r: any): TableCell {
49
64
  v = Array.isArray(v) ? v : [v]
50
65
 
@@ -95,6 +110,8 @@ export class SelectField extends Field<SelectFieldData> {
95
110
  switch (this.data.displayAs) {
96
111
  case 'state':
97
112
  return this.renderDataListItemValueForState(value)
113
+ case 'pills':
114
+ return this.renderDataListItemValueForPills(value)
98
115
  case 'text':
99
116
  return this.renderDataListItemValueForText(value)
100
117
  default:
@@ -103,6 +120,17 @@ export class SelectField extends Field<SelectFieldData> {
103
120
  })
104
121
  }
105
122
 
123
+ protected renderDataListItemValueForPills(value: any): any {
124
+ value = Array.isArray(value) ? value : [value]
125
+
126
+ return h(SDescPill, {
127
+ pill: this.optionsForValues(value).map((o) => ({
128
+ label: this.labelForOption(o),
129
+ mode: o.mode
130
+ }))
131
+ })
132
+ }
133
+
106
134
  protected renderDataListItemValueForState(value: any): any {
107
135
  if (this.data.multiple) {
108
136
  throw new Error('Displaying select field as state with multiple option is not supported.')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@globalbrain/sefirot",
3
- "version": "4.49.0",
3
+ "version": "4.51.0",
4
4
  "description": "Vue Components for Global Brain Design System.",
5
5
  "keywords": [
6
6
  "components",