@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.
- package/lib/blocks/lens/components/LensCatalog.vue +355 -72
- package/lib/blocks/lens/components/LensSheet.vue +40 -10
- package/lib/blocks/lens/components/LensSheetField.vue +9 -1
- package/lib/blocks/lens/components/LensTable.vue +28 -9
- package/lib/blocks/lens/components/LensTableEditableCell.vue +62 -0
- package/lib/blocks/lens/composables/FileDownloader.ts +28 -1
- package/lib/blocks/lens/composables/LensEdit.ts +6 -0
- 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'
|
|
@@ -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
|
-
|
|
151
|
-
|
|
152
|
-
//
|
|
153
|
-
//
|
|
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
|
-
//
|
|
161
|
-
//
|
|
162
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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).
|
|
215
|
-
//
|
|
216
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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;
|
|
671
|
-
//
|
|
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?.[
|
|
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
|
-
|
|
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)
|
|
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 ?
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
934
|
-
|
|
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 =
|
|
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
|
-
//
|
|
1125
|
-
// this method is exposed (and provided to
|
|
1126
|
-
|
|
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
|
|
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
|
|
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
|
-
<
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
<
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
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="
|
|
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
|
-
'
|
|
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
|
-
//
|
|
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 (
|
|
204
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
-
props.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|