@globalbrain/sefirot 4.50.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.
@@ -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,11 +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'
6
7
  import SPill, { type Mode as PillMode } from '../../../components/SPill.vue'
7
8
  import SState, { type Mode as StateMode } from '../../../components/SState.vue'
8
9
  import { useManualDropdownPosition } from '../../../composables/Dropdown'
9
10
  import { useTrans } from '../../../composables/Lang'
10
11
  import { useValidation } from '../../../composables/Validation'
12
+ import { day } from '../../../support/Day'
11
13
  import { type FieldData } from '../FieldData'
12
14
  import { useLensEdit } from '../composables/LensEdit'
13
15
  import { useLensInlineEdit } from '../composables/LensInlineEdit'
@@ -31,6 +33,10 @@ const inline = useLensInlineEdit()
31
33
  const myKey = computed(() => `${edit?.resolveId(props.record)}:${props.fieldKey}`)
32
34
  const editing = computed(() => inline?.activeKey.value === myKey.value)
33
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
+
34
40
  // If the backing value is replaced while this editor is open — a refresh banner
35
41
  // apply, a parent `refresh()`, or a requery that keeps this row visible — the
36
42
  // `model` captured back in `start()` is now stale, and saving it would overwrite
@@ -81,6 +87,18 @@ const displayState = computed<{ label: string; mode?: StateMode } | null>(() =>
81
87
  return cell && cell.type === 'state' ? { label: cell.label, mode: cell.mode } : null
82
88
  })
83
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
+
84
102
  // Falls back to a plain representation for non-text displays (pills and state
85
103
  // cells are rendered as their own components in the template above).
86
104
  const displayValue = computed(() => {
@@ -89,6 +107,14 @@ const displayValue = computed(() => {
89
107
  return resolved.value ?? ''
90
108
  }
91
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') : ''
116
+ }
117
+
92
118
  const v = props.value
93
119
  if (v == null) {
94
120
  return ''
@@ -123,6 +149,10 @@ const editorStyle = computed(() => ({
123
149
  }))
124
150
 
125
151
  function start() {
152
+ if (!canEdit.value) {
153
+ return
154
+ }
155
+
126
156
  inputComponent.value = props.field.formInputComponent()
127
157
  model.value = props.field.payloadToInput(props.value ?? props.field.inputEmptyValue())
128
158
  reset()
@@ -161,6 +191,19 @@ async function apply() {
161
191
  return
162
192
  }
163
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
+
164
207
  // Optimistic: patch + persist in the background, then close immediately.
165
208
  edit!.save(props.record, {
166
209
  [props.fieldKey]: props.field.inputToPayload(model.value)
@@ -217,8 +260,18 @@ function isTextLikeInput(target: EventTarget | null): boolean {
217
260
  :mode="displayState.mode"
218
261
  :label="displayState.label"
219
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>
220
272
  <span v-else class="value">{{ displayValue }}</span>
221
273
  <button
274
+ v-if="canEdit"
222
275
  class="edit"
223
276
  type="button"
224
277
  :aria-label="`${t.edit} ${field.label()}`"
@@ -266,6 +319,15 @@ function isTextLikeInput(target: EventTarget | null): boolean {
266
319
  text-overflow: ellipsis;
267
320
  }
268
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
+
269
331
  .pills {
270
332
  display: flex;
271
333
  flex-wrap: nowrap;
@@ -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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@globalbrain/sefirot",
3
- "version": "4.50.0",
3
+ "version": "4.51.0",
4
4
  "description": "Vue Components for Global Brain Design System.",
5
5
  "keywords": [
6
6
  "components",