@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.
- package/lib/blocks/lens/FieldData.ts +27 -0
- package/lib/blocks/lens/ResourceFetcher.ts +5 -1
- package/lib/blocks/lens/Rule.ts +5 -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 +10 -2
- 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/Error.ts +2 -2
- package/lib/composables/Url.ts +19 -2
- package/lib/styles/variables.css +1 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|