@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.
Files changed (31) hide show
  1. package/lib/blocks/lens/FieldData.ts +27 -0
  2. package/lib/blocks/lens/ResourceFetcher.ts +5 -1
  3. package/lib/blocks/lens/Rule.ts +10 -0
  4. package/lib/blocks/lens/components/LensCatalog.vue +826 -39
  5. package/lib/blocks/lens/components/LensSheet.vue +465 -0
  6. package/lib/blocks/lens/components/LensSheetField.vue +210 -0
  7. package/lib/blocks/lens/components/LensTable.vue +79 -5
  8. package/lib/blocks/lens/components/LensTableEditableCell.vue +285 -0
  9. package/lib/blocks/lens/composables/CatalogUrlQuerySync.ts +146 -0
  10. package/lib/blocks/lens/composables/LensEdit.ts +59 -0
  11. package/lib/blocks/lens/composables/LensInlineEdit.ts +36 -0
  12. package/lib/blocks/lens/composables/ResourceFetcher.ts +20 -7
  13. package/lib/blocks/lens/composables/SetupLens.ts +2 -0
  14. package/lib/blocks/lens/fields/AvatarField.ts +43 -0
  15. package/lib/blocks/lens/fields/ContentField.ts +21 -10
  16. package/lib/blocks/lens/fields/Field.ts +26 -1
  17. package/lib/blocks/lens/fields/FileUploadField.ts +8 -0
  18. package/lib/blocks/lens/fields/RelatedManyField.ts +44 -7
  19. package/lib/blocks/lens/fields/RelatedOneField.ts +42 -7
  20. package/lib/blocks/lens/validation/RuleMapper.ts +22 -3
  21. package/lib/blocks/lens/validation/ServerErrors.ts +25 -0
  22. package/lib/components/SInputSwitches.vue +2 -2
  23. package/lib/components/SSheet.vue +104 -0
  24. package/lib/composables/Api.ts +18 -10
  25. package/lib/composables/Url.ts +19 -2
  26. package/lib/styles/variables.css +1 -0
  27. package/lib/validation/rules/index.ts +1 -0
  28. package/lib/validation/rules/slackChannelLink.ts +15 -0
  29. package/lib/validation/validators/index.ts +1 -0
  30. package/lib/validation/validators/slackChannelLink.ts +52 -0
  31. package/package.json +17 -17
@@ -1,14 +1,18 @@
1
1
  <script setup lang="ts">
2
2
  import { computedAsync } from '@vueuse/core'
3
3
  import { cloneDeep } from 'lodash-es'
4
- import { computed } from 'vue'
4
+ import { computed, markRaw } from 'vue'
5
5
  import STable from '../../../components/STable.vue'
6
6
  import { type DropdownSection } from '../../../composables/Dropdown'
7
- import { type TableColumns, useTable } from '../../../composables/Table'
7
+ import { type TableCell, type TableColumns, useTable } from '../../../composables/Table'
8
8
  import { type FieldData } from '../FieldData'
9
9
  import { type LensQuerySort } from '../LensQuery'
10
10
  import { type LensResult } from '../LensResult'
11
11
  import { useFieldFactory } from '../composables/FieldFactory'
12
+ import { useLensEdit } from '../composables/LensEdit'
13
+ import { provideLensInlineEdit } from '../composables/LensInlineEdit'
14
+ import { type Field } from '../fields/Field'
15
+ import LensTableEditableCell from './LensTableEditableCell.vue'
12
16
 
