@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.
- package/lib/blocks/lens/components/LensCatalog.vue +294 -58
- package/lib/blocks/lens/components/LensSheet.vue +21 -8
- package/lib/blocks/lens/components/LensTable.vue +26 -7
- package/lib/blocks/lens/composables/FileDownloader.ts +28 -1
- package/lib/blocks/lens/composables/ResourceFetcher.ts +6 -0
- package/lib/blocks/lens/filter-inputs/SelectFilterInput.ts +18 -2
- package/lib/blocks/lens/validation/ServerErrors.ts +38 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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).
|
|
238
|
-
//
|
|
239
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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;
|
|
694
|
-
//
|
|
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?.[
|
|
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
|
-
|
|
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)
|
|
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 ?
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
957
|
-
|
|
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 =
|
|
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
|
|
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
|
-
<
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
<
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
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="
|
|
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
|
-
'
|
|
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
|
-
//
|
|
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 (
|
|
209
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
+
}
|