@globalbrain/sefirot 4.46.0 → 4.48.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/FieldData.ts +27 -0
- package/lib/blocks/lens/ResourceFetcher.ts +5 -1
- package/lib/blocks/lens/Rule.ts +10 -0
- package/lib/blocks/lens/components/LensCatalog.vue +826 -39
- package/lib/blocks/lens/components/LensSheet.vue +465 -0
- package/lib/blocks/lens/components/LensSheetField.vue +210 -0
- package/lib/blocks/lens/components/LensTable.vue +79 -5
- package/lib/blocks/lens/components/LensTableEditableCell.vue +285 -0
- package/lib/blocks/lens/composables/CatalogUrlQuerySync.ts +146 -0
- package/lib/blocks/lens/composables/LensEdit.ts +59 -0
- package/lib/blocks/lens/composables/LensInlineEdit.ts +36 -0
- package/lib/blocks/lens/composables/ResourceFetcher.ts +20 -7
- package/lib/blocks/lens/composables/SetupLens.ts +2 -0
- package/lib/blocks/lens/fields/AvatarField.ts +43 -0
- package/lib/blocks/lens/fields/ContentField.ts +21 -10
- package/lib/blocks/lens/fields/Field.ts +26 -1
- package/lib/blocks/lens/fields/FileUploadField.ts +8 -0
- package/lib/blocks/lens/fields/RelatedManyField.ts +44 -7
- package/lib/blocks/lens/fields/RelatedOneField.ts +42 -7
- package/lib/blocks/lens/validation/RuleMapper.ts +22 -3
- package/lib/blocks/lens/validation/ServerErrors.ts +25 -0
- package/lib/components/SInputSwitches.vue +2 -2
- package/lib/components/SSheet.vue +104 -0
- package/lib/composables/Api.ts +18 -10
- package/lib/composables/Url.ts +19 -2
- package/lib/styles/variables.css +1 -0
- package/lib/validation/rules/index.ts +1 -0
- package/lib/validation/rules/slackChannelLink.ts +15 -0
- package/lib/validation/validators/index.ts +1 -0
- package/lib/validation/validators/slackChannelLink.ts +52 -0
- package/package.json +17 -17
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { useDebounceFn, useElementSize } from '@vueuse/core'
|
|
3
|
-
import { computed, ref, watch } from 'vue'
|
|
3
|
+
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
4
|
+
import SButton from '../../../components/SButton.vue'
|
|
4
5
|
import SDivider from '../../../components/SDivider.vue'
|
|
5
|
-
import { useQuery } from '../../../composables/Api'
|
|
6
|
-
import { useLang } from '../../../composables/Lang'
|
|
6
|
+
import { useMutation, useQuery } from '../../../composables/Api'
|
|
7
|
+
import { useLang, useTrans } from '../../../composables/Lang'
|
|
7
8
|
import { usePower } from '../../../composables/Power'
|
|
9
|
+
import { useSnackbars } from '../../../stores/Snackbars'
|
|
8
10
|
import { type FieldData } from '../FieldData'
|
|
9
11
|
import { type LensQuery, type LensQuerySort } from '../LensQuery'
|
|
10
12
|
import { type LensResult } from '../LensResult'
|
|
13
|
+
import { useCatalogUrlQuerySync } from '../composables/CatalogUrlQuerySync'
|
|
14
|
+
import { provideLensEdit } from '../composables/LensEdit'
|
|
11
15
|
import LensCatalogControl, { type FilterPresets } from './LensCatalogControl.vue'
|
|
12
16
|
import LensCatalogFooter from './LensCatalogFooter.vue'
|
|
13
17
|
import LensCatalogStateFilter from './LensCatalogStateFilter.vue'
|
|
14
18
|
import LensCatalogStateSort from './LensCatalogStateSort.vue'
|
|
15
19
|
import LensFormFilter from './LensFormFilter.vue'
|
|
16
20
|
import LensFormView from './LensFormView.vue'
|
|
21
|
+
import LensSheet from './LensSheet.vue'
|
|
17
22
|
import LensTable from './LensTable.vue'
|
|
18
23
|
|
|
19
24
|
export interface Props {
|
|
@@ -110,6 +115,51 @@ export interface Props {
|
|
|
110
115
|
// user add any available column to the view (e.g. a saved-view editor),
|
|
111
116
|
// not just toggle the ones already selected.
|
|
112
117
|
loadSelectable?: boolean
|
|
118
|
+
|
|
119
|
+
// Whether to sync the catalog state with the URL query string so that
|
|
120
|
+
// it survives page reloads and can be shared as a link. The synced
|
|
121
|
+
// state is the search query (`q`), `filters`, `sort`, and `page`.
|
|
122
|
+
//
|
|
123
|
+
// While enabled, the catalog owns the page's entire query string:
|
|
124
|
+
// query params other than the above are removed whenever the catalog
|
|
125
|
+
// state is written back to the URL. For the same reason, at most one
|
|
126
|
+
// catalog per page may enable this option. The option is read once on
|
|
127
|
+
// setup, so toggling it afterwards has no effect.
|
|
128
|
+
urlSync?: boolean
|
|
129
|
+
|
|
130
|
+
// Enable CRUD editing for the catalog. When set, fields the backend
|
|
131
|
+
// marks `showOnUpdate` become inline-editable, clicking a row's id cell
|
|
132
|
+
// opens the record sheet (view + per-field edit + delete), and the
|
|
133
|
+
// `openCreate()` method / sheet create mode become available. The CRUD
|
|
134
|
+
// endpoints are derived from `endpoint` by replacing the trailing
|
|
135
|
+
// `/search` with `/update`, `/create`, and `/delete`.
|
|
136
|
+
//
|
|
137
|
+
// The row identifier (`indexField`, defaulting to `id`) is never
|
|
138
|
+
// inline- or sheet-editable on an existing row: optimistically changing
|
|
139
|
+
// a row's id before the slow write settles would re-key the row, so a
|
|
140
|
+
// follow-up save/delete would address the not-yet-synced new id. It can
|
|
141
|
+
// still be set on creation (via a `showOnCreate` field).
|
|
142
|
+
//
|
|
143
|
+
// An editable catalog must keep its index field among the rendered columns,
|
|
144
|
+
// because the row's sheet opener — and therefore the only way to view or
|
|
145
|
+
// delete a record — is the index-field cell. The default `id` identifier is
|
|
146
|
+
// kept when `select` is empty (server defaults), so that case works as-is; a
|
|
147
|
+
// *custom* `indexField`, however, must be listed in `select` explicitly (an
|
|
148
|
+
// empty/default `select` drops it from the rendered columns, leaving the rows
|
|
149
|
+
// with no opener).
|
|
150
|
+
editable?: boolean
|
|
151
|
+
|
|
152
|
+
// Whether new records can be created (enables create mode in the sheet
|
|
153
|
+
// and the exposed `openCreate()` method). Requires `editable`.
|
|
154
|
+
creatable?: boolean
|
|
155
|
+
|
|
156
|
+
// Width of the record sheet (any valid CSS width). Defaults to `480px`.
|
|
157
|
+
sheetWidth?: string
|
|
158
|
+
|
|
159
|
+
// Enable inline editing directly in the table: cells for `showOnUpdate`
|
|
160
|
+
// fields gain a hover edit affordance that opens an inline editor. Requires
|
|
161
|
+
// `editable` (it reuses the same CRUD edit context as the sheet).
|
|
162
|
+
inlineEdit?: boolean
|
|
113
163
|
}
|
|
114
164
|
|
|
115
165
|
const props = withDefaults(defineProps<Props>(), {
|
|
@@ -130,6 +180,23 @@ const emit = defineEmits<{
|
|
|
130
180
|
'cell-clicked': [value: any, record: any]
|
|
131
181
|
}>()
|
|
132
182
|
|
|
183
|
+
const { t } = useTrans({
|
|
184
|
+
en: {
|
|
185
|
+
write_error: 'Your latest changes might not be saved. Please reload the page, and contact support if the problem persists.',
|
|
186
|
+
busy_warning: 'A save is still in progress — changes you make now may not be saved.',
|
|
187
|
+
refresh_text: 'Newer results are available.',
|
|
188
|
+
refresh_action: 'Refresh'
|
|
189
|
+
},
|
|
190
|
+
ja: {
|
|
191
|
+
write_error: '最新の変更が保存されていない可能性があります。ページを再読み込みし、問題が解決しない場合はサポートにお問い合わせください。',
|
|
192
|
+
busy_warning: '保存処理中です。ここでの変更は保存されない可能性があります。',
|
|
193
|
+
refresh_text: '新しい結果があります。',
|
|
194
|
+
refresh_action: '更新'
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
const snackbars = useSnackbars()
|
|
199
|
+
|
|
133
200
|
const filterDialog = usePower()
|
|
134
201
|
const viewDialog = usePower()
|
|
135
202
|
|
|
@@ -142,9 +209,36 @@ const conditionBlocksEl = ref<HTMLElement | null>(null)
|
|
|
142
209
|
// due to user filtering.
|
|
143
210
|
const hasInitialResults = ref(false)
|
|
144
211
|
|
|
212
|
+
// A fresher server result fetched after the optimistic writes settled, awaiting
|
|
213
|
+
// the user's explicit opt-in via the refresh button — never auto-applied (that
|
|
214
|
+
// would momentarily revert the optimistic edits to stale data). Declared here
|
|
215
|
+
// (ahead of the query handlers that clear it) so they can reference it.
|
|
216
|
+
const latestState = ref<LensResult | null>(null)
|
|
217
|
+
|
|
218
|
+
// Count of optimistic background writes currently in flight. Declared here
|
|
219
|
+
// (ahead of the debounced refetch that consults it) so a queued refetch can bail
|
|
220
|
+
// when a write started during its debounce window. See the CRUD section below
|
|
221
|
+
// for the full optimistic-write strategy this is part of.
|
|
222
|
+
const pendingWrites = ref(0)
|
|
223
|
+
|
|
224
|
+
// Whether a (blocking) create POST is in flight. Not part of `busy` — the create
|
|
225
|
+
// sheet covers the controls — but it arms the unload guard, and the debounced
|
|
226
|
+
// refetch consults it (declared here for that reason), since a search issued
|
|
227
|
+
// before the new row exists must not assign its rows over the just-created record.
|
|
228
|
+
const creating = ref(false)
|
|
229
|
+
|
|
145
230
|
let prevFetchInput: LensQuery | null = null
|
|
146
231
|
let prevFetchResult: LensResult | null = null
|
|
147
232
|
|
|
233
|
+
// Monotonic token bumped whenever an optimistic write starts. The main search
|
|
234
|
+
// reads it so a normal search/refetch already past the network boundary when a
|
|
235
|
+
// write begins won't assign its pre-write rows over the optimistic patch — the
|
|
236
|
+
// busy lock guards the handlers, but not a request already in flight.
|
|
237
|
+
let searchToken = 0
|
|
238
|
+
// Mirror of the currently-shown result (kept in sync just below), so the search
|
|
239
|
+
// fetcher can return it — preserving the optimistic state — when superseded.
|
|
240
|
+
let currentResult: LensResult | undefined
|
|
241
|
+
|
|
148
242
|
// `_select` carries the caller's intent. The index field is preserved
|
|
149
243
|
// here if it was listed explicitly so the corresponding column gets
|
|
150
244
|
// rendered, and is only added to the request payload separately (see
|
|
@@ -183,9 +277,17 @@ const perPage = ref(100)
|
|
|
183
277
|
|
|
184
278
|
const lang = useLang()
|
|
185
279
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
280
|
+
// The entity name sent on every request. Falls back to the `__no_entity__`
|
|
281
|
+
// sentinel when the prop is omitted, so the search and the CRUD/detail calls
|
|
282
|
+
// target the same entity — otherwise the search would use the fallback while an
|
|
283
|
+
// omitted-entity CRUD payload would serialize `undefined` away and hit no entity.
|
|
284
|
+
const entityName = computed(() => props.entity ?? '__no_entity__')
|
|
285
|
+
|
|
286
|
+
// Build the search request from the catalog's current state. Shared by the
|
|
287
|
+
// main search and the background reconcile fetch so both query identically.
|
|
288
|
+
function buildSearchInput(): LensQuery {
|
|
289
|
+
return {
|
|
290
|
+
entity: entityName.value,
|
|
189
291
|
select: withIndexField(_select.value),
|
|
190
292
|
filters: createInputFilters(queryFilter.value, _filters.value),
|
|
191
293
|
sort: _sort.value.length > 0 ? _sort.value : defaultSort.value ?? [],
|
|
@@ -193,20 +295,62 @@ const { data: result, execute: refresh, loading } = useQuery(async (http) => {
|
|
|
193
295
|
page: page.value,
|
|
194
296
|
perPage: perPage.value
|
|
195
297
|
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const { data: result, execute: refresh, loading } = useQuery(async (http) => {
|
|
301
|
+
const input = buildSearchInput()
|
|
196
302
|
|
|
197
303
|
if (prevFetchInput && JSON.stringify(prevFetchInput) === JSON.stringify(input)) {
|
|
198
304
|
return prevFetchResult!
|
|
199
305
|
}
|
|
200
306
|
|
|
307
|
+
const token = searchToken
|
|
201
308
|
const res = await http.post<LensResult>(props.endpoint, input)
|
|
202
309
|
|
|
310
|
+
// A write started while this search was in flight; its rows are pre-write, so
|
|
311
|
+
// keep the optimistic state on screen rather than clobbering it. The post-batch
|
|
312
|
+
// reconcile surfaces the canonical state behind the refresh banner instead.
|
|
313
|
+
if (token !== searchToken) {
|
|
314
|
+
return currentResult ?? res
|
|
315
|
+
}
|
|
316
|
+
|
|
203
317
|
prevFetchInput = input
|
|
204
318
|
prevFetchResult = res
|
|
205
319
|
|
|
206
320
|
return res
|
|
207
321
|
})
|
|
208
322
|
|
|
209
|
-
|
|
323
|
+
// Keep `currentResult` pointing at the shown result. The reference changes only
|
|
324
|
+
// on a wholesale replace; optimistic in-place edits keep the same object, so this
|
|
325
|
+
// mirror always reflects them.
|
|
326
|
+
watch(result, (r) => { currentResult = r ?? undefined }, { immediate: true })
|
|
327
|
+
|
|
328
|
+
const doRefresh = useDebounceFn(() => {
|
|
329
|
+
// A write or a blocking create may have started during the debounce window: the
|
|
330
|
+
// busy lock guards the query handlers at click time, but not this deferred
|
|
331
|
+
// fetch. Bail if so — reading now would return pre-write / pre-create rows and
|
|
332
|
+
// overwrite the optimistic edit or the just-created record. The post-batch
|
|
333
|
+
// reconcile / create path surfaces the canonical state once things settle.
|
|
334
|
+
// (Deliberate refreshes go through `forceRefresh`, which calls `refresh`
|
|
335
|
+
// directly to bypass this guard.)
|
|
336
|
+
if (pendingWrites.value > 0 || creating.value) { return }
|
|
337
|
+
return refresh()
|
|
338
|
+
}, 50)
|
|
339
|
+
|
|
340
|
+
if (props.urlSync) {
|
|
341
|
+
// Route URL-driven changes (back/forward, shared links) through `refetch` so
|
|
342
|
+
// they clear any stashed reconcile result, just like the in-app controls — a
|
|
343
|
+
// refresh button must never apply data fetched for a previous query.
|
|
344
|
+
//
|
|
345
|
+
// Known limitation: unlike the in-app controls, this path is not gated by the
|
|
346
|
+
// busy lock, so a URL change landing mid-write could refetch stale data and
|
|
347
|
+
// drop an optimistic edit. Unreachable today (no page enables `urlSync` with
|
|
348
|
+
// `editable`); revisit if one ever does.
|
|
349
|
+
useCatalogUrlQuerySync(
|
|
350
|
+
{ query, filters: _filters, sort: _sort, page },
|
|
351
|
+
refetch
|
|
352
|
+
)
|
|
353
|
+
}
|
|
210
354
|
|
|
211
355
|
const _showEmptyState = computed(() => {
|
|
212
356
|
return props.showEmptyState && !hasInitialResults.value && result.value?.data.length === 0
|
|
@@ -235,7 +379,9 @@ const conditionBlocksSize = useElementSize(conditionBlocksEl)
|
|
|
235
379
|
|
|
236
380
|
const headerHeight = 'var(--lens-catalog-global-height-offset)'
|
|
237
381
|
const controlHeight = '56px - 1px'
|
|
238
|
-
const conditionBlocksHeight = computed(() =>
|
|
382
|
+
const conditionBlocksHeight = computed(() =>
|
|
383
|
+
conditionBlocksSize.height.value > 0 ? `${conditionBlocksSize.height.value}px - 1px` : '0px'
|
|
384
|
+
)
|
|
239
385
|
const columnsHeight = '40px - 1px'
|
|
240
386
|
const footerHeight = '56px - 1px'
|
|
241
387
|
|
|
@@ -261,9 +407,13 @@ const tableMaxHeight = computed(() => {
|
|
|
261
407
|
return undefined
|
|
262
408
|
}
|
|
263
409
|
if (props.height === 'fill') {
|
|
264
|
-
return
|
|
410
|
+
return '--table-max-height: '
|
|
411
|
+
+ `calc(100vh - ${headerHeight} - ${controlHeight} - ${conditionBlocksHeight.value}`
|
|
412
|
+
+ ` - ${columnsHeight} - ${footerHeight} - ${props.heightOffset ?? '0px'})`
|
|
265
413
|
}
|
|
266
|
-
return
|
|
414
|
+
return '--table-max-height: '
|
|
415
|
+
+ `calc(${props.height} - ${controlHeight} - ${conditionBlocksHeight.value}`
|
|
416
|
+
+ ` - ${columnsHeight} - ${footerHeight})`
|
|
267
417
|
})
|
|
268
418
|
|
|
269
419
|
// Initial setup when the result is loaded for the first time. When the
|
|
@@ -308,6 +458,36 @@ const tableSelect = computed(() => {
|
|
|
308
458
|
return withoutIndexField(result.value?.query.select ?? [])
|
|
309
459
|
})
|
|
310
460
|
|
|
461
|
+
// Whether the currently loaded rows actually carry the effective row identifier.
|
|
462
|
+
// Editing / opening a row needs it (`resolveId`), but rows fetched while
|
|
463
|
+
// `editable` was still false — or before an `indexField` change settled — won't
|
|
464
|
+
// have it until the triggered refetch lands. Editing is gated on this (see the
|
|
465
|
+
// `editable` getter) so a quick save in that window can't post `id: undefined`.
|
|
466
|
+
// An empty result has nothing to edit, so it doesn't block.
|
|
467
|
+
const rowsCarryIndexField = computed(() => {
|
|
468
|
+
const rows = result.value?.data
|
|
469
|
+
if (!rows || rows.length === 0) { return true }
|
|
470
|
+
return (props.indexField ?? 'id') in rows[0]
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
// The row-identifier the table uses as its selection key. An explicit
|
|
474
|
+
// `indexField`, or `id` for editable catalogs (which always carry it). Without
|
|
475
|
+
// this, an editable catalog that relies on the default would leave the table on
|
|
476
|
+
// positional selection keys, so an optimistic create/delete that shifts rows
|
|
477
|
+
// would leave `selected` pointing at the wrong records. Mirrors `edit.indexField`.
|
|
478
|
+
//
|
|
479
|
+
// Gated on `rowsCarryIndexField`: when `editable` flips true (or `indexField`
|
|
480
|
+
// changes) the on-screen rows don't carry the new key until the triggered refetch
|
|
481
|
+
// lands. Switching STable onto it during that window keys every row's selection to
|
|
482
|
+
// `undefined` — wiping the current selection and making any row-select emit
|
|
483
|
+
// `[undefined]` (so parent bulk actions act on no/wrong records). Stay on the
|
|
484
|
+
// previous positional key until the loaded rows carry it, matching the
|
|
485
|
+
// `edit.editable` gate so selection and editing flip together.
|
|
486
|
+
const tableIndexField = computed(() => {
|
|
487
|
+
const field = props.indexField ?? (props.editable ? 'id' : undefined)
|
|
488
|
+
return field && rowsCarryIndexField.value ? field : undefined
|
|
489
|
+
})
|
|
490
|
+
|
|
311
491
|
// The `indexField` is appended to the request `select` so the server
|
|
312
492
|
// returns its value on every row, but it is kept out of the internal
|
|
313
493
|
// `_select` / `_selectable` state so the caller-facing concept of
|
|
@@ -315,13 +495,16 @@ const tableSelect = computed(() => {
|
|
|
315
495
|
// not a column the user picked). The corresponding column is also
|
|
316
496
|
// hidden from the rendered table by `LensTable`.
|
|
317
497
|
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
498
|
+
// Editable catalogs must carry a row identifier so updates / deletes can
|
|
499
|
+
// address each row; when no `indexField` is configured the identifier
|
|
500
|
+
// defaults to `id`. When the caller has no concrete select list, nothing
|
|
501
|
+
// is added either — leaving the request empty lets the server use its own
|
|
502
|
+
// defaults. See `indexField` prop docs above.
|
|
321
503
|
function withIndexField(fields: string[]): string[] {
|
|
322
504
|
if (fields.length === 0) { return [] }
|
|
323
|
-
|
|
324
|
-
return [...fields
|
|
505
|
+
const field = props.indexField ?? (props.editable ? 'id' : null)
|
|
506
|
+
if (!field || fields.includes(field)) { return [...fields] }
|
|
507
|
+
return [...fields, field]
|
|
325
508
|
}
|
|
326
509
|
|
|
327
510
|
function withoutIndexField(fields: string[]): string[] {
|
|
@@ -339,25 +522,35 @@ function createInputFilters(queryFilters: any[], filters: any[]) {
|
|
|
339
522
|
].filter((f) => f?.length > 0)
|
|
340
523
|
}
|
|
341
524
|
|
|
525
|
+
// Re-run the search for the new query state, dropping any stashed
|
|
526
|
+
// reconcile result (the refreshed state supersedes it).
|
|
527
|
+
function refetch(): void {
|
|
528
|
+
latestState.value = null
|
|
529
|
+
doRefresh()
|
|
530
|
+
}
|
|
531
|
+
|
|
342
532
|
function onQuery(value: string | null) {
|
|
533
|
+
if (guardBusy()) { return }
|
|
343
534
|
query.value = value
|
|
344
535
|
page.value = 1
|
|
345
|
-
|
|
536
|
+
refetch()
|
|
346
537
|
}
|
|
347
538
|
|
|
348
539
|
function onFiltersUpdated(filters: any[]) {
|
|
540
|
+
if (guardBusy()) { return }
|
|
349
541
|
_filters.value = filters
|
|
350
542
|
page.value = 1
|
|
351
|
-
|
|
543
|
+
refetch()
|
|
352
544
|
filterDialog.off()
|
|
353
545
|
emit('filters-updated', _filters.value)
|
|
354
546
|
}
|
|
355
547
|
|
|
356
548
|
function onInlineFilterUpdated(filter: any[]) {
|
|
549
|
+
if (guardBusy()) { return }
|
|
357
550
|
const sameFilterIndex = _filters.value.findIndex((f) => f[0] === filter[0])
|
|
358
551
|
sameFilterIndex === -1 ? applyNewFilter(filter) : replaceFilter(sameFilterIndex, filter)
|
|
359
552
|
page.value = 1
|
|
360
|
-
|
|
553
|
+
refetch()
|
|
361
554
|
emit('filters-updated', _filters.value)
|
|
362
555
|
}
|
|
363
556
|
|
|
@@ -383,34 +576,42 @@ function replaceFilter(index: number, filter: any[]) {
|
|
|
383
576
|
}
|
|
384
577
|
|
|
385
578
|
function onResetFilters() {
|
|
579
|
+
if (guardBusy()) { return }
|
|
386
580
|
_filters.value = []
|
|
387
581
|
query.value = null
|
|
388
582
|
page.value = 1
|
|
389
|
-
|
|
583
|
+
refetch()
|
|
390
584
|
emit('filters-updated', _filters.value)
|
|
391
585
|
}
|
|
392
586
|
|
|
393
587
|
function onSortUpdated(sort: LensQuerySort) {
|
|
588
|
+
if (guardBusy()) { return }
|
|
394
589
|
_sort.value = [sort]
|
|
395
590
|
page.value = 1
|
|
396
|
-
|
|
591
|
+
refetch()
|
|
397
592
|
emit('sort-updated', _sort.value)
|
|
398
593
|
}
|
|
399
594
|
|
|
400
595
|
function onResetSorts() {
|
|
596
|
+
if (guardBusy()) { return }
|
|
401
597
|
_sort.value = []
|
|
402
598
|
page.value = 1
|
|
403
|
-
|
|
599
|
+
refetch()
|
|
404
600
|
emit('sort-updated', _sort.value)
|
|
405
601
|
}
|
|
406
602
|
|
|
407
|
-
function onViewUpdated(
|
|
603
|
+
function onViewUpdated(
|
|
604
|
+
newSelect: string[],
|
|
605
|
+
newSelectable: string[],
|
|
606
|
+
overrides: Record<string, Partial<FieldData>>
|
|
607
|
+
) {
|
|
608
|
+
if (guardBusy()) { return }
|
|
408
609
|
// Treat updates from the view form as deliberate user intent: if the
|
|
409
610
|
// user picked the index field on purpose, surface its column.
|
|
410
611
|
_select.value = newSelect
|
|
411
612
|
_selectable.value = newSelectable
|
|
412
613
|
_overrides.value = overrides
|
|
413
|
-
|
|
614
|
+
refetch()
|
|
414
615
|
viewDialog.off()
|
|
415
616
|
emit('select-updated', _select.value)
|
|
416
617
|
emit('selectable-updated', _selectable.value)
|
|
@@ -426,32 +627,563 @@ function onResetSelection() {
|
|
|
426
627
|
}
|
|
427
628
|
|
|
428
629
|
function onPrev() {
|
|
630
|
+
if (guardBusy()) { return }
|
|
429
631
|
page.value--
|
|
430
|
-
|
|
632
|
+
refetch()
|
|
431
633
|
}
|
|
432
634
|
|
|
433
635
|
function onNext() {
|
|
636
|
+
if (guardBusy()) { return }
|
|
434
637
|
page.value++
|
|
435
|
-
|
|
638
|
+
refetch()
|
|
436
639
|
}
|
|
437
640
|
|
|
438
|
-
//
|
|
439
|
-
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
641
|
+
// --- CRUD editing ----------------------------------------------------------
|
|
642
|
+
|
|
643
|
+
// Derive the CRUD endpoints from the (search) endpoint, e.g.
|
|
644
|
+
// `/api/admin/lens/search` -> `/api/admin/lens/{update,create,delete}`.
|
|
645
|
+
const endpointBase = computed(() => props.endpoint.replace(/\/search$/, ''))
|
|
646
|
+
|
|
647
|
+
const sheet = usePower()
|
|
648
|
+
const sheetMode = ref<'view' | 'create'>('view')
|
|
649
|
+
const sheetRecord = ref<Record<string, any> | null>(null)
|
|
650
|
+
|
|
651
|
+
// Whether the open detail sheet is still loading its full record via `/show`.
|
|
652
|
+
// The sheet opens immediately (so a click gives instant feedback) and shows a
|
|
653
|
+
// spinner until the record is loaded, rather than rendering partial fields.
|
|
654
|
+
const sheetLoading = ref(false)
|
|
655
|
+
|
|
656
|
+
// Whether the detail load (`/show`) failed. The sheet then shows an error
|
|
657
|
+
// asking the user to reload, rather than rendering a partial record.
|
|
658
|
+
const sheetError = ref(false)
|
|
659
|
+
|
|
660
|
+
// The slow target API can take a long time to sync a write, and a read during
|
|
661
|
+
// that window returns stale (pre-write) data. So updates and deletes are
|
|
662
|
+
// applied to the in-memory result immediately and persisted in the background;
|
|
663
|
+
// the server is never re-read to reconcile until the write settles. While any
|
|
664
|
+
// write is in flight the catalog is "busy": query controls and the refresh
|
|
665
|
+
// affordance are locked and an unload guard is armed. (`pendingWrites` itself is
|
|
666
|
+
// declared near the top so the debounced refetch can consult it.)
|
|
667
|
+
const reconciling = ref(false)
|
|
668
|
+
const busy = computed(() => pendingWrites.value > 0 || reconciling.value)
|
|
669
|
+
|
|
670
|
+
// Whether any write in the current in-flight batch failed; when so the reconcile
|
|
671
|
+
// is skipped (the snackbar already told the user to reload).
|
|
672
|
+
let batchErrored = false
|
|
673
|
+
|
|
674
|
+
// In-flight write chains keyed by record+field, so repeated writes to the same
|
|
675
|
+
// cell are serialized (sent in edit order — the last edit wins — even though
|
|
676
|
+
// the UI already shows the latest value optimistically). Writes to different
|
|
677
|
+
// cells run concurrently; the backend persists only the changed columns.
|
|
678
|
+
const writeChains = new Map<string, Promise<unknown>>()
|
|
679
|
+
|
|
680
|
+
// Count of in-flight writes per record id, so `/show` for a record that's
|
|
681
|
+
// mid-write can avoid overwriting its optimistic values with stale data.
|
|
682
|
+
const pendingByRecord = new Map<any, number>()
|
|
683
|
+
|
|
684
|
+
// Monotonic token for the in-flight `openSheet` `/show` request, so a newer
|
|
685
|
+
// open (another row, or the create form) supersedes an older one and the stale
|
|
686
|
+
// detail fetch doesn't open over it when it resolves.
|
|
687
|
+
let showRequestSeq = 0
|
|
688
|
+
|
|
689
|
+
// Monotonic token for the in-flight reconcile fetch, so a newer batch's
|
|
690
|
+
// reconcile supersedes an older one still in flight.
|
|
691
|
+
let reconcileToken = 0
|
|
692
|
+
|
|
693
|
+
// Resolve a record's identifier, unwrapping the id field's object shape
|
|
694
|
+
// (`{ value, display, path }`) when present.
|
|
695
|
+
function resolveId(record: Record<string, any>): any {
|
|
696
|
+
const v = record?.[props.indexField ?? 'id']
|
|
697
|
+
return v !== null && typeof v === 'object' ? v.value : v
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const { execute: executeUpdate } = useMutation((http, id: any, values: Record<string, any>) =>
|
|
701
|
+
http.post(`${endpointBase.value}/update`, {
|
|
702
|
+
entity: entityName.value, id, values, settings: { lang }
|
|
703
|
+
})
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
const { execute: executeCreate } = useMutation((http, values: Record<string, any>) =>
|
|
707
|
+
http.post<LensResult & { data: Record<string, any> }>(`${endpointBase.value}/create`, {
|
|
708
|
+
entity: entityName.value, values, settings: { lang }
|
|
709
|
+
})
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
const { execute: executeDelete } = useMutation((http, id: any) =>
|
|
713
|
+
http.post(`${endpointBase.value}/delete`, { entity: entityName.value, id })
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
// Resolve a single record's full detail (every index/detail field, not just
|
|
717
|
+
// the columns in `select`). This lets the sheet show — and edit — fields the
|
|
718
|
+
// catalog doesn't render as columns (e.g. long-form descriptions).
|
|
719
|
+
const { execute: executeShow } = useMutation((http, id: any) =>
|
|
720
|
+
http.post<LensResult & { data: Record<string, any> }>(`${endpointBase.value}/show`, {
|
|
721
|
+
entity: entityName.value, id, settings: { lang }
|
|
722
|
+
})
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
// Re-run the search without touching the displayed state. Used by the
|
|
726
|
+
// background reconcile so its result can be stashed behind the refresh button
|
|
727
|
+
// rather than replacing the optimistic view.
|
|
728
|
+
const { execute: executeReconcile } = useMutation((http, input: LensQuery) =>
|
|
729
|
+
http.post<LensResult>(props.endpoint, input)
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
function guardBusy(): boolean {
|
|
733
|
+
if (busy.value) {
|
|
734
|
+
snackbars.push({ mode: 'warning', text: t.busy_warning })
|
|
735
|
+
return true
|
|
736
|
+
}
|
|
737
|
+
return false
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Value-level comparison (rows in order + total) deciding whether a reconcile
|
|
741
|
+
// surfaced anything newer than the optimistic state on screen. `fresh` is the
|
|
742
|
+
// canonical search-shaped result; `shown` is what's displayed, which may carry
|
|
743
|
+
// extra detail-only keys an opened sheet merged in via `/show`. Compare only the
|
|
744
|
+
// keys `fresh` actually has, so that detail-key asymmetry doesn't raise a
|
|
745
|
+
// spurious refresh banner — only genuine changes to the table's own fields count.
|
|
746
|
+
function isSameResult(fresh: LensResult | null, shown: LensResult | null): boolean {
|
|
747
|
+
if (!fresh || !shown) { return fresh === shown }
|
|
748
|
+
if (fresh.pagination.total !== shown.pagination.total) { return false }
|
|
749
|
+
if (fresh.data.length !== shown.data.length) { return false }
|
|
750
|
+
for (let i = 0; i < fresh.data.length; i++) {
|
|
751
|
+
const freshRow = fresh.data[i]
|
|
752
|
+
const shownRow = shown.data[i]
|
|
753
|
+
for (const key of Object.keys(freshRow)) {
|
|
754
|
+
if (JSON.stringify(freshRow[key]) !== JSON.stringify(shownRow?.[key])) {
|
|
755
|
+
return false
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
return true
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// After the last in-flight write settles, fetch the canonical state for the
|
|
763
|
+
// current query and, only if it differs from what's shown, stash it behind the
|
|
764
|
+
// refresh button. The server is fresh by now (the write completed).
|
|
765
|
+
async function reconcile(): Promise<void> {
|
|
766
|
+
const token = ++reconcileToken
|
|
767
|
+
reconciling.value = true
|
|
768
|
+
try {
|
|
769
|
+
const fresh = await executeReconcile(buildSearchInput())
|
|
770
|
+
// Drop the result if a newer write batch already kicked off its own
|
|
771
|
+
// reconcile while this one was in flight.
|
|
772
|
+
if (token !== reconcileToken) {
|
|
773
|
+
return
|
|
774
|
+
}
|
|
775
|
+
// Authoritative: stash the canonical result when it differs from what's
|
|
776
|
+
// shown, otherwise clear any (possibly stale) stash — an earlier overlapping
|
|
777
|
+
// reconcile may have left a pre-edit result behind.
|
|
778
|
+
latestState.value = result.value && !isSameResult(fresh, result.value) ? fresh : null
|
|
779
|
+
} catch {
|
|
780
|
+
// Ignore — keep the optimistic state, offer no refresh.
|
|
781
|
+
} finally {
|
|
782
|
+
if (token === reconcileToken) {
|
|
783
|
+
reconciling.value = false
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function applyLatest(): void {
|
|
789
|
+
if (!latestState.value) { return }
|
|
790
|
+
result.value = latestState.value
|
|
791
|
+
latestState.value = null
|
|
792
|
+
prevFetchInput = null
|
|
793
|
+
rebindOpenSheet()
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// When `result` is replaced wholesale (the refresh banner, or a forced refresh),
|
|
797
|
+
// an open record sheet's `sheetRecord` still points at the old (now-orphaned) row
|
|
798
|
+
// object, so later sheet edits would mutate the orphan and stop reflecting in the
|
|
799
|
+
// table. Rebind to the matching fresh row — carrying over any detail-only fields
|
|
800
|
+
// already loaded via `/show` (the search result omits them) so the sheet keeps
|
|
801
|
+
// showing them — or close the sheet if the row is no longer present.
|
|
802
|
+
function rebindOpenSheet(): void {
|
|
803
|
+
if (!result.value) { return }
|
|
804
|
+
if (!sheet.state.value || sheetMode.value !== 'view' || !sheetRecord.value) { return }
|
|
805
|
+
const id = resolveId(sheetRecord.value)
|
|
806
|
+
const match = result.value.data.find((r) => resolveId(r) === id)
|
|
807
|
+
if (!match) {
|
|
808
|
+
sheet.off()
|
|
809
|
+
return
|
|
810
|
+
}
|
|
811
|
+
for (const key of Object.keys(sheetRecord.value)) {
|
|
812
|
+
if (!(key in match)) {
|
|
813
|
+
match[key] = sheetRecord.value[key]
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
sheetRecord.value = match
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// Core force-refetch: drop the memoized input + any stash, invalidate an in-flight
|
|
820
|
+
// reconcile (its request may predate the change being surfaced), refetch the
|
|
821
|
+
// current query, then rebind an open sheet to the fresh rows. Calls `refresh`
|
|
822
|
+
// directly (not the debounced `doRefresh`) so it bypasses the pending-write /
|
|
823
|
+
// creating bail — callers (the exposed refresh, the create path) gate it
|
|
824
|
+
// themselves, and create deliberately refreshes while `creating` is still true.
|
|
825
|
+
async function forceRefresh(): Promise<void> {
|
|
826
|
+
reconcileToken++
|
|
827
|
+
reconciling.value = false
|
|
828
|
+
// Supersede any normal search already in flight (e.g. issued by a query change
|
|
829
|
+
// before an external bulk mutation) so its older rows can't assign over this
|
|
830
|
+
// fresh result when it resolves — same token mechanism as the write/create
|
|
831
|
+
// races. This refetch captures the bumped token, so it isn't self-suppressed.
|
|
832
|
+
searchToken++
|
|
448
833
|
prevFetchInput = null
|
|
449
|
-
|
|
834
|
+
latestState.value = null
|
|
835
|
+
await refresh()
|
|
836
|
+
rebindOpenSheet()
|
|
450
837
|
}
|
|
451
838
|
|
|
839
|
+
// Re-runs the current query against the endpoint, preserving the catalog's
|
|
840
|
+
// in-memory state (select / filters / sort / page). Exposed so callers can
|
|
841
|
+
// reflect server-side changes — e.g. after a bulk action mutates rows — without
|
|
842
|
+
// remounting the component.
|
|
843
|
+
async function refreshCatalog(): Promise<void> {
|
|
844
|
+
// Defer while a write — or a (blocking) create — is in flight: a read now could
|
|
845
|
+
// return stale pre-write / pre-create data and clobber the optimistic or
|
|
846
|
+
// just-created rows (and a stashed stale result could outlive a failed write
|
|
847
|
+
// whose post-batch reconcile is skipped). The post-batch reconcile / create
|
|
848
|
+
// path surfaces the canonical state once things settle. When only a reconcile
|
|
849
|
+
// is in flight (reads are fresh again), forceRefresh supersedes it.
|
|
850
|
+
if (pendingWrites.value > 0 || creating.value) {
|
|
851
|
+
return
|
|
852
|
+
}
|
|
853
|
+
await forceRefresh()
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// When the effective row identifier appears or changes while editing is enabled,
|
|
857
|
+
// the loaded rows won't carry it until a refetch runs — so refetch here. Two
|
|
858
|
+
// cases: `editable` resolving true after mount (async permissions), so
|
|
859
|
+
// `withIndexField` only now appends the default `id`; or a parent resolving /
|
|
860
|
+
// changing `indexField` from async config. Without this the rows lack the new
|
|
861
|
+
// identifier, `rowsCarryIndexField` keeps editing gated off, and selection keys
|
|
862
|
+
// go `undefined` until an unrelated query change. A no-op when the identifier was
|
|
863
|
+
// already present (same request input → cached result reused).
|
|
864
|
+
//
|
|
865
|
+
// Close an open record sheet first: it was identified under the old field and
|
|
866
|
+
// can't be reliably re-matched to the new one (its row lacks the new key, so
|
|
867
|
+
// `resolveId` would be `undefined`), which would let a delete/save act on the
|
|
868
|
+
// wrong — or no — id during and after the refetch.
|
|
869
|
+
watch(
|
|
870
|
+
() => (props.editable ? (props.indexField ?? 'id') : null),
|
|
871
|
+
(field, prev) => {
|
|
872
|
+
if (!field || field === prev) { return }
|
|
873
|
+
if (sheet.state.value && sheetMode.value === 'view') { sheet.off() }
|
|
874
|
+
// Supersede any search still in flight under the previous identifier state: it
|
|
875
|
+
// was issued before the new id/index field, so its rows won't carry the new
|
|
876
|
+
// key. If it resolved after this refetch it would assign those id-less rows
|
|
877
|
+
// back into `result`, flipping `rowsCarryIndexField` (and editing) off again
|
|
878
|
+
// with no watcher to recover. Bumping the token makes the fetcher drop it.
|
|
879
|
+
searchToken++
|
|
880
|
+
refetch()
|
|
881
|
+
}
|
|
882
|
+
)
|
|
883
|
+
|
|
884
|
+
// Track an optimistic background write: count it as pending, surface a snackbar
|
|
885
|
+
// on failure, and once the whole batch settles (with no failures) reconcile.
|
|
886
|
+
function hasPendingWrite(recordId: any): boolean {
|
|
887
|
+
return pendingByRecord.has(recordId)
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Track an optimistic background write for `recordId`, serialized per `key` so
|
|
891
|
+
// writes to the same cell reach the server in edit order.
|
|
892
|
+
function trackWrite(recordId: any, key: string, run: () => Promise<unknown>): void {
|
|
893
|
+
// A new write changes the displayed state. Clear any stashed reconcile result
|
|
894
|
+
// and invalidate an in-flight reconcile (advance the token so its result is
|
|
895
|
+
// dropped; clear its busy flag — `pendingWrites` keeps us busy), so neither
|
|
896
|
+
// can later repopulate the refresh banner with pre-edit data. The post-batch
|
|
897
|
+
// reconcile re-derives it.
|
|
898
|
+
latestState.value = null
|
|
899
|
+
reconcileToken++
|
|
900
|
+
// Invalidate any main search already in flight so it can't assign its pre-write
|
|
901
|
+
// rows over the optimistic patch we're about to apply when it resolves.
|
|
902
|
+
searchToken++
|
|
903
|
+
reconciling.value = false
|
|
904
|
+
pendingWrites.value++
|
|
905
|
+
pendingByRecord.set(recordId, (pendingByRecord.get(recordId) ?? 0) + 1)
|
|
906
|
+
|
|
907
|
+
// Chain after any in-flight write for the same cell so the requests reach the
|
|
908
|
+
// server in order (a prior failure must not block the next write).
|
|
909
|
+
const prev = writeChains.get(key) ?? Promise.resolve()
|
|
910
|
+
const current = prev.catch(() => {}).then(run)
|
|
911
|
+
writeChains.set(key, current)
|
|
912
|
+
|
|
913
|
+
current
|
|
914
|
+
.catch(() => {
|
|
915
|
+
batchErrored = true
|
|
916
|
+
snackbars.push({ mode: 'danger', text: t.write_error })
|
|
917
|
+
})
|
|
918
|
+
.finally(() => {
|
|
919
|
+
// Only drop the chain entry if a newer write hasn't replaced it.
|
|
920
|
+
if (writeChains.get(key) === current) {
|
|
921
|
+
writeChains.delete(key)
|
|
922
|
+
}
|
|
923
|
+
const remaining = (pendingByRecord.get(recordId) ?? 1) - 1
|
|
924
|
+
if (remaining > 0) {
|
|
925
|
+
pendingByRecord.set(recordId, remaining)
|
|
926
|
+
} else {
|
|
927
|
+
pendingByRecord.delete(recordId)
|
|
928
|
+
}
|
|
929
|
+
pendingWrites.value--
|
|
930
|
+
if (pendingWrites.value === 0) {
|
|
931
|
+
const errored = batchErrored
|
|
932
|
+
batchErrored = false
|
|
933
|
+
if (!errored) {
|
|
934
|
+
reconcile()
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
})
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// Update — optimistic. Reflect the change in memory immediately (the catalog
|
|
941
|
+
// row and the open sheet share this object), then persist in the background.
|
|
942
|
+
function save(record: Record<string, any>, values: Record<string, any>): void {
|
|
943
|
+
const id = resolveId(record)
|
|
944
|
+
// Never let an update re-key the row. The built-in cell / sheet editors already
|
|
945
|
+
// refuse to edit the index field, but the sheet-slot `save` helper funnels
|
|
946
|
+
// through here directly and a custom editor could include it. Changing a row's
|
|
947
|
+
// identifier optimistically (before the slow write settles) would re-key the
|
|
948
|
+
// row, so a follow-up save / delete would resolve to the new, not-yet-synced id
|
|
949
|
+
// and miss the in-flight record. Strip it so the invariant holds for every
|
|
950
|
+
// caller. (The identifier is still settable on create, which uses `create()`.)
|
|
951
|
+
const indexField = props.indexField ?? 'id'
|
|
952
|
+
if (indexField in values) {
|
|
953
|
+
values = { ...values }
|
|
954
|
+
delete values[indexField]
|
|
955
|
+
}
|
|
956
|
+
if (Object.keys(values).length === 0) { return }
|
|
957
|
+
Object.assign(record, values)
|
|
958
|
+
// Serialize per record+field so two quick edits to the same cell apply in
|
|
959
|
+
// order; edits to disjoint fields run concurrently.
|
|
960
|
+
const fields = Object.keys(values)
|
|
961
|
+
const key = `${id}:${[...fields].sort().join(',')}`
|
|
962
|
+
// Same-cell repeats chain on this key inside trackWrite. But a multi-field save
|
|
963
|
+
// (e.g. a slot persisting {name,status}) and a later single-field save ({name})
|
|
964
|
+
// have different keys yet share a column, so without help they'd race — with
|
|
965
|
+
// the slow API the older one could land last and clobber the newer value. Gate
|
|
966
|
+
// behind any in-flight write for this record whose fields overlap; disjoint
|
|
967
|
+
// writes still run concurrently.
|
|
968
|
+
const prefix = `${id}:`
|
|
969
|
+
const fieldSet = new Set(fields)
|
|
970
|
+
const overlapping = [...writeChains.entries()]
|
|
971
|
+
.filter(([k]) => k !== key && k.startsWith(prefix)
|
|
972
|
+
&& k.slice(prefix.length).split(',').some((f) => fieldSet.has(f)))
|
|
973
|
+
.map(([, chain]) => chain)
|
|
974
|
+
const gate = Promise.allSettled(overlapping)
|
|
975
|
+
trackWrite(id, key, () => gate.then(() => executeUpdate(id, values)))
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// Create — blocking. The slow POST runs to completion, then the catalog is
|
|
979
|
+
// refreshed with the now-synced canonical state (preserving the active query).
|
|
980
|
+
// The create sheet shows a button spinner meanwhile.
|
|
981
|
+
function prependCreated(data: Record<string, any>): void {
|
|
982
|
+
if (result.value?.data) {
|
|
983
|
+
result.value.data.unshift(data)
|
|
984
|
+
}
|
|
985
|
+
if (result.value?.pagination) {
|
|
986
|
+
result.value.pagination.total++
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
async function create(values: Record<string, any>): Promise<Record<string, any>> {
|
|
991
|
+
creating.value = true
|
|
992
|
+
try {
|
|
993
|
+
const res = await executeCreate(values)
|
|
994
|
+
// The POST succeeded — the record exists. Invalidate any main search already
|
|
995
|
+
// in flight (issued before this create) so its pre-create rows can't assign
|
|
996
|
+
// over the just-created record when it resolves — mirrors trackWrite. Bump
|
|
997
|
+
// only AFTER success: a rejected create doesn't refetch, so suppressing the
|
|
998
|
+
// search here too would strand the catalog on the old query — leaving it valid
|
|
999
|
+
// lets it land its (correct, new-query) rows instead. New searches can't start
|
|
1000
|
+
// during the create (doRefresh bails on `creating`); create's own forceRefresh
|
|
1001
|
+
// bypasses that by calling refresh directly.
|
|
1002
|
+
searchToken++
|
|
1003
|
+
// From here a refresh failure must not reject (the caller would treat the
|
|
1004
|
+
// create as failed and re-submit a duplicate); fall back to showing the new
|
|
1005
|
+
// record optimistically.
|
|
1006
|
+
if (pendingWrites.value > 0) {
|
|
1007
|
+
// Other optimistic writes are still syncing; a refetch now would read the
|
|
1008
|
+
// backend before they're visible and clobber those rows. Prepend the new
|
|
1009
|
+
// record optimistically — the pending writes' reconcile refreshes the
|
|
1010
|
+
// full list (via the refresh banner) once they settle.
|
|
1011
|
+
prependCreated(res.data)
|
|
1012
|
+
} else {
|
|
1013
|
+
// Force a fresh refetch directly (not the exposed refreshCatalog, which
|
|
1014
|
+
// would no-op while `creating` is still true). forceRefresh also invalidates
|
|
1015
|
+
// any reconcile that a batch settling mid-POST kicked off, so a pre-create
|
|
1016
|
+
// snapshot can't land behind the refresh banner.
|
|
1017
|
+
try {
|
|
1018
|
+
await forceRefresh()
|
|
1019
|
+
} catch {
|
|
1020
|
+
prependCreated(res.data)
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
if (!hasInitialResults.value && (result.value?.data.length ?? 0) > 0) {
|
|
1024
|
+
hasInitialResults.value = true
|
|
1025
|
+
}
|
|
1026
|
+
return res.data
|
|
1027
|
+
} catch (e) {
|
|
1028
|
+
// The create was rejected (e.g. a server `unique` rule). A query/filter/page
|
|
1029
|
+
// change made just before submitting may have scheduled a refresh that the
|
|
1030
|
+
// `creating` bail dropped without issuing a request, leaving the rows on the
|
|
1031
|
+
// previous query. Re-schedule it now: doRefresh fires after `creating` clears,
|
|
1032
|
+
// re-bails if writes are pending, and is a memo no-op when the query is
|
|
1033
|
+
// unchanged (the common case), so it only does work when a query change was
|
|
1034
|
+
// actually stranded.
|
|
1035
|
+
void doRefresh()
|
|
1036
|
+
throw e
|
|
1037
|
+
} finally {
|
|
1038
|
+
creating.value = false
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// Delete — optimistic. Remove from the UI immediately, persist in the
|
|
1043
|
+
// background.
|
|
1044
|
+
function remove(record: Record<string, any>): void {
|
|
1045
|
+
const id = resolveId(record)
|
|
1046
|
+
if (result.value?.data) {
|
|
1047
|
+
const index = result.value.data.findIndex((r) => resolveId(r) === id)
|
|
1048
|
+
if (index !== -1) {
|
|
1049
|
+
result.value.data.splice(index, 1)
|
|
1050
|
+
// Only adjust the total when a row was actually removed from the current
|
|
1051
|
+
// page, so a not-found delete can't drift the count below the rows shown.
|
|
1052
|
+
if (result.value.pagination) {
|
|
1053
|
+
result.value.pagination.total--
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
// Serialize the delete behind any in-flight updates for the same record. With
|
|
1058
|
+
// the slow write API, a concurrently-issued delete could otherwise reach the
|
|
1059
|
+
// backend before a pending update — the update would then fail against an
|
|
1060
|
+
// already-deleted row (spurious save-failed snackbar) or resurrect/clobber
|
|
1061
|
+
// it. Gate on the record's current write chains (settled, success or not —
|
|
1062
|
+
// the row is being deleted regardless) before issuing the delete.
|
|
1063
|
+
const pendingForRecord = [...writeChains.entries()]
|
|
1064
|
+
.filter(([key]) => key.startsWith(`${id}:`))
|
|
1065
|
+
.map(([, chain]) => chain)
|
|
1066
|
+
// `Promise.allSettled([])` resolves immediately, so this is a no-op gate when
|
|
1067
|
+
// the record has no in-flight writes.
|
|
1068
|
+
const gate = Promise.allSettled(pendingForRecord)
|
|
1069
|
+
trackWrite(id, `${id}:__delete__`, () => gate.then(() => executeDelete(id)))
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
async function openSheet(record: Record<string, any>): Promise<void> {
|
|
1073
|
+
// Open immediately with a spinner (instant feedback so the user doesn't click
|
|
1074
|
+
// the id cell again), then load the full record (detail-only fields not
|
|
1075
|
+
// selected as columns) and render it. A newer open (another row, or the
|
|
1076
|
+
// create form) supersedes this one via the token.
|
|
1077
|
+
const id = resolveId(record)
|
|
1078
|
+
sheetRecord.value = record
|
|
1079
|
+
sheetMode.value = 'view'
|
|
1080
|
+
sheetLoading.value = true
|
|
1081
|
+
sheetError.value = false
|
|
1082
|
+
sheet.on()
|
|
1083
|
+
const seq = ++showRequestSeq
|
|
1084
|
+
// Snapshot whether the record had a write in flight when `/show` is issued:
|
|
1085
|
+
// the read can return pre-sync data, so even if the write finishes before
|
|
1086
|
+
// `/show` resolves we must not overwrite the optimistic value.
|
|
1087
|
+
const pendingAtIssue = hasPendingWrite(id)
|
|
1088
|
+
try {
|
|
1089
|
+
const res = await executeShow(id)
|
|
1090
|
+
// Merge into the *current* sheet record, not the one captured at call time: a
|
|
1091
|
+
// direct refresh may have rebound `sheetRecord` to a fresh row object while
|
|
1092
|
+
// `/show` was in flight, and the detail must land on the row the sheet is
|
|
1093
|
+
// actually showing (else it renders without the detail-only fields). The seq
|
|
1094
|
+
// guard ensures this is still the same logical record (a newer open bumps it).
|
|
1095
|
+
const target = sheetRecord.value
|
|
1096
|
+
if (seq === showRequestSeq && target) {
|
|
1097
|
+
if (pendingAtIssue || hasPendingWrite(id)) {
|
|
1098
|
+
// The record had/has an in-flight write, so `/show` may have read the
|
|
1099
|
+
// backend before it synced. Don't overwrite the optimistic values
|
|
1100
|
+
// already on the row; only fill in detail-only fields not yet present.
|
|
1101
|
+
for (const k of Object.keys(res.data)) {
|
|
1102
|
+
if (!(k in target)) {
|
|
1103
|
+
target[k] = res.data[k]
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
} else {
|
|
1107
|
+
Object.assign(target, res.data)
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
} catch {
|
|
1111
|
+
// Detail load failed: surface an error in the sheet rather than render a
|
|
1112
|
+
// partial record (the user is asked to reload).
|
|
1113
|
+
if (seq === showRequestSeq) {
|
|
1114
|
+
sheetError.value = true
|
|
1115
|
+
}
|
|
1116
|
+
} finally {
|
|
1117
|
+
if (seq === showRequestSeq) {
|
|
1118
|
+
sheetLoading.value = false
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
function openCreate(): void {
|
|
1124
|
+
// `creatable` is the prop that enables creation. Honor it here even though
|
|
1125
|
+
// this method is exposed (and provided to children).
|
|
1126
|
+
if (!props.creatable) {
|
|
1127
|
+
return
|
|
1128
|
+
}
|
|
1129
|
+
// Supersede any in-flight openSheet `/show` so it can't open over the create
|
|
1130
|
+
// form when it resolves.
|
|
1131
|
+
showRequestSeq++
|
|
1132
|
+
sheetRecord.value = null
|
|
1133
|
+
sheetMode.value = 'create'
|
|
1134
|
+
sheetLoading.value = false
|
|
1135
|
+
sheetError.value = false
|
|
1136
|
+
sheet.on()
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
provideLensEdit({
|
|
1140
|
+
// Getters so the injected context tracks prop changes after mount (e.g.
|
|
1141
|
+
// permissions resolving async, or a flag toggling `editable` off): LensTable
|
|
1142
|
+
// gates inline editing and the id-cell sheet on `edit.editable`.
|
|
1143
|
+
//
|
|
1144
|
+
// Also gated on `rowsCarryIndexField`: when `editable` flips true after mount we
|
|
1145
|
+
// trigger a refetch to pull in the identifier, but it lands asynchronously —
|
|
1146
|
+
// keep editing off until the rows actually carry it, so a save in that window
|
|
1147
|
+
// can't `resolveId()` to `undefined`.
|
|
1148
|
+
get editable() { return !!props.editable && rowsCarryIndexField.value },
|
|
1149
|
+
get creatable() { return !!props.creatable },
|
|
1150
|
+
// Use the same `__no_entity__` fallback as the search / CRUD requests so slot
|
|
1151
|
+
// side-channel saves (which read this) target the same entity, not an empty one.
|
|
1152
|
+
get entity() { return entityName.value },
|
|
1153
|
+
// Getter too: `LensTable` / `LensSheetField` read `edit.indexField` to pick the
|
|
1154
|
+
// sheet opener and to block identifier edits, so it must track a prop that
|
|
1155
|
+
// resolves (or changes) after mount — otherwise the new identifier column stays
|
|
1156
|
+
// non-clickable while the old field is still treated as the id.
|
|
1157
|
+
get indexField() { return props.indexField ?? 'id' },
|
|
1158
|
+
resolveId,
|
|
1159
|
+
save,
|
|
1160
|
+
create,
|
|
1161
|
+
remove,
|
|
1162
|
+
openSheet,
|
|
1163
|
+
openCreate
|
|
1164
|
+
})
|
|
1165
|
+
|
|
1166
|
+
// Warn the user (native prompt) if they try to leave while a write is still
|
|
1167
|
+
// syncing, since that change might not have been persisted yet.
|
|
1168
|
+
function onBeforeUnload(event: BeforeUnloadEvent): void {
|
|
1169
|
+
if (pendingWrites.value > 0 || creating.value) {
|
|
1170
|
+
event.preventDefault()
|
|
1171
|
+
event.returnValue = ''
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
onMounted(() => window.addEventListener('beforeunload', onBeforeUnload))
|
|
1176
|
+
onBeforeUnmount(() => window.removeEventListener('beforeunload', onBeforeUnload))
|
|
1177
|
+
|
|
452
1178
|
defineExpose({
|
|
453
1179
|
refresh: refreshCatalog,
|
|
454
1180
|
|
|
1181
|
+
/**
|
|
1182
|
+
* Open the record sheet in create mode. Exposed so a page's "New …"
|
|
1183
|
+
* button can trigger creation through the unified sheet.
|
|
1184
|
+
*/
|
|
1185
|
+
openCreate,
|
|
1186
|
+
|
|
455
1187
|
/**
|
|
456
1188
|
* Retrieve the current records in the catalog. This method is required when
|
|
457
1189
|
* the parent component needs to access the records directly, for example, to
|
|
@@ -485,6 +1217,7 @@ defineExpose({
|
|
|
485
1217
|
<div v-else class="container">
|
|
486
1218
|
<LensCatalogControl
|
|
487
1219
|
v-if="result"
|
|
1220
|
+
:class="{ 'is-busy': busy }"
|
|
488
1221
|
:query
|
|
489
1222
|
:query-ph
|
|
490
1223
|
:filter-presets
|
|
@@ -518,7 +1251,12 @@ defineExpose({
|
|
|
518
1251
|
</template>
|
|
519
1252
|
</LensCatalogControl>
|
|
520
1253
|
<div v-else class="control-skeleton" />
|
|
521
|
-
<div
|
|
1254
|
+
<div
|
|
1255
|
+
v-if="!hideConditions && result && (_filters.length > 0 || _sort.length > 0)"
|
|
1256
|
+
ref="conditionBlocksEl"
|
|
1257
|
+
class="condition-blocks"
|
|
1258
|
+
:class="{ 'is-busy': busy }"
|
|
1259
|
+
>
|
|
522
1260
|
<template v-if="_filters.length > 0">
|
|
523
1261
|
<SDivider />
|
|
524
1262
|
<LensCatalogStateFilter
|
|
@@ -537,15 +1275,20 @@ defineExpose({
|
|
|
537
1275
|
</template>
|
|
538
1276
|
</div>
|
|
539
1277
|
<SDivider />
|
|
1278
|
+
<div v-if="latestState && !busy" class="refresh-banner">
|
|
1279
|
+
<span class="refresh-banner-text">{{ t.refresh_text }}</span>
|
|
1280
|
+
<SButton size="mini" mode="info" :label="t.refresh_action" @click="applyLatest" />
|
|
1281
|
+
</div>
|
|
540
1282
|
<div class="list" :style="tableMaxHeight">
|
|
541
1283
|
<LensTable
|
|
542
1284
|
:result
|
|
543
1285
|
:loading
|
|
544
1286
|
:overrides="_overrides"
|
|
545
1287
|
:select="tableSelect"
|
|
546
|
-
:index-field
|
|
1288
|
+
:index-field="tableIndexField"
|
|
547
1289
|
:selected
|
|
548
1290
|
:clickable-fields
|
|
1291
|
+
:inline-edit
|
|
549
1292
|
@filter-updated="onInlineFilterUpdated"
|
|
550
1293
|
@sort-updated="onSortUpdated"
|
|
551
1294
|
@update:selected="onUpdateSelected"
|
|
@@ -553,6 +1296,7 @@ defineExpose({
|
|
|
553
1296
|
/>
|
|
554
1297
|
<LensCatalogFooter
|
|
555
1298
|
v-if="result && result?.pagination.total > 0"
|
|
1299
|
+
:class="{ 'is-busy': busy }"
|
|
556
1300
|
:result
|
|
557
1301
|
:loading
|
|
558
1302
|
@prev="onPrev"
|
|
@@ -583,6 +1327,27 @@ defineExpose({
|
|
|
583
1327
|
@apply="onViewUpdated"
|
|
584
1328
|
/>
|
|
585
1329
|
</SModal>
|
|
1330
|
+
|
|
1331
|
+
<LensSheet
|
|
1332
|
+
v-if="editable && result?.fields"
|
|
1333
|
+
:open="sheet.state.value"
|
|
1334
|
+
:mode="sheetMode"
|
|
1335
|
+
:entity="entityName"
|
|
1336
|
+
:record="sheetRecord"
|
|
1337
|
+
:loading="sheetLoading"
|
|
1338
|
+
:error="sheetError"
|
|
1339
|
+
:fields="result.fields"
|
|
1340
|
+
:index-field="indexField ?? 'id'"
|
|
1341
|
+
:width="sheetWidth"
|
|
1342
|
+
@close="sheet.off"
|
|
1343
|
+
>
|
|
1344
|
+
<template v-if="$slots['sheet-before']" #before="s">
|
|
1345
|
+
<slot name="sheet-before" v-bind="s" />
|
|
1346
|
+
</template>
|
|
1347
|
+
<template v-if="$slots['sheet-after']" #after="s">
|
|
1348
|
+
<slot name="sheet-after" v-bind="s" />
|
|
1349
|
+
</template>
|
|
1350
|
+
</LensSheet>
|
|
586
1351
|
</div>
|
|
587
1352
|
</template>
|
|
588
1353
|
|
|
@@ -596,7 +1361,6 @@ defineExpose({
|
|
|
596
1361
|
|
|
597
1362
|
.LensCatalog.show-empty-state {
|
|
598
1363
|
border-style: dashed;
|
|
599
|
-
background-color: var(--c-bg-1);
|
|
600
1364
|
}
|
|
601
1365
|
|
|
602
1366
|
.LensCatalog.has-border {
|
|
@@ -621,6 +1385,29 @@ defineExpose({
|
|
|
621
1385
|
min-height: 100%;
|
|
622
1386
|
}
|
|
623
1387
|
|
|
1388
|
+
/* While a background write is syncing, lock the query controls so the user
|
|
1389
|
+
can't change filters/sort/page (a refetch then would be stale and would
|
|
1390
|
+
drop the optimistic edits). Editing the rows stays available. */
|
|
1391
|
+
.is-busy {
|
|
1392
|
+
pointer-events: none;
|
|
1393
|
+
opacity: 0.6;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
.refresh-banner {
|
|
1397
|
+
display: flex;
|
|
1398
|
+
align-items: center;
|
|
1399
|
+
justify-content: flex-end;
|
|
1400
|
+
gap: 12px;
|
|
1401
|
+
padding: 8px 16px;
|
|
1402
|
+
border-bottom: 1px solid var(--c-gutter);
|
|
1403
|
+
background-color: var(--c-bg-2);
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
.refresh-banner-text {
|
|
1407
|
+
font-size: 13px;
|
|
1408
|
+
color: var(--c-text-2);
|
|
1409
|
+
}
|
|
1410
|
+
|
|
624
1411
|
.list {
|
|
625
1412
|
display: flex;
|
|
626
1413
|
flex-direction: column;
|