13
17
  const props = defineProps<{
14
18
  result?: LensResult
@@ -21,6 +25,9 @@ const props = defineProps<{
21
25
  select?: string[]
22
26
  selected?: any[]
23
27
  indexField?: string
28
+ // When set (and editing is enabled), cells for `showOnUpdate` fields
29
+ // render a hover edit affordance that opens an inline editor.
30
+ inlineEdit?: boolean
24
31
  }>()
25
32
 
26
33
  const emit = defineEmits<{
@@ -31,6 +38,12 @@ const emit = defineEmits<{
31
38
  }>()
32
39
 
33
40
  const fieldFactory = useFieldFactory()
41
+ const edit = useLensEdit()
42
+
43
+ // Track which cell's inline editor is open so opening one closes any other.
44
+ provideLensInlineEdit()
45
+
46
+ const editableCellComponent = markRaw(LensTableEditableCell)
34
47
 
35
48
  const records = computed(() => props.result?.data ?? [])
36
49
 
@@ -105,7 +118,48 @@ const columns = computedAsync(async () => {
105
118
 
106
119
  const field = fieldFactory.make(overriddenFieldData)
107
120
 
108
- columns[key] = field.tableColumn()
121
+ const column = field.tableColumn()
122
+
123
+ // When editing is enabled, clicking the row-identifier cell opens the record
124
+ // sheet (view + per-field edit + delete) instead of following a link. Keyed
125
+ // off the configured index field for any field type — a slug/code identifier
126
+ // opens the sheet just like an `id` — so other id-type columns (e.g. a
127
+ // `company_id` reference link) keep their own navigation.
128
+ if (
129
+ edit?.editable
130
+ && key === edit.indexField
131
+ ) {
132
+ const original = column.cell
133
+ column.cell = (v: any, r: any): TableCell<any, any> => {
134
+ const cell = typeof original === 'function' ? original(v, r) : original
135
+ return {
136
+ ...(cell as TableCell<any, any>),
137
+ link: null,
138
+ color: 'info',
139
+ onClick: () => edit.openSheet(r)
140
+ } as TableCell<any, any>
141
+ }
142
+ } else if (
143
+ props.inlineEdit
144
+ && edit?.editable
145
+ && key !== edit.indexField
146
+ && overriddenFieldData.showOnUpdate === true
147
+ && field.isSubmittable()
148
+ && field.supportsOptimisticUpdate()
149
+ && hasFormInput(field)
150
+ ) {
151
+ // Editable fields render a custom cell with a hover edit affordance
152
+ // that opens an inline editor (reusing the field's form input + the
153
+ // edit context's save). Sort/filter menus on the column are kept.
154
+ // Fields without a real input (or display-only ones) stay read-only.
155
+ column.cell = {
156
+ type: 'component',
157
+ component: editableCellComponent,
158
+ props: { field, fieldKey: key }
159
+ }
160
+ }
161
+
162
+ columns[key] = column
109
163
 
110
164
  const dropdown: DropdownSection[] = []
111
165
 
@@ -132,7 +186,11 @@ const table = useTable({
132
186
  records,
133
187
  orders,
134
188
  columns,
135
- indexField: props.indexField,
189
+ // A getter (not a snapshot) so the selection key stays reactive: `indexField`
190
+ // can change after mount — e.g. an editable catalog resolves permissions async
191
+ // and flips from positional keys to `id`. STable reads this inside a computed,
192
+ // so the getter establishes the dependency and selection re-keys correctly.
193
+ get indexField() { return props.indexField },
136
194
  borderless: true
137
195
  })
138
196
 
@@ -147,10 +205,26 @@ function onFilterUpdated(filter: any[]) {
147
205
  function onSortUpdated(sort: LensQuerySort) {
148
206
  emit('sort-updated', sort)
149
207
  }
208
+
209
+ // Some field types do not implement a form input (they `throw new
210
+ // Error('Not implemented.')`). Only upgrade a cell to the inline editor when
211
+ // the field actually has an input component, otherwise leave it read-only so a
212
+ // backend that marks such a field `showOnUpdate` renders a plain cell instead
213
+ // of a pencil that crashes on click.
214
+ function hasFormInput(field: Field<FieldData>): boolean {
215
+ try {
216
+ return !!field.formInputComponent()
217
+ } catch {
218
+ return false
219
+ }
220
+ }
150
221
  </script>
151
222
 
152
223
  <template>
153
- <div class="LensTable" :class="{ 'is-loading': loading, 'is-empty': (result?.data.length ?? 0) === 0 }">
224
+ <div
225
+ class="LensTable"
226
+ :class="{ 'is-loading': loading, 'is-empty': (result?.data.length ?? 0) === 0 }"
227
+ >
154
228
  <STable
155
229
  v-if="Object.keys(columns).length > 0"
156
230
  class="table"
@@ -0,0 +1,285 @@
1
+ <script setup lang="ts">
2
+ import IconPencilSimple from '~icons/ph/pencil-simple'
3
+ import { useElementBounding } from '@vueuse/core'
4
+ import { computed, nextTick, onUnmounted, ref, shallowRef, watch } from 'vue'
5
+ import SButton from '../../../components/SButton.vue'
6
+ import { useManualDropdownPosition } from '../../../composables/Dropdown'
7
+ import { useTrans } from '../../../composables/Lang'
8
+ import { useValidation } from '../../../composables/Validation'
9
+ import { type FieldData } from '../FieldData'
10
+ import { useLensEdit } from '../composables/LensEdit'
11
+ import { useLensInlineEdit } from '../composables/LensInlineEdit'
12
+ import { type Field } from '../fields/Field'
13
+
14
+ const props = defineProps<{
15
+ field: Field<FieldData>
16
+ fieldKey: string
17
+ value: any
18
+ record: Record<string, any>
19
+ }>()
20
+
21
+ const { t } = useTrans({
22
+ en: { cancel: 'Cancel', save: 'Save', edit: 'Edit' },
23
+ ja: { cancel: 'キャンセル', save: '保存', edit: '編集' }
24
+ })
25
+
26
+ const edit = useLensEdit()
27
+ const inline = useLensInlineEdit()
28
+
29
+ const myKey = computed(() => `${edit?.resolveId(props.record)}:${props.fieldKey}`)
30
+ const editing = computed(() => inline?.activeKey.value === myKey.value)
31
+
32
+ // If the backing value is replaced while this editor is open — a refresh banner
33
+ // apply, a parent `refresh()`, or a requery that keeps this row visible — the
34
+ // `model` captured back in `start()` is now stale, and saving it would overwrite
35
+ // the freshly fetched cell. Close the editor so the user re-opens against the
36
+ // current value. Our own optimistic save also mutates `props.value`, but `apply()`
37
+ // calls `inline.stop()` synchronously right after, so `editing` is already false
38
+ // by the time this (async) watcher flushes — it won't fight a normal save. User
39
+ // typing only touches the local `model`, never `props.value`, so it won't trip.
40
+ watch(
41
+ () => props.value,
42
+ () => { if (editing.value) { inline?.stop() } }
43
+ )
44
+
45
+ // STable virtualization can unmount this row (and its teleported editor) while
46
+ // scrolling. If this cell holds the active editor, clear the shared active key
47
+ // on unmount so a later remount doesn't reopen a blank editor (the local
48
+ // `inputComponent`/`model` are only set by `start()`).
49
+ onUnmounted(() => {
50
+ if (inline?.activeKey.value === myKey.value) {
51
+ inline.stop()
52
+ }
53
+ })
54
+
55
+ // Reuse the field's own table-cell rendering for the display value so the
56
+ // label mapping (e.g. select option labels) matches the read-only columns.
57
+ // Falls back to a plain representation for non-text displays.
58
+ const displayValue = computed(() => {
59
+ try {
60
+ const cell = props.field.tableColumn().cell
61
+ const resolved: any = typeof cell === 'function' ? cell(props.value, props.record) : cell
62
+ if (resolved && (resolved.type === 'text' || resolved.type === 'number')) {
63
+ return resolved.value ?? ''
64
+ }
65
+ // `state` cells (e.g. a select with displayAs: 'state') carry the localized
66
+ // label, not the raw payload — show that rather than falling through.
67
+ if (resolved && resolved.type === 'state') {
68
+ return resolved.label ?? ''
69
+ }
70
+ } catch {
71
+ // Field types without a text cell fall through to the generic display.
72
+ }
73
+
74
+ const v = props.value
75
+ if (v == null) {
76
+ return ''
77
+ }
78
+ if (Array.isArray(v)) {
79
+ return v.join(', ')
80
+ }
81
+ if (typeof v === 'object') {
82
+ return v.display ?? v.value ?? ''
83
+ }
84
+ return String(v)
85
+ })
86
+
87
+ // --- Editor (a floating box anchored to the cell, escaping table overflow) ---
88
+
89
+ const anchor = ref<HTMLElement | null>(null)
90
+ const editorEl = ref<HTMLElement | null>(null)
91
+ const bounds = useElementBounding(anchor)
92
+ const { inset, update } = useManualDropdownPosition(anchor)
93
+
94
+ const inputComponent = shallowRef<any>(null)
95
+ const model = ref<any>(null)
96
+
97
+ const { validation, validate, reset } = useValidation(
98
+ () => ({ input: model.value }),
99
+ () => ({ input: props.field.generateValidationRules() })
100
+ )
101
+
102
+ const editorStyle = computed(() => ({
103
+ ...inset.value,
104
+ width: `${Math.max(bounds.width.value, 240)}px`
105
+ }))
106
+
107
+ function start() {
108
+ inputComponent.value = props.field.formInputComponent()
109
+ model.value = props.field.payloadToInput(props.value ?? props.field.inputEmptyValue())
110
+ reset()
111
+ inline?.start(myKey.value)
112
+ nextTick(() => {
113
+ update()
114
+ const el = editorEl.value?.querySelector(
115
+ 'input, textarea, [contenteditable], [tabindex]'
116
+ ) as HTMLElement | null
117
+ el?.focus()
118
+ })
119
+ }
120
+
121
+ function cancel() {
122
+ inline?.stop()
123
+ }
124
+
125
+ async function apply() {
126
+ // Snapshot the value we're editing so we can detect the backing cell being
127
+ // replaced out from under us during validation (checked below).
128
+ const editedValue = props.value
129
+
130
+ // Client-side validation only; server-only rules (e.g. `unique`) are enforced
131
+ // by the background write, which surfaces a snackbar on rejection.
132
+ if (!(await validate())) {
133
+ return
134
+ }
135
+
136
+ // A wholesale refresh (refresh banner, a parent `refresh()`, a route change)
137
+ // can swap the backing row during the validate microtask. If this cell's value
138
+ // changed, `model` now predates the fresh value and persisting it would clobber
139
+ // the just-fetched cell — bail. The value watcher above also closes the editor,
140
+ // but it flushes asynchronously and can't abort this already-running save, so
141
+ // re-check here against the snapshot rather than relying on its ordering.
142
+ if (props.value !== editedValue) {
143
+ return
144
+ }
145
+
146
+ // Optimistic: patch + persist in the background, then close immediately.
147
+ edit!.save(props.record, {
148
+ [props.fieldKey]: props.field.inputToPayload(model.value)
149
+ })
150
+
151
+ // Only close if this cell is still the active one: the user may have started
152
+ // editing another cell while validating, and an unconditional stop would
153
+ // clear that new editor's shared key.
154
+ if (inline?.activeKey.value === myKey.value) {
155
+ inline.stop()
156
+ }
157
+ }
158
+
159
+ function onEditorKeydown(event: KeyboardEvent) {
160
+ if (event.key === 'Escape') {
161
+ event.preventDefault()
162
+ cancel()
163
+ return
164
+ }
165
+ // Enter saves only from a plain text-like input. A textarea inserts a
166
+ // newline, and controls that handle Enter themselves (e.g. a dropdown that
167
+ // opens its menu on Enter) must keep it — otherwise a keyboard user submits
168
+ // the old value instead of choosing an option.
169
+ if (event.key === 'Enter' && isTextLikeInput(event.target)) {
170
+ event.preventDefault()
171
+ apply()
172
+ }
173
+ }
174
+
175
+ function isTextLikeInput(target: EventTarget | null): boolean {
176
+ const el = target as HTMLElement | null
177
+ if (!el || el.tagName !== 'INPUT') {
178
+ return false
179
+ }
180
+ const type = (el as HTMLInputElement).type
181
+ return ['text', 'search', 'url', 'email', 'tel', 'password', 'number'].includes(type)
182
+ }
183
+ </script>
184
+
185
+ <template>
186
+ <div ref="anchor" class="LensTableEditableCell" :class="{ editing }">
187
+ <span class="value">{{ displayValue }}</span>
188
+ <button
189
+ class="edit"
190
+ type="button"
191
+ :aria-label="`${t.edit} ${field.label()}`"
192
+ @click="start"
193
+ >
194
+ <IconPencilSimple class="edit-icon" />
195
+ </button>
196
+
197
+ <Teleport v-if="editing && inputComponent" to="#sefirot-modals">
198
+ <div
199
+ ref="editorEl"
200
+ class="LensTableEditableCellEditor"
201
+ :style="editorStyle"
202
+ @keydown="onEditorKeydown"
203
+ >
204
+ <component :is="inputComponent" v-model="model" :validation="validation.input" />
205
+ <div class="actions">
206
+ <SButton size="mini" :label="t.cancel" @click="cancel" />
207
+ <SButton size="mini" mode="info" :label="t.save" @click="apply" />
208
+ </div>
209
+ </div>
210
+ </Teleport>
211
+ </div>
212
+ </template>
213
+
214
+ <style scoped lang="postcss">
215
+ /* Mirror STableCellText's box so the cell fills the row height instead of
216
+ collapsing, leaving room on the right for the hover edit affordance. */
217
+ .LensTableEditableCell {
218
+ position: relative;
219
+ display: flex;
220
+ align-items: center;
221
+ min-width: 0;
222
+ min-height: 40px;
223
+ padding: 8px 36px 8px 16px;
224
+ }
225
+
226
+ .value {
227
+ line-height: 24px;
228
+ font-size: var(--table-cell-font-size);
229
+ font-weight: var(--table-cell-font-weight);
230
+ color: var(--c-text-1);
231
+ white-space: nowrap;
232
+ overflow: hidden;
233
+ text-overflow: ellipsis;
234
+ }
235
+
236
+ .edit {
237
+ position: absolute;
238
+ top: 50%;
239
+ right: 8px;
240
+ display: flex;
241
+ align-items: center;
242
+ justify-content: center;
243
+ width: 24px;
244
+ height: 24px;
245
+ transform: translateY(-50%);
246
+ border-radius: 6px;
247
+ color: var(--c-text-2);
248
+ opacity: 0;
249
+ transition: opacity 0.1s, background-color 0.1s, color 0.1s;
250
+ }
251
+
252
+ .LensTableEditableCell:hover .edit,
253
+ .LensTableEditableCell.editing .edit {
254
+ opacity: 1;
255
+ }
256
+
257
+ .edit:hover {
258
+ background-color: var(--c-bg-mute-1);
259
+ color: var(--c-text-1);
260
+ }
261
+
262
+ .edit-icon {
263
+ width: 14px;
264
+ height: 14px;
265
+ }
266
+
267
+ .LensTableEditableCellEditor {
268
+ position: fixed;
269
+ z-index: var(--z-index-sheet, 2000);
270
+ display: flex;
271
+ flex-direction: column;
272
+ gap: 8px;
273
+ padding: 8px;
274
+ border: 1px solid var(--c-border);
275
+ border-radius: 8px;
276
+ background-color: var(--c-bg-1);
277
+ box-shadow: var(--shadow-depth-3);
278
+ }
279
+
280
+ .actions {
281
+ display: flex;
282
+ justify-content: flex-end;
283
+ gap: 8px;
284
+ }
285
+ </style>
@@ -0,0 +1,146 @@
1
+ import cloneDeep from 'lodash-es/cloneDeep'
2
+ import { type Ref, onScopeDispose, reactive, watch } from 'vue'
3
+ import { useUrlQuerySync } from '../../../composables/Url'
4
+ import { FilterOperatorLabelDict } from '../FilterOperator'
5
+ import { type LensQuerySort } from '../LensQuery'
6
+
7
+ export interface CatalogUrlQuerySyncState {
8
+ query: Ref<string | null>
9
+ filters: Ref<any[]>
10
+ sort: Ref<LensQuerySort[]>
11
+ page: Ref<number>
12
+ }
13
+
14
+ const filterOperators = new Set(Object.keys(FilterOperatorLabelDict))
15
+
16
+ // The sync owns a fixed set of query param keys (`q`, `filters`, `sort`,
17
+ // and `page`), so two active instances on the same page would overwrite
18
+ // each other's params. Track active instances to warn about this misuse.
19
+ let activeCount = 0
20
+
21
+ /**
22
+ * Sync the catalog state with the URL query string via `useUrlQuerySync`.
23
+ * `filters` and `sort` are carried as JSON since their nested array
24
+ * structure (with non-string values) cannot survive a flat query string.
25
+ *
26
+ * `onChange` is called whenever the synced state changes after the
27
+ * initial setup. State changes coming from the catalog UI already
28
+ * trigger a fetch in their own handlers, so the callback is expected to
29
+ * be debounced; its real purpose is to fetch on URL-driven changes
30
+ * (browser back/forward, hand-edited URL) which have no other
31
+ * refresh path.
32
+ */
33
+ export function useCatalogUrlQuerySync(
34
+ state: CatalogUrlQuerySyncState,
35
+ onChange: () => void
36
+ ): void {
37
+ if (import.meta.env.DEV) {
38
+ activeCount++
39
+ if (activeCount > 1) {
40
+ console.warn(
41
+ '[sefirot] Multiple `LensCatalog` components with `url-sync` enabled'
42
+ + ' are active at the same time. They will overwrite each other\'s'
43
+ + ' query params (`q`, `filters`, `sort`, and `page`). Enable'
44
+ + ' `url-sync` on at most one catalog per page.'
45
+ )
46
+ }
47
+ onScopeDispose(() => { activeCount-- })
48
+ }
49
+
50
+ // URL params are user-editable, so anything malformed falls back to
51
+ // the state's initial value, exactly as if the param was absent. The
52
+ // snapshots must be deep clones: the refs' arrays are later mutated in
53
+ // place (inline filter updates `push`/`splice` into `filters`, and the
54
+ // URL -> state sync splices arrays as well), which would otherwise
55
+ // silently drift these "initial" values along with the current state.
56
+ const defaultFilters = cloneDeep(state.filters.value)
57
+ const defaultSort = cloneDeep(state.sort.value)
58
+ const defaultPage = state.page.value
59
+
60
+ const urlState = reactive({
61
+ q: state.query,
62
+ filters: state.filters,
63
+ sort: state.sort,
64
+ page: state.page
65
+ })
66
+
67
+ useUrlQuerySync(urlState, {
68
+ casts: {
69
+ q: (v) => castQ(v),
70
+ filters: (v) => castFilters(v) ?? cloneDeep(defaultFilters),
71
+ sort: (v) => castSort(v) ?? cloneDeep(defaultSort),
72
+ page: (v) => castPage(v) ?? defaultPage
73
+ },
74
+ serializers: {
75
+ filters: (v) => JSON.stringify(v),
76
+ sort: (v) => JSON.stringify(v)
77
+ }
78
+ })
79
+
80
+ // Registered after `useUrlQuerySync` on purpose: the initial URL ->
81
+ // state application happens synchronously inside it, before this
82
+ // watcher exists, so the initial fetch (on mounted) is not duplicated.
83
+ watch(urlState, onChange, { deep: true })
84
+ }
85
+
86
+ function castQ(value: any): string | null {
87
+ return typeof value === 'string' && value !== '' ? value : null
88
+ }
89
+
90
+ function castFilters(value: any): any[] | null {
91
+ if (typeof value !== 'string') {
92
+ return null
93
+ }
94
+ try {
95
+ const parsed = JSON.parse(value)
96
+ return isFilters(parsed) ? parsed : null
97
+ } catch {
98
+ return null
99
+ }
100
+ }
101
+
102
+ // Filters are a list of entries where an entry is either a condition
103
+ // tuple `[field, operator, value]` or a group `['$and' | '$or', entries]`.
104
+ // Refer to `groupToLensFilters` in `LensFormFilter.vue`.
105
+ function isFilters(value: unknown): value is any[] {
106
+ return Array.isArray(value) && value.every(isFilterEntry)
107
+ }
108
+
109
+ function isFilterEntry(value: unknown): boolean {
110
+ if (!Array.isArray(value)) {
111
+ return false
112
+ }
113
+ if (value[0] === '$and' || value[0] === '$or') {
114
+ return value.length === 2 && isFilters(value[1])
115
+ }
116
+ return value.length === 3
117
+ && typeof value[0] === 'string'
118
+ && typeof value[1] === 'string'
119
+ && filterOperators.has(value[1])
120
+ }
121
+
122
+ function castSort(value: any): LensQuerySort[] | null {
123
+ if (typeof value !== 'string') {
124
+ return null
125
+ }
126
+ try {
127
+ const parsed = JSON.parse(value)
128
+ return isSort(parsed) ? parsed : null
129
+ } catch {
130
+ return null
131
+ }
132
+ }
133
+
134
+ function isSort(value: unknown): value is LensQuerySort[] {
135
+ return Array.isArray(value) && value.every((entry) => {
136
+ return Array.isArray(entry)
137
+ && entry.length === 2
138
+ && typeof entry[0] === 'string'
139
+ && (entry[1] === 'asc' || entry[1] === 'desc')
140
+ })
141
+ }
142
+
143
+ function castPage(value: any): number | null {
144
+ const page = Number(value)
145
+ return Number.isInteger(page) && page >= 1 ? page : null
146
+ }
@@ -0,0 +1,59 @@
1
+ import { type InjectionKey, inject, provide } from 'vue'
2
+
3
+ /**
4
+ * The edit context shared from `LensCatalog` down to the inline editable
5
+ * cells and the record sheet via provide/inject. It carries the entity
6
+ * identity plus the CRUD operations (which talk to the backend and patch
7
+ * the catalog's in-memory records optimistically) and the sheet controls.
8
+ */
9
+ export interface LensEditContext {
10
+ /** Whether the catalog has editing enabled at all. */
11
+ editable: boolean
12
+
13
+ /** Whether new records may be created. */
14
+ creatable: boolean
15
+
16
+ /** The entity key being edited (e.g. `topic`, `user`). */
17
+ entity: string
18
+
19
+ /** The record-identity field key (e.g. `id`). */
20
+ indexField: string
21
+
22
+ /** Resolve a record's identifier, unwrapping the id field's object shape. */
23
+ resolveId: (record: Record<string, any>) => any
24
+
25
+ /**
26
+ * Apply a partial update to an existing record. Optimistic: patches the
27
+ * record in memory immediately and persists in the background, so it returns
28
+ * synchronously without waiting for the (potentially slow) write.
29
+ */
30
+ save: (record: Record<string, any>, values: Record<string, any>) => void
31
+
32
+ /**
33
+ * Create a new record. Blocking: resolves once the record is persisted and
34
+ * the catalog has been refreshed with the canonical state.
35
+ */
36
+ create: (values: Record<string, any>) => Promise<Record<string, any>>
37
+
38
+ /**
39
+ * Delete a record. Optimistic: removes it from the catalog immediately and
40
+ * persists in the background, so it returns synchronously.
41
+ */
42
+ remove: (record: Record<string, any>) => void
43
+
44
+ /** Open the record sheet for an existing record (view + per-field edit). */
45
+ openSheet: (record: Record<string, any>) => void
46
+
47
+ /** Open the record sheet in create mode. */
48
+ openCreate: () => void
49
+ }
50
+
51
+ const LensEditKey: InjectionKey<LensEditContext> = Symbol('LensEdit')
52
+
53
+ export function provideLensEdit(ctx: LensEditContext): void {
54
+ provide(LensEditKey, ctx)
55
+ }
56
+
57
+ export function useLensEdit(): LensEditContext | null {
58
+ return inject(LensEditKey, null)
59
+ }
@@ -0,0 +1,36 @@
1
+ import { type InjectionKey, type Ref, inject, provide, ref } from 'vue'
2
+
3
+ /**
4
+ * Tracks which table cell currently has its inline editor open, so that
5
+ * opening one editor closes any other. The key is `${recordId}:${fieldKey}`.
6
+ */
7
+ export interface LensInlineEditContext {
8
+ /** The key of the cell currently being edited, or null. */
9
+ activeKey: Ref<string | null>
10
+
11
+ /** Open the editor for the given cell key (closing any other). */
12
+ start: (key: string) => void
13
+
14
+ /** Close the active editor. */
15
+ stop: () => void
16
+ }
17
+
18
+ const LensInlineEditKey: InjectionKey<LensInlineEditContext> = Symbol('LensInlineEdit')
19
+
20
+ export function provideLensInlineEdit(): LensInlineEditContext {
21
+ const activeKey = ref<string | null>(null)
22
+
23
+ const ctx: LensInlineEditContext = {
24
+ activeKey,
25
+ start: (key) => { activeKey.value = key },
26
+ stop: () => { activeKey.value = null }
27
+ }
28
+
29
+ provide(LensInlineEditKey, ctx)
30
+
31
+ return ctx
32
+ }
33
+
34
+ export function useLensInlineEdit(): LensInlineEditContext | null {
35
+ return inject(LensInlineEditKey, null)
36
+ }