@globalbrain/sefirot 4.47.0 → 4.48.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
- const { data: result, execute: refresh, loading } = useQuery(async (http) => {
187
- const input = {
188
- entity: props.entity ?? '__no_entity__',
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
- const doRefresh = useDebounceFn(refresh, 50)
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(() => conditionBlocksSize.height.value > 0 ? `${conditionBlocksSize.height.value}px - 1px` : '0px')
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 `--table-max-height: calc(100vh - ${headerHeight} - ${controlHeight} - ${conditionBlocksHeight.value} - ${columnsHeight} - ${footerHeight} - ${props.heightOffset ?? '0px'})`
410
+ return '--table-max-height: '
411
+ + `calc(100vh - ${headerHeight} - ${controlHeight} - ${conditionBlocksHeight.value}`
412
+ + ` - ${columnsHeight} - ${footerHeight} - ${props.heightOffset ?? '0px'})`
265
413
  }
266
- return `--table-max-height: calc(${props.height} - ${controlHeight} - ${conditionBlocksHeight.value} - ${columnsHeight} - ${footerHeight})`
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
- // When the caller has no concrete select list, the index field is
319
- // *not* added either leaving the request empty lets the server use
320
- // its own defaults. See `indexField` prop docs above.
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
- if (!props.indexField || fields.includes(props.indexField)) { return [...fields] }
324
- return [...fields, props.indexField]
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
- doRefresh()
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
- doRefresh()
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
- doRefresh()
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
- doRefresh()
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
- doRefresh()
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
- doRefresh()
599
+ refetch()
404
600
  emit('sort-updated', _sort.value)
405
601
  }
406
602
 
407
- function onViewUpdated(newSelect: string[], newSelectable: string[], overrides: Record<string, Partial<FieldData>>) {
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
- doRefresh()
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
- doRefresh()
632
+ refetch()
431
633
  }
432
634
 
433
635
  function onNext() {
636
+ if (guardBusy()) { return }
434
637
  page.value++
435
- doRefresh()
638
+ refetch()
436
639
  }
437
640
 
438
- // Re-runs the current query against the endpoint, preserving the
439
- // catalog's in-memory state (select / filters / sort / page). Exposed
440
- // so callers can reflect server-side changes e.g. after a bulk action
441
- // mutates rows — without remounting the component.
442
- async function refreshCatalog(): Promise<void> {
443
- // A refresh is requested precisely when server-side data may have
444
- // changed while the query input did not (e.g. after a bulk action).
445
- // Clear the memoized input so the equality shortcut in the fetcher
446
- // misses and a real request is issued instead of returning the stale
447
- // cached result.
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
- await doRefresh()
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 v-if="!hideConditions && result && (_filters.length > 0 || _sort.length > 0)" ref="conditionBlocksEl" class="condition-blocks">
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;