@globalbrain/sefirot 4.50.0 → 4.52.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.
@@ -7,11 +7,13 @@ import { useMutation, useQuery } from '../../../composables/Api'
7
7
  import { useLang, useTrans } from '../../../composables/Lang'
8
8
  import { usePower } from '../../../composables/Power'
9
9
  import { useSnackbars } from '../../../stores/Snackbars'
10
+ import { day } from '../../../support/Day'
10
11
  import { type FieldData } from '../FieldData'
11
12
  import { type LensQuery, type LensQuerySort } from '../LensQuery'
12
13
  import { type LensResult } from '../LensResult'
13
14
  import { useCatalogUrlQuerySync } from '../composables/CatalogUrlQuerySync'
14
15
  import { provideLensEdit } from '../composables/LensEdit'
16
+ import { extractServerMessage, isAuthError } from '../validation/ServerErrors'
15
17
  import LensCatalogControl, { type FilterPresets } from './LensCatalogControl.vue'
16
18
  import LensCatalogFooter from './LensCatalogFooter.vue'
17
19
  import LensCatalogStateFilter from './LensCatalogStateFilter.vue'
@@ -147,24 +149,47 @@ export interface Props {
147
149
  // *custom* `indexField`, however, must be listed in `select` explicitly (an
148
150
  // empty/default `select` drops it from the rendered columns, leaving the rows
149
151
  // 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`.
152
+ //
153
+ // Turning this on also enables `creatable`, `deletable`, and `inlineEditable`
154
+ // by default; pass `false` to any of them to opt out.
155
+ //
156
+ // Pass a predicate `(record) => boolean` instead of `true` to allow editing
157
+ // only some rows (e.g. from a per-record policy): the inline affordance and
158
+ // the sheet's per-field edit are hidden for the rows it rejects.
159
+ editable?: boolean | ((record: Record<string, any>) => boolean)
160
+
161
+ // Whether records can be deleted from the sheet. Defaults to enabled for an
162
+ // editable catalog. Pass `false` to hide the delete button entirely, or a
163
+ // predicate `(record) => boolean` to allow deleting only some rows (e.g. from
164
+ // a per-record policy). Delete is reachable only through the sheet, so this
165
+ // has no effect unless the catalog is `editable`.
166
+ deletable?: boolean | ((record: Record<string, any>) => boolean)
167
+
168
+ // Whether new records can be created (enables create mode in the sheet and
169
+ // the exposed `openCreate()` method). Defaults to enabled when the catalog is
170
+ // `editable`; pass `false` to disable.
154
171
  creatable?: boolean
155
172
 
156
173
  // Width of the record sheet (any valid CSS width). Defaults to `480px`.
157
174
  sheetWidth?: string
158
175
 
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
176
+ // Enable inline editing directly in the table: cells for `showOnUpdate` fields
177
+ // gain a hover edit affordance that opens an inline editor (reusing the sheet's
178
+ // CRUD edit context). Defaults to enabled when the catalog is `editable`; pass
179
+ // `false` to restrict editing to the record sheet.
180
+ inlineEditable?: boolean
163
181
  }
164
182
 
165
183
  const props = withDefaults(defineProps<Props>(), {
166
184
  canFilter: true,
167
- canSort: true
185
+ canSort: true,
186
+ // Default these on so an editable catalog gets create / delete / inline edit
187
+ // without opting in to each. A boolean prop can't tell "omitted" from `false`
188
+ // (Vue casts an absent boolean to `false`), so they default on here and the
189
+ // resolvers below gate them on `editable`; pass the prop `false` to opt out.
190
+ creatable: true,
191
+ deletable: true,
192
+ inlineEditable: true
168
193
  })
169
194
 
170
195
  const selected = defineModel<any[]>('selected')
@@ -182,16 +207,32 @@ const emit = defineEmits<{
182
207
 
183
208
  const { t } = useTrans({
184
209
  en: {
185
- write_error: 'Your latest changes might not be saved. Please reload the page, and contact support if the problem persists.',
186
- busy_warning: 'A save is still in progress — changes you make now may not be saved.',
210
+ save_failed: (label: string) => `We couldn’t save “${label}”.`,
211
+ save_failed_anon: 'We couldn’t save your change.',
212
+ delete_failed: (label: string) => `We couldn’t delete “${label}”.`,
213
+ delete_failed_anon: 'We couldn’t delete this record.',
214
+ write_error_reload: 'Please reload and try again.',
215
+ write_error_recover: 'Please reload to see the latest.',
216
+ busy_warning: 'Still saving — please wait a moment before changing the view.',
187
217
  refresh_text: 'Newer results are available.',
188
- refresh_action: 'Refresh'
218
+ refresh_failed_text: 'Some changes couldn’t be saved. Refresh to restore the latest values.',
219
+ refresh_action: 'Refresh',
220
+ search_error: 'We couldn’t load the records.',
221
+ search_retry: 'Try again'
189
222
  },
190
223
  ja: {
191
- write_error: '最新の変更が保存されていない可能性があります。ページを再読み込みし、問題が解決しない場合はサポートにお問い合わせください。',
192
- busy_warning: '保存処理中です。ここでの変更は保存されない可能性があります。',
224
+ save_failed: (label: string) => `「${label}」を保存できませんでした。`,
225
+ save_failed_anon: '変更を保存できませんでした。',
226
+ delete_failed: (label: string) => `「${label}」を削除できませんでした。`,
227
+ delete_failed_anon: 'レコードを削除できませんでした。',
228
+ write_error_reload: 'ページを再読み込みして、もう一度お試しください。',
229
+ write_error_recover: 'ページを再読み込みして最新の状態をご確認ください。',
230
+ busy_warning: '保存中です。表示を変更する前に少しお待ちください。',
193
231
  refresh_text: '新しい結果があります。',
194
- refresh_action: '更新'
232
+ refresh_failed_text: '保存できなかった変更があります。更新して最新の値に戻してください。',
233
+ refresh_action: '更新',
234
+ search_error: 'レコードを読み込めませんでした。',
235
+ search_retry: '再試行'
195
236
  }
196
237
  })
197
238
 
@@ -211,9 +252,12 @@ const hasInitialResults = ref(false)
211
252
 
212
253
  // A fresher server result fetched after the optimistic writes settled, awaiting
213
254
  // the user's explicit opt-in via the refresh button — never auto-applied (that
214
- // would momentarily revert the optimistic edits to stale data). Declared here
215
- // (ahead of the query handlers that clear it) so they can reference it.
216
- const latestState = ref<LensResult | null>(null)
255
+ // would momentarily revert the optimistic edits to stale data). `fromError` marks
256
+ // a stash produced by a *failed* write (the optimistic value diverged from the
257
+ // canonical one), so the banner can explain it's a recovery rather than just
258
+ // newer data. Declared here (ahead of the query handlers that clear it) so they
259
+ // can reference it.
260
+ const latestState = ref<{ result: LensResult; fromError: boolean } | null>(null)
217
261
 
218
262
  // Count of optimistic background writes currently in flight. Declared here
219
263
  // (ahead of the debounced refetch that consults it) so a queued refetch can bail
@@ -318,13 +362,79 @@ const { data: result, execute: refresh, loading } = useQuery(async (http) => {
318
362
  prevFetchResult = res
319
363
 
320
364
  return res
321
- })
365
+ }, { immediate: false })
322
366
 
