@globalbrain/sefirot 4.51.0 → 4.52.1

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'
@@ -205,16 +207,32 @@ const emit = defineEmits<{
205
207
 
206
208
  const { t } = useTrans({
207
209
  en: {
208
- write_error: 'Your latest changes might not be saved. Please reload the page, and contact support if the problem persists.',
209
- 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 data.',
216
+ busy_warning: 'Still saving — please wait a moment before changing the view.',
210
217
  refresh_text: 'Newer results are available.',
211
- 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'
212
222
  },
213
223
  ja: {
214
- write_error: '最新の変更が保存されていない可能性があります。ページを再読み込みし、問題が解決しない場合はサポートにお問い合わせください。',
215
- 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: '保存中です。表示を変更する前に少しお待ちください。',
216
231
  refresh_text: '新しい結果があります。',
217
- refresh_action: '更新'
232
+ refresh_failed_text: '保存できなかった変更があります。更新して最新の値に戻してください。',
233
+ refresh_action: '更新',
234
+ search_error: 'レコードを読み込めませんでした。',
235
+ search_retry: '再試行'
218
236
  }
219
237
  })
220
238
 
@@ -234,9 +252,12 @@ const hasInitialResults = ref(false)
234
252
 
235
253
  // A fresher server result fetched after the optimistic writes settled, awaiting
236
254
  // the user's explicit opt-in via the refresh button — never auto-applied (that
237
- // would momentarily revert the optimistic edits to stale data). Declared here
238
- // (ahead of the query handlers that clear it) so they can reference it.
239
- 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)
240
261
 
241
262
  // Count of optimistic background writes currently in flight. Declared here
242
263
  // (ahead of the debounced refetch that consults it) so a queued refetch can bail
@@ -341,13 +362,79 @@ const { data: result, execute: refresh, loading } = useQuery(async (http) => {
341
362
  prevFetchResult = res
342
363
 
343
364
  return res
344
- })
365
+ }, { immediate: false })
345
366
 
346
367
  // Keep `currentResult` pointing at the shown result. The reference changes only
347
368
  // on a wholesale replace; optimistic in-place edits keep the same object, so this
348
369
  // mirror always reflects them.
349
370
  watch(result, (r) => { currentResult = r ?? undefined }, { immediate: true })