323
367
  // Keep `currentResult` pointing at the shown result. The reference changes only
324
368
  // on a wholesale replace; optimistic in-place edits keep the same object, so this
325
369
  // mirror always reflects them.
326
370
  watch(result, (r) => { currentResult = r ?? undefined }, { immediate: true })
327
371
 
372
+ // Whether the most recent table search (initial load or a user-driven re-search)
373
+ // failed. A failed fetch otherwise leaves the previous rows on screen with the
374
+ // controls re-enabled — indistinguishable from the new query legitimately
375
+ // returning them — so the list shows an inline error + retry instead.
376
+ const searchError = ref(false)
377
+
378
+ // Monotonic token so only the latest search may raise the error flag: with the
379
+ // slow backend two searches can overlap, and an older one failing *after* a newer
380
+ // one succeeded must not flash an error over the good rows now on screen.
381
+ let searchRunSeq = 0
382
+
383
+ // Run the main table search, tracking whether it failed. Drives the initial load
384
+ // (the query's `immediate` is off so this owns it) and every `doRefresh`.
385
+ async function runSearch(): Promise<void> {
386
+ const seq = ++searchRunSeq
387
+ // Snapshot `searchToken` too: a write / create / forced refresh / index-field
388
+ // change bumps it to supersede an in-flight search (so even on success the
389
+ // fetcher discards that search's rows and returns the current result instead).
390
+ // Such a superseded run must neither raise the error (its rejection is for a
391
+ // request we already decided to drop) nor clear it (its "success" is the stale
392
+ // current result, not the requested rows) — the follow-up (the write's
393
+ // reconcile, the forced result) settles the state instead.
394
+ const token = searchToken
395
+ try {
396
+ await refresh()
397
+ // Clear only on the latest, non-superseded run's success — an older run
398
+ // resolving late must not wipe a newer run's error, and a write-invalidated
399
+ // run resolving with the current result must not dismiss the error over rows
400
+ // that aren't the requested ones. Not cleared up front either: keeping the
401
+ // error visible until a retry actually succeeds avoids flashing the previous
402
+ // query's (interactable) rows back on screen mid-retry.
403
+ if (seq === searchRunSeq && token === searchToken) {
404
+ searchError.value = false
405
+ // These fresh rows for the current query supersede any pending recovery
406
+ // banner (a reconcile stash), so its button can't later overwrite them with
407
+ // an older snapshot. Query-change refetches already drop it; the retry path
408
+ // (doRefresh, which keeps the banner for a failed retry) doesn't, so a
409
+ // successful retry must clear it here.
410
+ latestState.value = null
411
+ }
412
+ } catch (e) {
413
+ // Auth / session-expiry (401 / 419) must reach the app's re-auth flow, not be
414
+ // parked on the inline retry state — matches the download / filter paths and
415
+ // the ServerErrors contract. Rethrow so the rejection propagates to the global
416
+ // handler as it did before this wrapper caught it.
417
+ if (isAuthError(e)) {
418
+ throw e
419
+ }
420
+ if (seq === searchRunSeq && token === searchToken) {
421
+ searchError.value = true
422
+ }
423
+ }
424
+ }
425
+
426
+ onMounted(runSearch)
427
+
428
+ // When a search fails, the error state replaces the table but the control bar —
429
+ // including its selected-actions (bulk) slot — stays mounted. Clear any selection
430
+ // on entering the error so the user can't act on now-hidden rows from the previous
431
+ // result while the UI says the current query failed.
432
+ watch(searchError, (err) => {
433
+ if (err && selected.value?.length) {
434
+ selected.value = []
435
+ }
436
+ })
437
+
328
438
  const doRefresh = useDebounceFn(() => {
329
439
  // A write or a blocking create may have started during the debounce window: the
330
440
  // busy lock guards the query handlers at click time, but not this deferred
@@ -334,7 +444,7 @@ const doRefresh = useDebounceFn(() => {
334
444
  // (Deliberate refreshes go through `forceRefresh`, which calls `refresh`
335
445
  // directly to bypass this guard.)
336
446
  if (pendingWrites.value > 0 || creating.value) { return }
337
- return refresh()
447
+ return runSearch()
338
448
  }, 50)
339
449
 
340
450
  if (props.urlSync) {
@@ -353,7 +463,14 @@ if (props.urlSync) {
353
463
  }
354
464
 
355
465
  const _showEmptyState = computed(() => {
356
- return props.showEmptyState && !hasInitialResults.value && result.value?.data.length === 0
466
+ // Exclude `searchError`: when a re-search fails the result still holds the
467
+ // previous (possibly zero-row) data, which would otherwise keep the empty-state
468
+ // slot showing — and it sits in this branch's `v-else`, so the inline error +
469
+ // retry would never render. Yield to the error state instead.
470
+ return props.showEmptyState
471
+ && !searchError.value
472
+ && !hasInitialResults.value
473
+ && result.value?.data.length === 0
357
474
  })
358
475
 
359
476
  const hasConditions = computed(() => {
@@ -458,6 +575,13 @@ const tableSelect = computed(() => {
458
575
  return withoutIndexField(result.value?.query.select ?? [])
459
576
  })
460
577
 
578
+ // The effective row identifier when resolving / editing a row: the configured
579
+ // `indexField`, or `id` by default. Distinct from the raw `props.indexField`,
580
+ // whose `undefined` means "no index field configured" — `withIndexField` /
581
+ // `withoutIndexField` rely on that to leave a non-editable catalog's columns
582
+ // alone, so they deliberately keep using the prop directly rather than this.
583
+ const idField = computed(() => props.indexField ?? 'id')
584
+
461
585
  // Whether the currently loaded rows actually carry the effective row identifier.
462
586
  // Editing / opening a row needs it (`resolveId`), but rows fetched while
463
587
  // `editable` was still false — or before an `indexField` change settled — won't
@@ -467,7 +591,7 @@ const tableSelect = computed(() => {
467
591
  const rowsCarryIndexField = computed(() => {
468
592
  const rows = result.value?.data
469
593
  if (!rows || rows.length === 0) { return true }
470
- return (props.indexField ?? 'id') in rows[0]
594
+ return idField.value in rows[0]
471
595
  })
472
596
 
473
597
  // The row-identifier the table uses as its selection key. An explicit
@@ -526,6 +650,12 @@ function createInputFilters(queryFilters: any[], filters: any[]) {
526
650
  // reconcile result (the refreshed state supersedes it).
527
651
  function refetch(): void {
528
652
  latestState.value = null
653
+ // Supersede any in-flight search now, not only once the debounced run starts:
654
+ // a retry/search still loading for the previous query state would otherwise
655
+ // remain "latest" through the debounce window and, on resolving, clear the error
656
+ // or assign its rows after the query has already moved on. The next run captures
657
+ // the bumped token, so it isn't self-suppressed.
658
+ searchToken++
529
659
  doRefresh()
530
660
  }
531
661
 
@@ -667,8 +797,9 @@ const sheetError = ref(false)
667
797
  const reconciling = ref(false)
668
798
  const busy = computed(() => pendingWrites.value > 0 || reconciling.value)
669
799
 
670
- // Whether any write in the current in-flight batch failed; when so the reconcile
671
- // is skipped (the snackbar already told the user to reload).
800
+ // Whether any write in the current in-flight batch failed; passed to the
801
+ // post-batch reconcile so the refresh banner can label itself as a recovery for
802
+ // the failed write's diverged row rather than just "newer data".
672
803
  let batchErrored = false
673
804
 
674
805
  // In-flight write chains keyed by record+field, so repeated writes to the same
@@ -693,10 +824,20 @@ let reconcileToken = 0
693
824
  // Resolve a record's identifier, unwrapping the id field's object shape
694
825
  // (`{ value, display, path }`) when present.
695
826
  function resolveId(record: Record<string, any>): any {
696
- const v = record?.[props.indexField ?? 'id']
827
+ const v = record?.[idField.value]
697
828
  return v !== null && typeof v === 'object' ? v.value : v
698
829
  }
699
830
 
831
+ // Resolve a human-facing label for a record — the index field's display value
832
+ // (the id object's `{ value, display, path }` shape) falling back to the bare
833
+ // identifier — to name the affected row in a write-failure snackbar. Null when
834
+ // no usable label exists, so the snackbar can fall back to anonymous copy.
835
+ function resolveLabel(record: Record<string, any>): string | null {
836
+ const v = record?.[idField.value]
837
+ const label = v !== null && typeof v === 'object' ? (v.display ?? v.value) : v
838
+ return label != null && label !== '' ? String(label) : null
839
+ }
840
+
700
841
  const { execute: executeUpdate } = useMutation((http, id: any, values: Record<string, any>) =>
701
842
  http.post(`${endpointBase.value}/update`, {
702
843
  entity: entityName.value, id, values, settings: { lang }
@@ -737,6 +878,14 @@ function guardBusy(): boolean {
737
878
  return false
738
879
  }
739
880
 
881
+ // `updated_at` is a server-managed timestamp that bumps on every write, so a
882
+ // reconciled row's value always differs from the optimistic one even when nothing
883
+ // else changed. It's excluded from the diff below (and patched to ~now on save) so
884
+ // an edit never surfaces the refresh banner just for its own timestamp — the user
885
+ // shouldn't be asked to reload the whole view over a change that obviously follows
886
+ // from their edit. Always a datetime column, never user-editable.
887
+ const UPDATED_AT_KEY = 'updated_at'
888
+
740
889
  // Value-level comparison (rows in order + total) deciding whether a reconcile
741
890
  // surfaced anything newer than the optimistic state on screen. `fresh` is the
742
891
  // canonical search-shaped result; `shown` is what's displayed, which may carry
@@ -751,6 +900,9 @@ function isSameResult(fresh: LensResult | null, shown: LensResult | null): boole
751
900
  const freshRow = fresh.data[i]
752
901
  const shownRow = shown.data[i]
753
902
  for (const key of Object.keys(freshRow)) {
903
+ // The always-bumped timestamp would make every edited row look "changed";
904
+ // skip it so it alone can't surface the refresh banner (see UPDATED_AT_KEY).
905
+ if (key === UPDATED_AT_KEY) { continue }
754
906
  if (JSON.stringify(freshRow[key]) !== JSON.stringify(shownRow?.[key])) {
755
907
  return false
756
908
  }
@@ -761,8 +913,11 @@ function isSameResult(fresh: LensResult | null, shown: LensResult | null): boole
761
913
 
762
914
  // After the last in-flight write settles, fetch the canonical state for the
763
915
  // current query and, only if it differs from what's shown, stash it behind the
764
- // refresh button. The server is fresh by now (the write completed).
765
- async function reconcile(): Promise<void> {
916
+ // refresh button. The server is fresh by now (the write completed). `afterError`
917
+ // is set when a write in the batch *failed*: its optimistic edit wasn't rolled
918
+ // back, so the canonical state diverges and the stash becomes the user's one-click
919
+ // path to restore it — the banner labels it accordingly.
920
+ async function reconcile(afterError = false): Promise<void> {
766
921
  const token = ++reconcileToken
767
922
  reconciling.value = true
768
923
  try {
@@ -775,9 +930,13 @@ async function reconcile(): Promise<void> {
775
930
  // Authoritative: stash the canonical result when it differs from what's
776
931
  // shown, otherwise clear any (possibly stale) stash — an earlier overlapping
777
932
  // reconcile may have left a pre-edit result behind.
778
- latestState.value = result.value && !isSameResult(fresh, result.value) ? fresh : null
933
+ latestState.value = result.value && !isSameResult(fresh, result.value)
934
+ ? { result: fresh, fromError: afterError }
935
+ : null
779
936
  } catch {
780
- // Ignore — keep the optimistic state, offer no refresh.
937
+ // Ignore — keep the optimistic state, offer no refresh. After a failed write
938
+ // this means no recovery banner, so the failure snackbar's reload guidance is
939
+ // the fallback.
781
940
  } finally {
782
941
  if (token === reconcileToken) {
783
942
  reconciling.value = false
@@ -787,8 +946,15 @@ async function reconcile(): Promise<void> {
787
946
 
788
947
  function applyLatest(): void {
789
948
  if (!latestState.value) { return }
790
- result.value = latestState.value
949
+ result.value = latestState.value.result
791
950
  latestState.value = null
951
+ // These are fresh authoritative rows for the current query, so drop any
952
+ // search-error state — e.g. a search failed, then a sheet save's reconcile
953
+ // surfaced this banner; applying it must reveal the rows, not stay on the retry.
954
+ searchError.value = false
955
+ // Supersede any in-flight retry started from that error state, so it can't
956
+ // assign over these rows or re-raise the error after they've been applied.
957
+ searchToken++
792
958
  prevFetchInput = null
793
959
  rebindOpenSheet()
794
960
  }
@@ -830,9 +996,19 @@ async function forceRefresh(): Promise<void> {
830
996
  // fresh result when it resolves — same token mechanism as the write/create
831
997
  // races. This refetch captures the bumped token, so it isn't self-suppressed.
832
998
  searchToken++
999
+ const token = searchToken
833
1000
  prevFetchInput = null
834
1001
  latestState.value = null
835
1002
  await refresh()
1003
+ // A deliberate reload landed fresh rows — clear any prior search-error state so
1004
+ // the list shows them instead of a stale error. But only if this reload wasn't
1005
+ // itself invalidated mid-flight (a write bumping `searchToken` makes the fetcher
1006
+ // return the current result, not freshly-assigned rows) — otherwise we'd dismiss
1007
+ // the error over the previous query's rows. (On failure this rethrows and the
1008
+ // flag is left untouched; the create path handles its own fallback.)
1009
+ if (token === searchToken) {
1010
+ searchError.value = false
1011
+ }
836
1012
  rebindOpenSheet()
837
1013
  }
838
1014
 
@@ -867,7 +1043,7 @@ async function refreshCatalog(): Promise<void> {
867
1043
  // `resolveId` would be `undefined`), which would let a delete/save act on the
868
1044
  // wrong — or no — id during and after the refetch.
869
1045
  watch(
870
- () => (props.editable ? (props.indexField ?? 'id') : null),
1046
+ () => (props.editable ? idField.value : null),
871
1047
  (field, prev) => {
872
1048
  if (!field || field === prev) { return }
873
1049
  if (sheet.state.value && sheetMode.value === 'view') { sheet.off() }
@@ -882,14 +1058,40 @@ watch(
882
1058
  )
883
1059
 
884
1060
  // Track an optimistic background write: count it as pending, surface a snackbar
885
- // on failure, and once the whole batch settles (with no failures) reconcile.
1061
+ // on failure, and once the whole batch settles reconcile (so a failed write's
1062
+ // diverged row can be restored via the refresh banner).
886
1063
  function hasPendingWrite(recordId: any): boolean {
887
1064
  return pendingByRecord.has(recordId)
888
1065
  }
889
1066
 
1067
+ // Compose the failure snackbar: name the affected row when we have a label, then
1068
+ // the server's reason (a policy / business-rule / validation message) when
1069
+ // present, and always a recovery hint. The optimistic value isn't rolled back, so
1070
+ // the user needs a way back to canonical state — the refresh banner offers a
1071
+ // one-click path when it appears, but it can't always (a failed recovery reconcile
1072
+ // shows none, and `isSameResult` can't see detail-only sheet fields absent from
1073
+ // the search), so the reload hint stays as the guaranteed fallback. With a reason
1074
+ // present, "reload to see the latest" (the change didn't take; reload to resync);
1075
+ // without one (network / 5xx / opaque), "reload and try again".
1076
+ function writeErrorText(op: 'save' | 'delete', label: string | null, reason: string | null): string {
1077
+ const subject = op === 'delete'
1078
+ ? (label != null ? t.delete_failed(label) : t.delete_failed_anon)
1079
+ : (label != null ? t.save_failed(label) : t.save_failed_anon)
1080
+ return reason
1081
+ ? `${subject} ${reason} ${t.write_error_recover}`
1082
+ : `${subject} ${t.write_error_reload}`
1083
+ }
1084
+
890
1085
  // Track an optimistic background write for `recordId`, serialized per `key` so
891
- // writes to the same cell reach the server in edit order.
892
- function trackWrite(recordId: any, key: string, run: () => Promise<unknown>): void {
1086
+ // writes to the same cell reach the server in edit order. `op` / `label` name the
1087
+ // affected row in the failure snackbar.
1088
+ function trackWrite(
1089
+ recordId: any,
1090
+ key: string,
1091
+ op: 'save' | 'delete',
1092
+ label: string | null,
1093
+ run: () => Promise<unknown>
1094
+ ): void {
893
1095
  // A new write changes the displayed state. Clear any stashed reconcile result
894
1096
  // and invalidate an in-flight reconcile (advance the token so its result is
895
1097
  // dropped; clear its busy flag — `pendingWrites` keeps us busy), so neither
@@ -911,9 +1113,17 @@ function trackWrite(recordId: any, key: string, run: () => Promise<unknown>): vo
911
1113
  writeChains.set(key, current)
912
1114
 
913
1115
  current
914
- .catch(() => {
1116
+ .catch((e) => {
915
1117
  batchErrored = true
916
- snackbars.push({ mode: 'danger', text: t.write_error })
1118
+ // Name the affected row and prefer a server-provided reason (a policy /
1119
+ // business-rule deny, a validation message) so a rejected write explains
1120
+ // itself and the user can tell which edit failed when several are in flight.
1121
+ // The optimistic edit isn't rolled back here; the post-batch reconcile
1122
+ // surfaces the refresh banner to restore it.
1123
+ snackbars.push({
1124
+ mode: 'danger',
1125
+ text: writeErrorText(op, label, extractServerMessage(e))
1126
+ })
917
1127
  })
918
1128
  .finally(() => {
919
1129
  // Only drop the chain entry if a newer write hasn't replaced it.
@@ -930,9 +1140,12 @@ function trackWrite(recordId: any, key: string, run: () => Promise<unknown>): vo
930
1140
  if (pendingWrites.value === 0) {
931
1141
  const errored = batchErrored
932
1142
  batchErrored = false
933
- if (!errored) {
934
- reconcile()
935
- }
1143
+ // Reconcile on error too: a failed write left its optimistic edit on the
1144
+ // row, so the canonical state now diverges and the refresh banner becomes
1145
+ // the user's one-click path to restore it (labelled as a recovery). If the
1146
+ // reconcile itself fails, no banner shows and the snackbar's reload
1147
+ // guidance is the fallback.
1148
+ reconcile(errored)
936
1149
  }
937
1150
  })
938
1151
  }
@@ -948,13 +1161,21 @@ function save(record: Record<string, any>, values: Record<string, any>): void {
948
1161
  // row, so a follow-up save / delete would resolve to the new, not-yet-synced id
949
1162
  // and miss the in-flight record. Strip it so the invariant holds for every
950
1163
  // caller. (The identifier is still settable on create, which uses `create()`.)
951
- const indexField = props.indexField ?? 'id'
1164
+ const indexField = idField.value
952
1165
  if (indexField in values) {
953
1166
  values = { ...values }
954
1167
  delete values[indexField]
955
1168
  }
956
1169
  if (Object.keys(values).length === 0) { return }
957
1170
  Object.assign(record, values)
1171
+ // Optimistically advance the row's `updated_at` to ~now so the displayed
1172
+ // timestamp reflects the edit immediately. The server sets the authoritative
1173
+ // value; a little skew is fine and never surfaces the refresh banner, since the
1174
+ // reconcile diff ignores this column. Display-only — deliberately not added to
1175
+ // the persisted `values` (the server owns its own timestamping).
1176
+ if (UPDATED_AT_KEY in record) {
1177
+ record[UPDATED_AT_KEY] = day().toISOString()
1178
+ }
958
1179
  // Serialize per record+field so two quick edits to the same cell apply in
959
1180
  // order; edits to disjoint fields run concurrently.
960
1181
  const fields = Object.keys(values)
@@ -972,7 +1193,7 @@ function save(record: Record<string, any>, values: Record<string, any>): void {
972
1193
  && k.slice(prefix.length).split(',').some((f) => fieldSet.has(f)))
973
1194
  .map(([, chain]) => chain)
974
1195
  const gate = Promise.allSettled(overlapping)
975
- trackWrite(id, key, () => gate.then(() => executeUpdate(id, values)))
1196
+ trackWrite(id, key, 'save', resolveLabel(record), () => gate.then(() => executeUpdate(id, values)))
976
1197
  }
977
1198
 
978
1199
  // Create — blocking. The slow POST runs to completion, then the catalog is
@@ -1000,6 +1221,11 @@ async function create(values: Record<string, any>): Promise<Record<string, any>>
1000
1221
  // during the create (doRefresh bails on `creating`); create's own forceRefresh
1001
1222
  // bypasses that by calling refresh directly.
1002
1223
  searchToken++
1224
+ // Don't clear `searchError` here: only a successful current-query refresh
1225
+ // should (forceRefresh does so on success). The fallbacks below show the
1226
+ // previous query's rows plus the new record — not the requested query — so
1227
+ // dismissing the error there would present stale rows as if they matched.
1228
+ //
1003
1229
  // From here a refresh failure must not reject (the caller would treat the
1004
1230
  // create as failed and re-submit a duplicate); fall back to showing the new
1005
1231
  // record optimistically.
@@ -1066,7 +1292,7 @@ function remove(record: Record<string, any>): void {
1066
1292
  // `Promise.allSettled([])` resolves immediately, so this is a no-op gate when
1067
1293
  // the record has no in-flight writes.
1068
1294
  const gate = Promise.allSettled(pendingForRecord)
1069
- trackWrite(id, `${id}:__delete__`, () => gate.then(() => executeDelete(id)))
1295
+ trackWrite(id, `${id}:__delete__`, 'delete', resolveLabel(record), () => gate.then(() => executeDelete(id)))
1070
1296
  }
1071
1297
 
1072
1298
  async function openSheet(record: Record<string, any>): Promise<void> {
@@ -1121,9 +1347,10 @@ async function openSheet(record: Record<string, any>): Promise<void> {
1121
1347
  }
1122
1348
 
1123
1349
  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) {
1350
+ // Creation is enabled by `creatable`, which defaults to on for an editable
1351
+ // catalog. Honor it here even though this method is exposed (and provided to
1352
+ // children).
1353
+ if (!props.editable || !props.creatable) {
1127
1354
  return
1128
1355
  }
1129
1356
  // Supersede any in-flight openSheet `/show` so it can't open over the create
@@ -1136,6 +1363,27 @@ function openCreate(): void {
1136
1363
  sheet.on()
1137
1364
  }
1138
1365
 
1366
+ // Catalog-level editing gate: `editable` is set (boolean `true` or a predicate)
1367
+ // and the rows carry the index field needed to resolve a record's id.
1368
+ function editEnabled(): boolean {
1369
+ return !!props.editable && rowsCarryIndexField.value
1370
+ }
1371
+
1372
+ // Per-record refinement: a predicate `editable`/`deletable` decides each row, a
1373
+ // boolean applies to all.
1374
+ function canEdit(record: Record<string, any>): boolean {
1375
+ return editEnabled() && (typeof props.editable === 'function' ? props.editable(record) : true)
1376
+ }
1377
+
1378
+ // Delete is a stronger action than edit and rides the same editable sheet, so a
1379
+ // row must be editable before it can be deleted — building on `canEdit` makes a
1380
+ // per-record `editable` predicate gate delete too (a row it rejects is never
1381
+ // deletable). `deletable` then refines further: on unless explicitly `false`, or
1382
+ // its own per-record predicate.
1383
+ function canDelete(record: Record<string, any>): boolean {
1384
+ return canEdit(record) && (typeof props.deletable === 'function' ? props.deletable(record) : props.deletable)
1385
+ }
1386
+
1139
1387
  provideLensEdit({
1140
1388
  // Getters so the injected context tracks prop changes after mount (e.g.
1141
1389
  // permissions resolving async, or a flag toggling `editable` off): LensTable
@@ -1145,8 +1393,8 @@ provideLensEdit({
1145
1393
  // trigger a refetch to pull in the identifier, but it lands asynchronously —
1146
1394
  // keep editing off until the rows actually carry it, so a save in that window
1147
1395
  // can't `resolveId()` to `undefined`.
1148
- get editable() { return !!props.editable && rowsCarryIndexField.value },
1149
- get creatable() { return !!props.creatable },
1396
+ get editable() { return editEnabled() },
1397
+ get creatable() { return !!props.editable && props.creatable },
1150
1398
  // Use the same `__no_entity__` fallback as the search / CRUD requests so slot
1151
1399
  // side-channel saves (which read this) target the same entity, not an empty one.
1152
1400
  get entity() { return entityName.value },
@@ -1154,7 +1402,9 @@ provideLensEdit({
1154
1402
  // sheet opener and to block identifier edits, so it must track a prop that
1155
1403
  // resolves (or changes) after mount — otherwise the new identifier column stays
1156
1404
  // non-clickable while the old field is still treated as the id.
1157
- get indexField() { return props.indexField ?? 'id' },
1405
+ get indexField() { return idField.value },
1406
+ canEdit,
1407
+ canDelete,
1158
1408
  resolveId,
1159
1409
  save,
1160
1410
  create,
@@ -1276,32 +1526,48 @@ defineExpose({
1276
1526
  </div>
1277
1527
  <SDivider />
1278
1528
  <div v-if="latestState && !busy" class="refresh-banner">
1279
- <span class="refresh-banner-text">{{ t.refresh_text }}</span>
1529
+ <span class="refresh-banner-text">{{ latestState.fromError ? t.refresh_failed_text : t.refresh_text }}</span>
1280
1530
  <SButton size="mini" mode="info" :label="t.refresh_action" @click="applyLatest" />
1281
1531
  </div>
1282
1532
  <div class="list" :style="tableMaxHeight">
1283
- <LensTable
1284
- :result
1285
- :loading
1286
- :overrides="_overrides"
1287
- :select="tableSelect"
1288
- :index-field="tableIndexField"
1289
- :selected
1290
- :clickable-fields
1291
- :inline-edit
1292
- @filter-updated="onInlineFilterUpdated"
1293
- @sort-updated="onSortUpdated"
1294
- @update:selected="onUpdateSelected"
1295
- @cell-clicked="(v, r) => emit('cell-clicked', v, r)"
1296
- />
1297
- <LensCatalogFooter
1298
- v-if="result && result?.pagination.total > 0"
1299
- :class="{ 'is-busy': busy }"
1300
- :result
1301
- :loading
1302
- @prev="onPrev"
1303
- @next="onNext"
1304
- />
1533
+ <div v-if="searchError" class="list-error">
1534
+ <p class="list-error-text">{{ t.search_error }}</p>
1535
+ <!-- Retry the same query via the busy-guarded `doRefresh` (not `refetch`,
1536
+ which would clear a `latestState` recovery banner before the retry
1537
+ resolves — a failed retry would then lose that known-good stash). -->
1538
+ <SButton
1539
+ size="small"
1540
+ mode="info"
1541
+ :label="t.search_retry"
1542
+ :loading
1543
+ :disabled="busy"
1544
+ @click="doRefresh"
1545
+ />
1546
+ </div>
1547
+ <template v-else>
1548
+ <LensTable
1549
+ :result
1550
+ :loading
1551
+ :overrides="_overrides"
1552
+ :select="tableSelect"
1553
+ :index-field="tableIndexField"
1554
+ :selected
1555
+ :clickable-fields
1556
+ :inline-editable
1557
+ @filter-updated="onInlineFilterUpdated"
1558
+ @sort-updated="onSortUpdated"
1559
+ @update:selected="onUpdateSelected"
1560
+ @cell-clicked="(v, r) => emit('cell-clicked', v, r)"
1561
+ />
1562
+ <LensCatalogFooter
1563
+ v-if="result && result?.pagination.total > 0"
1564
+ :class="{ 'is-busy': busy }"
1565
+ :result
1566
+ :loading
1567
+ @prev="onPrev"
1568
+ @next="onNext"
1569
+ />
1570
+ </template>
1305
1571
  </div>
1306
1572
  </div>
1307
1573
 
@@ -1337,7 +1603,7 @@ defineExpose({
1337
1603
  :loading="sheetLoading"
1338
1604
  :error="sheetError"
1339
1605
  :fields="result.fields"
1340
- :index-field="indexField ?? 'id'"
1606
+ :index-field="idField"
1341
1607
  :width="sheetWidth"
1342
1608
  @close="sheet.off"
1343
1609
  >
@@ -1414,6 +1680,23 @@ defineExpose({
1414
1680
  flex-grow: 1;
1415
1681
  }
1416
1682
 
1683
+ .list-error {
1684
+ display: flex;
1685
+ flex-direction: column;
1686
+ flex-grow: 1;
1687
+ align-items: center;
1688
+ justify-content: center;
1689
+ gap: 12px;
1690
+ padding: 48px 16px;
1691
+ text-align: center;
1692
+ }
1693
+
1694
+ .list-error-text {
1695
+ font-size: 14px;
1696
+ line-height: 1.6;
1697
+ color: var(--c-text-2);
1698
+ }
1699
+
1417
1700
  .control-skeleton {
1418
1701
  height: 56px;
1419
1702
  background-color: var(--c-bg-1);
@@ -9,10 +9,11 @@ import { provideDataListState } from '../../../composables/DataList'
9
9
  import { useTrans } from '../../../composables/Lang'
10
10
  import { usePower } from '../../../composables/Power'
11
11
  import { useValidation } from '../../../composables/Validation'
12
+ import { useSnackbars } from '../../../stores/Snackbars'
12
13
  import { type FieldData } from '../FieldData'
13
14
  import { useFieldFactory } from '../composables/FieldFactory'
14
15
  import { useLensEdit } from '../composables/LensEdit'
15
- import { extractServerErrors } from '../validation/ServerErrors'
16
+ import { extractServerErrors, extractServerMessage } from '../validation/ServerErrors'
16
17
  import LensSheetField from './LensSheetField.vue'
17
18
 
18
19
  const props = withDefaults(defineProps<{
@@ -46,7 +47,7 @@ const { t } = useTrans({
46
47
  confirm_delete: 'Delete this record?',
47
48
  new_record: 'New record',
48
49
  load_error:
49
- 'Couldn’t load this record. Please reload the page, and contact support if the problem persists.'
50
+ 'We couldn’t load this record. Please reload the page and try again.'
50
51
  },
51
52
  ja: {
52
53
  create: '作成',
@@ -55,12 +56,13 @@ const { t } = useTrans({
55
56
  confirm_delete: 'このレコードを削除しますか?',
56
57
  new_record: '新規作成',
57
58
  load_error:
58
- 'このレコードを読み込めませんでした。ページを再読み込みし、問題が解決しない場合はサポートにお問い合わせください。'
59
+ 'このレコードを読み込めませんでした。ページを再読み込みして、もう一度お試しください。'
59
60
  }
60
61
  })
61
62
 
62
63
  const edit = useLensEdit()
63
64
  const factory = useFieldFactory()
65
+ const snackbars = useSnackbars()
64
66
 
65
67
  // Provide the data-list label width directly (instead of wrapping rows in
66
68
  // SDataList) so each LensSheetField wrapper doesn't make its SDataListItem a
@@ -126,6 +128,11 @@ const saving = ref(false)
126
128
  // sheet opened; `onCreate` re-checks it too.
127
129
  const creatable = computed(() => !!edit?.creatable)
128
130
 
131
+ // Whether the open record may be deleted. Reactive (a getter on the edit
132
+ // context) so the delete button hides if a per-record `deletable` predicate, or
133
+ // a permission change, rejects it after the sheet has opened.
134
+ const deletable = computed(() => !!props.record && !!edit?.canDelete(props.record))
135
+
129
136
  // Backend-only validation errors (e.g. `unique`) returned by a rejected
130
137
  // create, fed to Vuelidate via `$externalResults` so they surface on the
131
138
  // offending field. The create form's keys are the bare field keys, matching
@@ -197,13 +204,24 @@ async function onCreate() {
197
204
  await edit!.create(values)
198
205
  emit('close')
199
206
  } catch (e) {
200
- // Surface backend validation errors (e.g. a duplicate `unique` value) on
201
- // the offending fields; rethrow anything that isn't a 422.
207
+ // Surface backend validation errors (e.g. a duplicate `unique` value) on the
208
+ // offending fields. Failing that, surface a server-provided message (a policy
209
+ // / business-rule deny, or a form-level 422) as a snackbar and keep the sheet
210
+ // open with the input intact — rather than rethrowing into the global
211
+ // full-page error handler. Failures without a displayable message — network,
212
+ // 5xx, opaque, and auth / session expiry (which extractServerMessage excludes
213
+ // so the app can prompt re-auth) — still propagate.
202
214
  const errors = extractServerErrors(e)
203
- if (!errors) {
204
- throw e
215
+ if (errors) {
216
+ serverErrors.value = errors
217
+ return
218
+ }
219
+ const message = extractServerMessage(e)
220
+ if (message) {
221
+ snackbars.push({ mode: 'danger', text: message })
222
+ return
205
223
  }
206
- serverErrors.value = errors
224
+ throw e
207
225
  } finally {
208
226
  saving.value = false
209
227
  }
@@ -255,7 +273,11 @@ function saveRecord(values: Record<string, any>): Promise<void> {
255
273
  // but saving against the partial row mid-`/show` (or after it failed) could
256
274
  // overwrite a not-yet-loaded detail field with an empty value — the built-in
257
275
  // fields avoid this by not rendering until the record is ready.
258
- if (props.loading || props.error || !props.record || !edit) {
276
+ //
277
+ // Also honor the per-record edit gate: a custom slot editor funnels through
278
+ // here, so a row a `editable` predicate rejects must not be saved through the
279
+ // slot any more than through the built-in cell / field editors.
280
+ if (props.loading || props.error || !props.record || !edit || !edit.canEdit(props.record)) {
259
281
  return Promise.resolve()
260
282
  }
261
283
  edit.save(props.record, values)
@@ -270,6 +292,10 @@ const slotProps = computed(() => ({
270
292
  // record has loaded; `save` also hard-refuses while loading/error as a guard.
271
293
  loading: props.loading ?? false,
272
294
  error: props.error ?? false,
295
+ // Whether a per-record `editable` predicate allows editing this row, so a slot
296
+ // editor can disable its own controls for a rejected row; `save` enforces it
297
+ // regardless, but otherwise the refusal is only visible as a silent no-op.
298
+ canEdit: !!props.record && !!edit?.canEdit(props.record),
273
299
  save: saveRecord
274
300
  }))
275
301
  </script>
@@ -338,7 +364,7 @@ const slotProps = computed(() => ({
338
364
  @click="onCreate"
339
365
  />
340
366
  </template>
341
- <template v-else-if="record && !loading && !error">
367
+ <template v-else-if="record && !loading && !error && deletable">
342
368
  <template v-if="confirmingDelete.state.value">
343
369
  <span class="confirm-text">{{ t.confirm_delete }}</span>
344
370
  <SButton size="medium" :label="t.cancel" @click="confirmingDelete.off" />
@@ -457,6 +483,10 @@ const slotProps = computed(() => ({
457
483
  border-top: 1px solid var(--c-divider);
458
484
  }
459
485
 
486
+ .footer:empty {
487
+ display: none;
488
+ }
489
+
460
490
  .confirm-text {
461
491
  margin-right: auto;
462
492
  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<{
@@ -68,11 +68,6 @@ const columnKeys = computed(() => {
68
68
  return visible.map((k) => (k === '__empty__' ? `__empty__::${emptyIndex++}` : k))
69
69
  })
70
70
 
71
- const orders = computed(() => [
72
- ...columnKeys.value,
73
- '__last_empty__'
74
- ])
75
-
76
71
  const columns = computedAsync(async () => {
77
72
  const r = props.result
78
73
 
@@ -125,6 +120,13 @@ const columns = computedAsync(async () => {
125
120
  // off the configured index field for any field type — a slug/code identifier
126
121
  // opens the sheet just like an `id` — so other id-type columns (e.g. a
127
122
  // `company_id` reference link) keep their own navigation.
123
+ //
124
+ // Exception: an `id` field whose server value carries a `path` renders that
125
+ // path as a link; respect it so those rows navigate to the details page instead
126
+ // of opening the sheet (decided per row, so id's without a path still open it).
127
+ // Scoped to `id` fields — a custom index field that itself renders a link (e.g.
128
+ // a `link` / `slack_message` identifier) is still turned into the sheet opener,
129
+ // as is a column whose `cell` is undefined (falls straight through to the opener).
128
130
  if (
129
131
  edit?.editable
130
132
  && key === edit.indexField
@@ -132,15 +134,20 @@ const columns = computedAsync(async () => {
132
134
  const original = column.cell
133
135
  column.cell = (v: any, r: any): TableCell<any, any> => {
134
136
  const cell = typeof original === 'function' ? original(v, r) : original
137
+ if (overriddenFieldData.type === 'id' && cell && 'link' in cell && cell.link) {
138
+ return cell
139
+ }
135
140
  return {
136
- ...(cell as TableCell<any, any>),
141
+ ...cell,
137
142
  link: null,
143
+ // @ts-expect-error avatar and day cells don't have info as color,
144
+ // but we don't use those for the index field anyway, so it's safe to force it here
138
145
  color: 'info',
139
146
  onClick: () => edit.openSheet(r)
140
- } as TableCell<any, any>
147
+ }
141
148
  }
142
149
  } else if (
143
- props.inlineEdit
150
+ props.inlineEditable
144
151
  && edit?.editable
145
152
  && key !== edit.indexField
146
153
  && overriddenFieldData.showOnUpdate === true
@@ -182,6 +189,18 @@ const columns = computedAsync(async () => {
182
189
  return columns
183
190
  }, {})
184
191
 
192
+ // Render a column only once its definition exists. `columns` resolves
193
+ // asynchronously (computedAsync), so when a column is toggled back on its key
194
+ // lands in `columnKeys` a tick before `columns` rebuilds to include it. Emitting
195
+ // that key in `orders` during the gap makes STable render the column with no cell
196
+ // definition — STableCell then falls back to STableCellText with the raw value
197
+ // (e.g. the `id` field's `{ value, display, path }` object), tripping a Vue
198
+ // prop-type warning. Gating on presence in `columns` keeps the two in lockstep.
199
+ const orders = computed(() => [
200
+ ...columnKeys.value.filter((key) => key in columns.value),
201
+ '__last_empty__'
202
+ ])
203
+
185
204
  const table = useTable({
186
205
  records,
187
206
  orders,
@@ -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;
@@ -1,10 +1,37 @@
1
1
  import { useGet } from '../../../composables/Api'
2
+ import { useTrans } from '../../../composables/Lang'
3
+ import { useSnackbars } from '../../../stores/Snackbars'
2
4
  import { type FileDownloader } from '../FileDownloader'
5
+ import { isAuthError } from '../validation/ServerErrors'
3
6
 
4
7
  export function useFileDownloader(): FileDownloader {
8
+ const snackbars = useSnackbars()
9
+
10
+ const { t } = useTrans({
11
+ en: { download_error: 'We couldn’t download this file. Please try again.' },
12
+ ja: { download_error: 'ファイルをダウンロードできませんでした。もう一度お試しください。' }
13
+ })
14
+
5
15
  const { execute: fileDownloader } = useGet<any, [url: string]>(async (http, url) => {
6
16
  return http.download(url)
7
17
  })
8
18
 
9
- return fileDownloader
19
+ // The click binding (e.g. SDescFile's `@click`) doesn't await the returned
20
+ // promise, so a failed download would otherwise be an unhandled rejection with
21
+ // no feedback — the click just appears to do nothing. Catch and surface a
22
+ // snackbar. The response is a blob, so the server's message isn't readily
23
+ // decodable here; generic copy is the right level.
24
+ return async (url) => {
25
+ try {
26
+ return await fileDownloader(url)
27
+ } catch (e) {
28
+ // Let auth / session-expiry failures (401 / 419) propagate to the app's
29
+ // error flow so it can prompt re-authentication — the click path doesn't
30
+ // await this, so the rejection reaches the global handler as it did before.
31
+ if (isAuthError(e)) {
32
+ throw e
33
+ }
34
+ snackbars.push({ mode: 'danger', text: t.download_error })
35
+ }
36
+ }
10
37
  }
@@ -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
 
@@ -33,6 +33,12 @@ export function useResourceFetcher(): ResourceFetcher {
33
33
  data.value[key] = response
34
34
  delete pendingList[key]
35
35
  return response
36
+ },
37
+ (error) => {
38
+ // Clear the pending entry on failure too — otherwise the rejected promise
39
+ // stays cached under `key` and every retry returns the same rejection.
40
+ delete pendingList[key]
41
+ throw error
36
42
  }
37
43
  )
38
44
 
@@ -2,6 +2,7 @@ import { type ValidationArgs } from '@vuelidate/core'
2
2
  import { defineAsyncComponent } from 'vue'
3
3
  import SInputDropdown, { type Option } from '../../../components/SInputDropdown.vue'
4
4
  import { required } from '../../../validation/rules'
5
+ import { isAuthError } from '../validation/ServerErrors'
5
6
  import { FilterInput } from './FilterInput'
6
7
 
7
8
  export class SelectFilterInput extends FilterInput {
@@ -53,10 +54,25 @@ export class SelectFilterInput extends FilterInput {
53
54
  }
54
55
 
55
56
  async valueToText(value: any): Promise<string> {
56
- const options = await this.resolveOptions()
57
+ // Degrade gracefully: if the options can't be fetched, or the applied filter
58
+ // value isn't among them (the option set changed, or the referenced record was
59
+ // deleted), fall back to the raw value instead of asserting — a throw here
60
+ // leaves the filter chip stuck on its loading placeholder forever.
61
+ let options: Option[] = []
62
+ try {
63
+ options = await this.resolveOptions()
64
+ } catch (e) {
65
+ // Let auth / session-expiry failures reach the app's error / re-auth flow
66
+ // instead of silently degrading to raw values. Other failures — an option
67
+ // miss or a non-auth fetch error — fall back so the chip stays readable.
68
+ if (isAuthError(e)) {
69
+ throw e
70
+ }
71
+ options = []
72
+ }
57
73
 
58
74
  return this.valueAsArray(value)
59
- .map((v) => options.find((o) => o.value === v)!.label)
75
+ .map((v) => options.find((o) => o.value === v)?.label ?? String(v))
60
76
  .join(', ')
61
77
  }
62
78
 
@@ -23,3 +23,41 @@ export function extractServerErrors(error: unknown): Record<string, string[]> |
23
23
 
24
24
  return errors
25
25
  }
26
+
27
+ /**
28
+ * Auth / session-expiry statuses (Laravel: 401 unauthenticated, 419 session /
29
+ * CSRF token expired). These need the app-level error flow — e.g. to prompt
30
+ * re-authentication — so Lens never surfaces them inline or swallows them into a
31
+ * snackbar; callers let them propagate to the global handler instead.
32
+ */
33
+ const AUTH_STATUSES = new Set([401, 419])
34
+
35
+ export function isAuthError(error: unknown): boolean {
36
+ return isFetchError(error) && error.status != null && AUTH_STATUSES.has(error.status)
37
+ }
38
+
39
+ /**
40
+ * Extract a human-readable top-level message from a failed request, when the
41
+ * server provides one worth showing the user. Laravel includes a `message` on
42
+ * most error responses — policy denials (`Response::deny('…')`), business-rule
43
+ * rejections, and 422 summaries. Limited to 4xx so internal 5xx detail never
44
+ * leaks to the UI, and excludes auth / session statuses (see `isAuthError`) so
45
+ * those reach the app's re-auth flow rather than being shown inline. 5xx /
46
+ * network / opaque / auth failures return `null` so the caller can fall back to
47
+ * its own generic copy or rethrow.
48
+ */
49
+ export function extractServerMessage(error: unknown): string | null {
50
+ if (
51
+ !isFetchError(error)
52
+ || error.status == null
53
+ || error.status < 400
54
+ || error.status >= 500
55
+ || AUTH_STATUSES.has(error.status)
56
+ ) {
57
+ return null
58
+ }
59
+
60
+ const message = (error.data as any)?.message
61
+
62
+ return typeof message === 'string' && message.trim() !== '' ? message : null
63
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@globalbrain/sefirot",
3
- "version": "4.50.0",
3
+ "version": "4.52.0",
4
4
  "description": "Vue Components for Global Brain Design System.",
5
5
  "keywords": [
6
6
  "components",