350
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
+
351
438
  const doRefresh = useDebounceFn(() => {
352
439
  // A write or a blocking create may have started during the debounce window: the
353
440
  // busy lock guards the query handlers at click time, but not this deferred
@@ -357,7 +444,7 @@ const doRefresh = useDebounceFn(() => {
357
444
  // (Deliberate refreshes go through `forceRefresh`, which calls `refresh`
358
445
  // directly to bypass this guard.)
359
446
  if (pendingWrites.value > 0 || creating.value) { return }
360
- return refresh()
447
+ return runSearch()
361
448
  }, 50)
362
449
 
363
450
  if (props.urlSync) {
@@ -376,7 +463,14 @@ if (props.urlSync) {
376
463
  }
377
464
 
378
465
  const _showEmptyState = computed(() => {
379
- 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
380
474
  })
381
475
 
382
476
  const hasConditions = computed(() => {
@@ -481,6 +575,13 @@ const tableSelect = computed(() => {
481
575
  return withoutIndexField(result.value?.query.select ?? [])
482
576
  })
483
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
+
484
585
  // Whether the currently loaded rows actually carry the effective row identifier.
485
586
  // Editing / opening a row needs it (`resolveId`), but rows fetched while
486
587
  // `editable` was still false — or before an `indexField` change settled — won't
@@ -490,7 +591,7 @@ const tableSelect = computed(() => {
490
591
  const rowsCarryIndexField = computed(() => {
491
592
  const rows = result.value?.data
492
593
  if (!rows || rows.length === 0) { return true }
493
- return (props.indexField ?? 'id') in rows[0]
594
+ return idField.value in rows[0]
494
595
  })
495
596
 
496
597
  // The row-identifier the table uses as its selection key. An explicit
@@ -549,6 +650,12 @@ function createInputFilters(queryFilters: any[], filters: any[]) {
549
650
  // reconcile result (the refreshed state supersedes it).
550
651
  function refetch(): void {
551
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++
552
659
  doRefresh()
553
660
  }
554
661
 
@@ -690,8 +797,9 @@ const sheetError = ref(false)
690
797
  const reconciling = ref(false)
691
798
  const busy = computed(() => pendingWrites.value > 0 || reconciling.value)
692
799
 
693
- // Whether any write in the current in-flight batch failed; when so the reconcile
694
- // 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".
695
803
  let batchErrored = false
696
804
 
697
805
  // In-flight write chains keyed by record+field, so repeated writes to the same
@@ -716,10 +824,20 @@ let reconcileToken = 0
716
824
  // Resolve a record's identifier, unwrapping the id field's object shape
717
825
  // (`{ value, display, path }`) when present.
718
826
  function resolveId(record: Record<string, any>): any {
719
- const v = record?.[props.indexField ?? 'id']
827
+ const v = record?.[idField.value]
720
828
  return v !== null && typeof v === 'object' ? v.value : v
721
829
  }
722
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
+
723
841
  const { execute: executeUpdate } = useMutation((http, id: any, values: Record<string, any>) =>
724
842
  http.post(`${endpointBase.value}/update`, {
725
843
  entity: entityName.value, id, values, settings: { lang }
@@ -760,6 +878,14 @@ function guardBusy(): boolean {
760
878
  return false
761
879
  }
762
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
+
763
889
  // Value-level comparison (rows in order + total) deciding whether a reconcile
764
890
  // surfaced anything newer than the optimistic state on screen. `fresh` is the
765
891
  // canonical search-shaped result; `shown` is what's displayed, which may carry
@@ -774,6 +900,9 @@ function isSameResult(fresh: LensResult | null, shown: LensResult | null): boole
774
900
  const freshRow = fresh.data[i]
775
901
  const shownRow = shown.data[i]
776
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 }
777
906
  if (JSON.stringify(freshRow[key]) !== JSON.stringify(shownRow?.[key])) {
778
907
  return false
779
908
  }
@@ -784,8 +913,11 @@ function isSameResult(fresh: LensResult | null, shown: LensResult | null): boole
784
913
 
785
914
  // After the last in-flight write settles, fetch the canonical state for the
786
915
  // current query and, only if it differs from what's shown, stash it behind the
787
- // refresh button. The server is fresh by now (the write completed).
788
- 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> {
789
921
  const token = ++reconcileToken
790
922
  reconciling.value = true
791
923
  try {
@@ -798,9 +930,13 @@ async function reconcile(): Promise<void> {
798
930
  // Authoritative: stash the canonical result when it differs from what's
799
931
  // shown, otherwise clear any (possibly stale) stash — an earlier overlapping
800
932
  // reconcile may have left a pre-edit result behind.
801
- 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
802
936
  } catch {
803
- // 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.
804
940
  } finally {
805
941
  if (token === reconcileToken) {
806
942
  reconciling.value = false
@@ -810,8 +946,15 @@ async function reconcile(): Promise<void> {
810
946
 
811
947
  function applyLatest(): void {
812
948
  if (!latestState.value) { return }
813
- result.value = latestState.value
949
+ result.value = latestState.value.result
814
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++
815
958
  prevFetchInput = null
816
959
  rebindOpenSheet()
817
960
  }
@@ -853,9 +996,19 @@ async function forceRefresh(): Promise<void> {
853
996
  // fresh result when it resolves — same token mechanism as the write/create
854
997
  // races. This refetch captures the bumped token, so it isn't self-suppressed.
855
998
  searchToken++
999
+ const token = searchToken
856
1000
  prevFetchInput = null
857
1001
  latestState.value = null
858
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
+ }
859
1012
  rebindOpenSheet()
860
1013
  }
861
1014
 
@@ -890,7 +1043,7 @@ async function refreshCatalog(): Promise<void> {
890
1043
  // `resolveId` would be `undefined`), which would let a delete/save act on the
891
1044
  // wrong — or no — id during and after the refetch.
892
1045
  watch(
893
- () => (props.editable ? (props.indexField ?? 'id') : null),
1046
+ () => (props.editable ? idField.value : null),
894
1047
  (field, prev) => {
895
1048
  if (!field || field === prev) { return }
896
1049
  if (sheet.state.value && sheetMode.value === 'view') { sheet.off() }
@@ -905,14 +1058,40 @@ watch(
905
1058
  )
906
1059
 
907
1060
  // Track an optimistic background write: count it as pending, surface a snackbar
908
- // 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).
909
1063
  function hasPendingWrite(recordId: any): boolean {
910
1064
  return pendingByRecord.has(recordId)
911
1065
  }
912
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 data" (the change didn't take; reload to
1075
+ // resync); 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
+
913
1085
  // Track an optimistic background write for `recordId`, serialized per `key` so
914
- // writes to the same cell reach the server in edit order.
915
- 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 {
916
1095
  // A new write changes the displayed state. Clear any stashed reconcile result
917
1096
  // and invalidate an in-flight reconcile (advance the token so its result is
918
1097
  // dropped; clear its busy flag — `pendingWrites` keeps us busy), so neither
@@ -934,9 +1113,17 @@ function trackWrite(recordId: any, key: string, run: () => Promise<unknown>): vo
934
1113
  writeChains.set(key, current)
935
1114
 
936
1115
  current
937
- .catch(() => {
1116
+ .catch((e) => {
938
1117
  batchErrored = true
939
- 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
+ })
940
1127
  })
941
1128
  .finally(() => {
942
1129
  // Only drop the chain entry if a newer write hasn't replaced it.
@@ -953,9 +1140,12 @@ function trackWrite(recordId: any, key: string, run: () => Promise<unknown>): vo
953
1140
  if (pendingWrites.value === 0) {
954
1141
  const errored = batchErrored
955
1142
  batchErrored = false
956
- if (!errored) {
957
- reconcile()
958
- }
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)
959
1149
  }
960
1150
  })
961
1151
  }
@@ -971,13 +1161,21 @@ function save(record: Record<string, any>, values: Record<string, any>): void {
971
1161
  // row, so a follow-up save / delete would resolve to the new, not-yet-synced id
972
1162
  // and miss the in-flight record. Strip it so the invariant holds for every
973
1163
  // caller. (The identifier is still settable on create, which uses `create()`.)
974
- const indexField = props.indexField ?? 'id'
1164
+ const indexField = idField.value
975
1165
  if (indexField in values) {
976
1166
  values = { ...values }
977
1167
  delete values[indexField]
978
1168
  }
979
1169
  if (Object.keys(values).length === 0) { return }
980
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
+ }
981
1179
  // Serialize per record+field so two quick edits to the same cell apply in
982
1180
  // order; edits to disjoint fields run concurrently.
983
1181
  const fields = Object.keys(values)
@@ -995,7 +1193,7 @@ function save(record: Record<string, any>, values: Record<string, any>): void {
995
1193
  && k.slice(prefix.length).split(',').some((f) => fieldSet.has(f)))
996
1194
  .map(([, chain]) => chain)
997
1195
  const gate = Promise.allSettled(overlapping)
998
- trackWrite(id, key, () => gate.then(() => executeUpdate(id, values)))
1196
+ trackWrite(id, key, 'save', resolveLabel(record), () => gate.then(() => executeUpdate(id, values)))
999
1197
  }
1000
1198
 
1001
1199
  // Create — blocking. The slow POST runs to completion, then the catalog is
@@ -1023,6 +1221,11 @@ async function create(values: Record<string, any>): Promise<Record<string, any>>
1023
1221
  // during the create (doRefresh bails on `creating`); create's own forceRefresh
1024
1222
  // bypasses that by calling refresh directly.
1025
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
+ //
1026
1229
  // From here a refresh failure must not reject (the caller would treat the
1027
1230
  // create as failed and re-submit a duplicate); fall back to showing the new
1028
1231
  // record optimistically.
@@ -1089,7 +1292,7 @@ function remove(record: Record<string, any>): void {
1089
1292
  // `Promise.allSettled([])` resolves immediately, so this is a no-op gate when
1090
1293
  // the record has no in-flight writes.
1091
1294
  const gate = Promise.allSettled(pendingForRecord)
1092
- trackWrite(id, `${id}:__delete__`, () => gate.then(() => executeDelete(id)))
1295
+ trackWrite(id, `${id}:__delete__`, 'delete', resolveLabel(record), () => gate.then(() => executeDelete(id)))
1093
1296
  }
1094
1297
 
1095
1298
  async function openSheet(record: Record<string, any>): Promise<void> {
@@ -1199,7 +1402,7 @@ provideLensEdit({
1199
1402
  // sheet opener and to block identifier edits, so it must track a prop that
1200
1403
  // resolves (or changes) after mount — otherwise the new identifier column stays
1201
1404
  // non-clickable while the old field is still treated as the id.
1202
- get indexField() { return props.indexField ?? 'id' },
1405
+ get indexField() { return idField.value },
1203
1406
  canEdit,
1204
1407
  canDelete,
1205
1408
  resolveId,
@@ -1323,32 +1526,48 @@ defineExpose({
1323
1526
  </div>
1324
1527
  <SDivider />
1325
1528
  <div v-if="latestState && !busy" class="refresh-banner">
1326
- <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>
1327
1530
  <SButton size="mini" mode="info" :label="t.refresh_action" @click="applyLatest" />
1328
1531
  </div>
1329
1532
  <div class="list" :style="tableMaxHeight">
1330
- <LensTable
1331
- :result
1332
- :loading
1333
- :overrides="_overrides"
1334
- :select="tableSelect"
1335
- :index-field="tableIndexField"
1336
- :selected
1337
- :clickable-fields
1338
- :inline-editable
1339
- @filter-updated="onInlineFilterUpdated"
1340
- @sort-updated="onSortUpdated"
1341
- @update:selected="onUpdateSelected"
1342
- @cell-clicked="(v, r) => emit('cell-clicked', v, r)"
1343
- />
1344
- <LensCatalogFooter
1345
- v-if="result && result?.pagination.total > 0"
1346
- :class="{ 'is-busy': busy }"
1347
- :result
1348
- :loading
1349
- @prev="onPrev"
1350
- @next="onNext"
1351
- />
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>
1352
1571
  </div>
1353
1572
  </div>
1354
1573
 
@@ -1384,7 +1603,7 @@ defineExpose({
1384
1603
  :loading="sheetLoading"
1385
1604
  :error="sheetError"
1386
1605
  :fields="result.fields"
1387
- :index-field="indexField ?? 'id'"
1606
+ :index-field="idField"
1388
1607
  :width="sheetWidth"
1389
1608
  @close="sheet.off"
1390
1609
  >
@@ -1461,6 +1680,23 @@ defineExpose({
1461
1680
  flex-grow: 1;
1462
1681
  }
1463
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
+
1464
1700
  .control-skeleton {
1465
1701
  height: 56px;
1466
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
@@ -202,13 +204,24 @@ async function onCreate() {
202
204
  await edit!.create(values)
203
205
  emit('close')
204
206
  } catch (e) {
205
- // Surface backend validation errors (e.g. a duplicate `unique` value) on
206
- // 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.
207
214
  const errors = extractServerErrors(e)
208
- if (!errors) {
209
- 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
210
223
  }
211
- serverErrors.value = errors
224
+ throw e
212
225
  } finally {
213
226
  saving.value = false
214
227
  }
@@ -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,12 +134,17 @@ 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
150
  props.inlineEditable
@@ -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,
@@ -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
  }
@@ -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.51.0",
3
+ "version": "4.52.1",
4
4
  "description": "Vue Components for Global Brain Design System.",
5
5
  "keywords": [
6
6
  "components",