@globalbrain/sefirot 4.46.0 → 4.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/blocks/lens/FieldData.ts +27 -0
- package/lib/blocks/lens/ResourceFetcher.ts +5 -1
- package/lib/blocks/lens/Rule.ts +10 -0
- package/lib/blocks/lens/components/LensCatalog.vue +826 -39
- package/lib/blocks/lens/components/LensSheet.vue +465 -0
- package/lib/blocks/lens/components/LensSheetField.vue +210 -0
- package/lib/blocks/lens/components/LensTable.vue +79 -5
- package/lib/blocks/lens/components/LensTableEditableCell.vue +285 -0
- package/lib/blocks/lens/composables/CatalogUrlQuerySync.ts +146 -0
- package/lib/blocks/lens/composables/LensEdit.ts +59 -0
- package/lib/blocks/lens/composables/LensInlineEdit.ts +36 -0
- package/lib/blocks/lens/composables/ResourceFetcher.ts +20 -7
- package/lib/blocks/lens/composables/SetupLens.ts +2 -0
- package/lib/blocks/lens/fields/AvatarField.ts +43 -0
- package/lib/blocks/lens/fields/ContentField.ts +21 -10
- package/lib/blocks/lens/fields/Field.ts +26 -1
- package/lib/blocks/lens/fields/FileUploadField.ts +8 -0
- package/lib/blocks/lens/fields/RelatedManyField.ts +44 -7
- package/lib/blocks/lens/fields/RelatedOneField.ts +42 -7
- package/lib/blocks/lens/validation/RuleMapper.ts +22 -3
- package/lib/blocks/lens/validation/ServerErrors.ts +25 -0
- package/lib/components/SInputSwitches.vue +2 -2
- package/lib/components/SSheet.vue +104 -0
- package/lib/composables/Api.ts +18 -10
- package/lib/composables/Url.ts +19 -2
- package/lib/styles/variables.css +1 -0
- package/lib/validation/rules/index.ts +1 -0
- package/lib/validation/rules/slackChannelLink.ts +15 -0
- package/lib/validation/validators/index.ts +1 -0
- package/lib/validation/validators/slackChannelLink.ts +52 -0
- package/package.json +17 -17
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import IconTrash from '~icons/ph/trash'
|
|
3
|
+
import IconX from '~icons/ph/x'
|
|
4
|
+
import { computed, reactive, ref, watch } from 'vue'
|
|
5
|
+
import SButton from '../../../components/SButton.vue'
|
|
6
|
+
import SSheet from '../../../components/SSheet.vue'
|
|
7
|
+
import SSpinner from '../../../components/SSpinner.vue'
|
|
8
|
+
import { provideDataListState } from '../../../composables/DataList'
|
|
9
|
+
import { useTrans } from '../../../composables/Lang'
|
|
10
|
+
import { usePower } from '../../../composables/Power'
|
|
11
|
+
import { useValidation } from '../../../composables/Validation'
|
|
12
|
+
import { type FieldData } from '../FieldData'
|
|
13
|
+
import { useFieldFactory } from '../composables/FieldFactory'
|
|
14
|
+
import { useLensEdit } from '../composables/LensEdit'
|
|
15
|
+
import { extractServerErrors } from '../validation/ServerErrors'
|
|
16
|
+
import LensSheetField from './LensSheetField.vue'
|
|
17
|
+
|
|
18
|
+
const props = withDefaults(defineProps<{
|
|
19
|
+
open: boolean
|
|
20
|
+
mode?: 'view' | 'create'
|
|
21
|
+
entity: string
|
|
22
|
+
record?: Record<string, any> | null
|
|
23
|
+
// Whether the record's full detail is still loading via `/show`. While true,
|
|
24
|
+
// the view body shows a spinner instead of partial fields.
|
|
25
|
+
loading?: boolean
|
|
26
|
+
// Whether the detail load failed. When true, the view body shows an error
|
|
27
|
+
// (asking the user to reload) instead of a partial record.
|
|
28
|
+
error?: boolean
|
|
29
|
+
fields: Record<string, FieldData>
|
|
30
|
+
indexField?: string
|
|
31
|
+
width?: string
|
|
32
|
+
}>(), {
|
|
33
|
+
mode: 'view',
|
|
34
|
+
indexField: 'id'
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const emit = defineEmits<{
|
|
38
|
+
close: []
|
|
39
|
+
}>()
|
|
40
|
+
|
|
41
|
+
const { t } = useTrans({
|
|
42
|
+
en: {
|
|
43
|
+
create: 'Create',
|
|
44
|
+
cancel: 'Cancel',
|
|
45
|
+
delete: 'Delete',
|
|
46
|
+
confirm_delete: 'Delete this record?',
|
|
47
|
+
new_record: 'New record',
|
|
48
|
+
load_error:
|
|
49
|
+
'Couldn’t load this record. Please reload the page, and contact support if the problem persists.'
|
|
50
|
+
},
|
|
51
|
+
ja: {
|
|
52
|
+
create: '作成',
|
|
53
|
+
cancel: 'キャンセル',
|
|
54
|
+
delete: '削除',
|
|
55
|
+
confirm_delete: 'このレコードを削除しますか?',
|
|
56
|
+
new_record: '新規作成',
|
|
57
|
+
load_error:
|
|
58
|
+
'このレコードを読み込めませんでした。ページを再読み込みし、問題が解決しない場合はサポートにお問い合わせください。'
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const edit = useLensEdit()
|
|
63
|
+
const factory = useFieldFactory()
|
|
64
|
+
|
|
65
|
+
// Provide the data-list label width directly (instead of wrapping rows in
|
|
66
|
+
// SDataList) so each LensSheetField wrapper doesn't make its SDataListItem a
|
|
67
|
+
// `:first-child`, which would otherwise double SDataList's dashed dividers.
|
|
68
|
+
provideDataListState({ labelWidth: computed(() => '160px') })
|
|
69
|
+
|
|
70
|
+
const entries = computed(() =>
|
|
71
|
+
Object.entries(props.fields).map(([key, fieldData]) => ({
|
|
72
|
+
key,
|
|
73
|
+
fieldData,
|
|
74
|
+
field: factory.make(fieldData)
|
|
75
|
+
}))
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
const detailFields = computed(() => entries.value.filter((e) => e.fieldData.showOnDetail !== false))
|
|
79
|
+
const createFields = computed(() => entries.value.filter((e) => e.fieldData.showOnCreate === true))
|
|
80
|
+
|
|
81
|
+
// Materialize the create-form input components once per field set. Calling
|
|
82
|
+
// `formInputComponent()` inline in the template would mint a brand-new
|
|
83
|
+
// component definition on every render, which breaks Vue's component patching
|
|
84
|
+
// (`instance.update is not a function`).
|
|
85
|
+
//
|
|
86
|
+
// Some field types don't implement a form input (they `throw new
|
|
87
|
+
// Error('Not implemented.')`); resolve defensively and drop those from the
|
|
88
|
+
// form rather than letting one unsupported field crash the create sheet.
|
|
89
|
+
const createFieldViews = computed(() =>
|
|
90
|
+
createFields.value
|
|
91
|
+
.map((e) => ({ key: e.key, field: e.field, component: resolveInput(e.field) }))
|
|
92
|
+
.filter((v) => v.component != null)
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
// The subset of create views that actually contribute a value. Display-only
|
|
96
|
+
// fields (e.g. a `content` field showing instructions) still render, but
|
|
97
|
+
// `formInputComponent()` returns static markup with no value, so they must not
|
|
98
|
+
// be seeded, validated, or submitted.
|
|
99
|
+
const createInputViews = computed(() =>
|
|
100
|
+
createFieldViews.value.filter((v) => v.field.isSubmittable())
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
function resolveInput(field: { formInputComponent: () => any }): any {
|
|
104
|
+
try {
|
|
105
|
+
return field.formInputComponent()
|
|
106
|
+
} catch {
|
|
107
|
+
return null
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const title = computed(() => {
|
|
112
|
+
if (props.mode === 'create') {
|
|
113
|
+
return t.new_record
|
|
114
|
+
}
|
|
115
|
+
const id = props.record?.[props.indexField]
|
|
116
|
+
return (id && typeof id === 'object' ? id.display : id) ?? ''
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
// --- Create mode form state ---
|
|
120
|
+
|
|
121
|
+
const createModel = reactive<Record<string, any>>({})
|
|
122
|
+
const saving = ref(false)
|
|
123
|
+
|
|
124
|
+
// Whether creation is currently permitted. Reactive (a getter on the injected
|
|
125
|
+
// edit context) so the Create button disables if `creatable` flips off after the
|
|
126
|
+
// sheet opened; `onCreate` re-checks it too.
|
|
127
|
+
const creatable = computed(() => !!edit?.creatable)
|
|
128
|
+
|
|
129
|
+
// Backend-only validation errors (e.g. `unique`) returned by a rejected
|
|
130
|
+
// create, fed to Vuelidate via `$externalResults` so they surface on the
|
|
131
|
+
// offending field. The create form's keys are the bare field keys, matching
|
|
132
|
+
// the 422 error keys 1:1.
|
|
133
|
+
const serverErrors = ref<Record<string, string[]>>({})
|
|
134
|
+
|
|
135
|
+
const { validation, validate, reset } = useValidation(
|
|
136
|
+
() => createModel,
|
|
137
|
+
() => Object.fromEntries(
|
|
138
|
+
createInputViews.value.map((e) => [e.key, e.field.generateValidationRules()])
|
|
139
|
+
),
|
|
140
|
+
{ $externalResults: serverErrors }
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
const confirmingDelete = usePower()
|
|
144
|
+
|
|
145
|
+
// Latch for the confirmed delete (see onDelete). Reset only when the sheet
|
|
146
|
+
// (re)opens — not on close — so a double-click during the close transition stays
|
|
147
|
+
// blocked.
|
|
148
|
+
const deleting = ref(false)
|
|
149
|
+
|
|
150
|
+
watch(
|
|
151
|
+
() => [props.open, props.mode, props.record] as const,
|
|
152
|
+
([open, mode]) => {
|
|
153
|
+
// Reset the delete confirmation whenever the sheet opens, switches mode, or
|
|
154
|
+
// is reused for a different record.
|
|
155
|
+
confirmingDelete.off()
|
|
156
|
+
if (open) {
|
|
157
|
+
deleting.value = false
|
|
158
|
+
}
|
|
159
|
+
if (open && mode === 'create') {
|
|
160
|
+
for (const { key, field } of createInputViews.value) {
|
|
161
|
+
createModel[key] = field.inputEmptyValue()
|
|
162
|
+
}
|
|
163
|
+
serverErrors.value = {}
|
|
164
|
+
reset()
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
{ immediate: true }
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
async function onCreate() {
|
|
171
|
+
// Guard re-entry: set `saving` before the first await so a fast double-click on
|
|
172
|
+
// Create can't start a second submission (and create a duplicate record) while
|
|
173
|
+
// the first call's validation is still pending.
|
|
174
|
+
if (saving.value) {
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
// Re-check creation is still permitted: `openCreate()`'s guard runs once, so if
|
|
178
|
+
// `creatable` flipped off after the sheet opened (async permission change, a
|
|
179
|
+
// parent disabling creation), the context no longer allows it. Close instead of
|
|
180
|
+
// POSTing a create the server would reject anyway. The Create button is also
|
|
181
|
+
// disabled in this state; this guards a flip between render and click.
|
|
182
|
+
if (!edit?.creatable) {
|
|
183
|
+
emit('close')
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
saving.value = true
|
|
187
|
+
serverErrors.value = {}
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
if (!(await validate())) {
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
const values: Record<string, any> = {}
|
|
194
|
+
for (const { key, field } of createInputViews.value) {
|
|
195
|
+
values[key] = field.inputToPayload(createModel[key])
|
|
196
|
+
}
|
|
197
|
+
await edit!.create(values)
|
|
198
|
+
emit('close')
|
|
199
|
+
} catch (e) {
|
|
200
|
+
// Surface backend validation errors (e.g. a duplicate `unique` value) on
|
|
201
|
+
// the offending fields; rethrow anything that isn't a 422.
|
|
202
|
+
const errors = extractServerErrors(e)
|
|
203
|
+
if (!errors) {
|
|
204
|
+
throw e
|
|
205
|
+
}
|
|
206
|
+
serverErrors.value = errors
|
|
207
|
+
} finally {
|
|
208
|
+
saving.value = false
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function onDelete() {
|
|
213
|
+
// Latch so a fast double-click — before the sheet's leave transition unmounts
|
|
214
|
+
// the panel — can't run twice and queue a second DELETE for the same record (a
|
|
215
|
+
// non-idempotent backend would then show a failure snackbar after the first
|
|
216
|
+
// delete already succeeded). Reset when the sheet next opens.
|
|
217
|
+
if (!props.record || deleting.value) {
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
deleting.value = true
|
|
221
|
+
// Optimistic: the row is removed immediately and the delete is persisted in
|
|
222
|
+
// the background, so close right away.
|
|
223
|
+
edit!.remove(props.record)
|
|
224
|
+
emit('close')
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Block closing while a create is in flight. The create POST is blocking and
|
|
228
|
+
// slow; closing mid-save would dismiss the sheet while `creating` keeps the
|
|
229
|
+
// catalog controls interactive (create isn't part of the busy lock), letting
|
|
230
|
+
// the user submit a duplicate record or start an edit the create's follow-up
|
|
231
|
+
// refresh could overwrite. The backdrop/Escape paths are gated via SSheet's
|
|
232
|
+
// `closable` prop; this guards the explicit close button (and any stray emit).
|
|
233
|
+
function requestClose() {
|
|
234
|
+
if (saving.value) {
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
emit('close')
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// --- Slot context -----------------------------------------------------------
|
|
241
|
+
//
|
|
242
|
+
// Content passed to the `#before` / `#after` slots is declared in the page
|
|
243
|
+
// that hosts the catalog, so it cannot `inject` the edit context (which is
|
|
244
|
+
// provided inside `LensCatalog`, a child of that page). Expose the pieces a
|
|
245
|
+
// slot needs as slot props instead: the resolved record id, a record-bound
|
|
246
|
+
// partial `save`, and the entity key. This keeps the generic sheet free of
|
|
247
|
+
// one-off concerns (avatar upload, social links, linked records) while still
|
|
248
|
+
// letting a page implement them.
|
|
249
|
+
|
|
250
|
+
const resolvedId = computed(() => (props.record && edit ? edit.resolveId(props.record) : null))
|
|
251
|
+
|
|
252
|
+
function saveRecord(values: Record<string, any>): Promise<void> {
|
|
253
|
+
// Refuse to save until the full record has loaded. The slots render before the
|
|
254
|
+
// `loading` / `error` branch (so a `#before` header can show during the load),
|
|
255
|
+
// but saving against the partial row mid-`/show` (or after it failed) could
|
|
256
|
+
// overwrite a not-yet-loaded detail field with an empty value — the built-in
|
|
257
|
+
// fields avoid this by not rendering until the record is ready.
|
|
258
|
+
if (props.loading || props.error || !props.record || !edit) {
|
|
259
|
+
return Promise.resolve()
|
|
260
|
+
}
|
|
261
|
+
edit.save(props.record, values)
|
|
262
|
+
return Promise.resolve()
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const slotProps = computed(() => ({
|
|
266
|
+
record: props.record ?? null,
|
|
267
|
+
id: resolvedId.value,
|
|
268
|
+
entity: props.entity,
|
|
269
|
+
// Surfaced so custom editors can disable their save controls until the full
|
|
270
|
+
// record has loaded; `save` also hard-refuses while loading/error as a guard.
|
|
271
|
+
loading: props.loading ?? false,
|
|
272
|
+
error: props.error ?? false,
|
|
273
|
+
save: saveRecord
|
|
274
|
+
}))
|
|
275
|
+
</script>
|
|
276
|
+
|
|
277
|
+
<template>
|
|
278
|
+
<SSheet :open :closable="!saving" :width="width ?? '480px'" @close="requestClose">
|
|
279
|
+
<div class="LensSheet">
|
|
280
|
+
<div class="header">
|
|
281
|
+
<div class="title">{{ title }}</div>
|
|
282
|
+
<button
|
|
283
|
+
class="close"
|
|
284
|
+
type="button"
|
|
285
|
+
aria-label="Close"
|
|
286
|
+
:disabled="saving"
|
|
287
|
+
@click="requestClose"
|
|
288
|
+
>
|
|
289
|
+
<IconX class="close-icon" />
|
|
290
|
+
</button>
|
|
291
|
+
</div>
|
|
292
|
+
|
|
293
|
+
<div class="body">
|
|
294
|
+
<slot name="before" v-bind="slotProps" />
|
|
295
|
+
|
|
296
|
+
<template v-if="mode === 'view' && record">
|
|
297
|
+
<div v-if="loading" class="sheet-loading">
|
|
298
|
+
<SSpinner class="sheet-loading-spinner" />
|
|
299
|
+
</div>
|
|
300
|
+
<div v-else-if="error" class="sheet-error">
|
|
301
|
+
{{ t.load_error }}
|
|
302
|
+
</div>
|
|
303
|
+
<div v-else class="sheet-rows">
|
|
304
|
+
<LensSheetField
|
|
305
|
+
v-for="entry in detailFields"
|
|
306
|
+
:key="entry.key"
|
|
307
|
+
:field="entry.field"
|
|
308
|
+
:field-key="entry.key"
|
|
309
|
+
:record
|
|
310
|
+
/>
|
|
311
|
+
</div>
|
|
312
|
+
</template>
|
|
313
|
+
|
|
314
|
+
<template v-else-if="mode === 'create'">
|
|
315
|
+
<div class="create-form">
|
|
316
|
+
<component
|
|
317
|
+
:is="view.component"
|
|
318
|
+
v-for="view in createFieldViews"
|
|
319
|
+
:key="view.key"
|
|
320
|
+
v-model="createModel[view.key]"
|
|
321
|
+
:validation="validation[view.key]"
|
|
322
|
+
/>
|
|
323
|
+
</div>
|
|
324
|
+
</template>
|
|
325
|
+
|
|
326
|
+
<slot name="after" v-bind="slotProps" />
|
|
327
|
+
</div>
|
|
328
|
+
|
|
329
|
+
<div class="footer">
|
|
330
|
+
<template v-if="mode === 'create'">
|
|
331
|
+
<SButton size="medium" :label="t.cancel" :disabled="saving" @click="requestClose" />
|
|
332
|
+
<SButton
|
|
333
|
+
size="medium"
|
|
334
|
+
mode="info"
|
|
335
|
+
:label="t.create"
|
|
336
|
+
:loading="saving"
|
|
337
|
+
:disabled="!creatable"
|
|
338
|
+
@click="onCreate"
|
|
339
|
+
/>
|
|
340
|
+
</template>
|
|
341
|
+
<template v-else-if="record && !loading && !error">
|
|
342
|
+
<template v-if="confirmingDelete.state.value">
|
|
343
|
+
<span class="confirm-text">{{ t.confirm_delete }}</span>
|
|
344
|
+
<SButton size="medium" :label="t.cancel" @click="confirmingDelete.off" />
|
|
345
|
+
<SButton size="medium" mode="danger" :label="t.delete" @click="onDelete" />
|
|
346
|
+
</template>
|
|
347
|
+
<SButton
|
|
348
|
+
v-else
|
|
349
|
+
size="medium"
|
|
350
|
+
mode="danger"
|
|
351
|
+
type="outline"
|
|
352
|
+
:icon="IconTrash"
|
|
353
|
+
:label="t.delete"
|
|
354
|
+
@click="confirmingDelete.on"
|
|
355
|
+
/>
|
|
356
|
+
</template>
|
|
357
|
+
</div>
|
|
358
|
+
</div>
|
|
359
|
+
</SSheet>
|
|
360
|
+
</template>
|
|
361
|
+
|
|
362
|
+
<style scoped lang="postcss">
|
|
363
|
+
.LensSheet {
|
|
364
|
+
display: flex;
|
|
365
|
+
flex-direction: column;
|
|
366
|
+
height: 100%;
|
|
367
|
+
background-color: var(--c-bg-1);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
.header {
|
|
371
|
+
display: flex;
|
|
372
|
+
align-items: center;
|
|
373
|
+
justify-content: space-between;
|
|
374
|
+
gap: 12px;
|
|
375
|
+
padding: 16px 24px;
|
|
376
|
+
border-bottom: 1px solid var(--c-divider);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
.title {
|
|
380
|
+
font-size: 16px;
|
|
381
|
+
font-weight: 500;
|
|
382
|
+
font-feature-settings: "tnum";
|
|
383
|
+
color: var(--c-text-1);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
.close {
|
|
387
|
+
display: flex;
|
|
388
|
+
align-items: center;
|
|
389
|
+
justify-content: center;
|
|
390
|
+
width: 32px;
|
|
391
|
+
height: 32px;
|
|
392
|
+
border-radius: 6px;
|
|
393
|
+
color: var(--c-text-2);
|
|
394
|
+
transition: background-color 0.1s, color 0.1s;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
.close:hover:not(:disabled) {
|
|
398
|
+
background-color: var(--c-bg-mute-1);
|
|
399
|
+
color: var(--c-text-1);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
.close:disabled {
|
|
403
|
+
cursor: not-allowed;
|
|
404
|
+
opacity: 0.5;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
.close-icon {
|
|
408
|
+
width: 18px;
|
|
409
|
+
height: 18px;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
.body {
|
|
413
|
+
display: flex;
|
|
414
|
+
flex-direction: column;
|
|
415
|
+
gap: 16px;
|
|
416
|
+
flex-grow: 1;
|
|
417
|
+
padding: 16px 24px;
|
|
418
|
+
overflow-y: auto;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
.sheet-rows {
|
|
422
|
+
border-top: 1px dashed var(--c-divider);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
.sheet-loading {
|
|
426
|
+
display: flex;
|
|
427
|
+
align-items: center;
|
|
428
|
+
justify-content: center;
|
|
429
|
+
padding: 32px 0;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
.sheet-loading-spinner {
|
|
433
|
+
width: 28px;
|
|
434
|
+
height: 28px;
|
|
435
|
+
color: var(--c-text-2);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
.sheet-error {
|
|
439
|
+
padding: 24px 0;
|
|
440
|
+
font-size: 14px;
|
|
441
|
+
line-height: 1.6;
|
|
442
|
+
color: var(--c-text-2);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
.create-form {
|
|
446
|
+
display: flex;
|
|
447
|
+
flex-direction: column;
|
|
448
|
+
gap: 16px;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
.footer {
|
|
452
|
+
display: flex;
|
|
453
|
+
align-items: center;
|
|
454
|
+
justify-content: flex-end;
|
|
455
|
+
gap: 8px;
|
|
456
|
+
padding: 16px 24px;
|
|
457
|
+
border-top: 1px solid var(--c-divider);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
.confirm-text {
|
|
461
|
+
margin-right: auto;
|
|
462
|
+
font-size: 13px;
|
|
463
|
+
color: var(--c-text-2);
|
|
464
|
+
}
|
|
465
|
+
</style>
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import IconPencilSimple from '~icons/ph/pencil-simple'
|
|
3
|
+
import { computed, ref, watch } from 'vue'
|
|
4
|
+
import SButton from '../../../components/SButton.vue'
|
|
5
|
+
import SDataListItem from '../../../components/SDataListItem.vue'
|
|
6
|
+
import { useTrans } from '../../../composables/Lang'
|
|
7
|
+
import { useValidation } from '../../../composables/Validation'
|
|
8
|
+
import { type FieldData } from '../FieldData'
|
|
9
|
+
import { useLensEdit } from '../composables/LensEdit'
|
|
10
|
+
import { type Field } from '../fields/Field'
|
|
11
|
+
|
|
12
|
+
const props = defineProps<{
|
|
13
|
+
field: Field<FieldData>
|
|
14
|
+
fieldKey: string
|
|
15
|
+
record: Record<string, any>
|
|
16
|
+
}>()
|
|
17
|
+
|
|
18
|
+
const { t } = useTrans({
|
|
19
|
+
en: { cancel: 'Cancel', apply: 'Apply', edit: 'Edit' },
|
|
20
|
+
ja: { cancel: 'キャンセル', apply: '適用', edit: '編集' }
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const edit = useLensEdit()
|
|
24
|
+
|
|
25
|
+
const editable = computed(() => !!edit && (props.field as any).data?.showOnUpdate === true)
|
|
26
|
+
|
|
27
|
+
// Some field types do not implement a detail renderer or an editable input
|
|
28
|
+
// (they `throw new Error('Not implemented.')`). Resolve them defensively so a
|
|
29
|
+
// single unimplemented field never breaks the whole sheet — fall back to a
|
|
30
|
+
// plain label/value row for display, and simply omit the edit affordance.
|
|
31
|
+
//
|
|
32
|
+
// Computed off `props.field` so they recompute when the field instance changes
|
|
33
|
+
// (e.g. a refresh swaps in new field metadata for an already-rendered key).
|
|
34
|
+
const displayComponent = computed(() => resolve(() => props.field.dataListItemComponent()))
|
|
35
|
+
const inputComponent = computed(() => resolve(() => props.field.formInputComponent()))
|
|
36
|
+
|
|
37
|
+
function resolve(fn: () => any): any {
|
|
38
|
+
try {
|
|
39
|
+
return fn()
|
|
40
|
+
} catch {
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Fallback display value for fields without a `dataListItemComponent`.
|
|
46
|
+
const displayValue = computed(() => {
|
|
47
|
+
const v = props.record[props.fieldKey]
|
|
48
|
+
if (v == null) {
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
if (Array.isArray(v)) {
|
|
52
|
+
return v.join(', ')
|
|
53
|
+
}
|
|
54
|
+
if (typeof v === 'object') {
|
|
55
|
+
return v.display ?? v.value ?? JSON.stringify(v)
|
|
56
|
+
}
|
|
57
|
+
return String(v)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
// Display-only fields (e.g. `content`) resolve an input component but render
|
|
61
|
+
// static markup with no value, so they must not gain an edit affordance. The
|
|
62
|
+
// row-identifier (`indexField`) is also never editable: optimistically changing
|
|
63
|
+
// the id before the write settles would re-key the row, so a follow-up save /
|
|
64
|
+
// delete would address the not-yet-synced new id instead of the in-flight one.
|
|
65
|
+
const canEdit = computed(() =>
|
|
66
|
+
editable.value
|
|
67
|
+
&& !!inputComponent.value
|
|
68
|
+
&& props.fieldKey !== edit?.indexField
|
|
69
|
+
&& props.field.isSubmittable()
|
|
70
|
+
&& props.field.supportsOptimisticUpdate()
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
const editing = ref(false)
|
|
74
|
+
const model = ref<any>(null)
|
|
75
|
+
|
|
76
|
+
// If the backing record is replaced/rebound while this editor is open (the
|
|
77
|
+
// refresh banner, a parent `refresh()`, or a `/show` merge filling detail keys),
|
|
78
|
+
// the `model` captured in `start()` is stale; applying it would overwrite the
|
|
79
|
+
// freshly bound value. Close the editor so the user re-opens against the current
|
|
80
|
+
// value. Our own optimistic save also mutates this value, but `apply()` sets
|
|
81
|
+
// `editing = false` synchronously right after, so this async watcher sees it
|
|
82
|
+
// already closed; user typing only touches the local `model`, never the record.
|
|
83
|
+
watch(
|
|
84
|
+
() => props.record[props.fieldKey],
|
|
85
|
+
() => { if (editing.value) { editing.value = false } }
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
const { validation, validate, reset } = useValidation(
|
|
89
|
+
() => ({ input: model.value }),
|
|
90
|
+
() => ({ input: props.field.generateValidationRules() })
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
function start() {
|
|
94
|
+
const raw = props.record[props.fieldKey]
|
|
95
|
+
model.value = props.field.payloadToInput(raw ?? props.field.inputEmptyValue())
|
|
96
|
+
reset()
|
|
97
|
+
editing.value = true
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function cancel() {
|
|
101
|
+
editing.value = false
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function apply() {
|
|
105
|
+
// Snapshot the value we're editing so we can detect the backing record being
|
|
106
|
+
// rebound out from under us during validation (checked below).
|
|
107
|
+
const editedValue = props.record[props.fieldKey]
|
|
108
|
+
|
|
109
|
+
// Client-side validation only (required, length, …). Server-only rules such
|
|
110
|
+
// as `unique` are enforced by the background write, which surfaces a snackbar
|
|
111
|
+
// on rejection.
|
|
112
|
+
if (!(await validate())) {
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// A refresh / rebind can swap the backing record during the validate microtask;
|
|
117
|
+
// the watcher above closes the editor but flushes asynchronously and can't abort
|
|
118
|
+
// this already-running apply, so re-check against the snapshot. If the value
|
|
119
|
+
// changed, `model` is stale — bail rather than overwrite the fresh value.
|
|
120
|
+
if (props.record[props.fieldKey] !== editedValue) {
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Optimistic: patch + persist in the background, then close immediately.
|
|
125
|
+
edit!.save(props.record, {
|
|
126
|
+
[props.fieldKey]: props.field.inputToPayload(model.value)
|
|
127
|
+
})
|
|
128
|
+
editing.value = false
|
|
129
|
+
}
|
|
130
|
+
</script>
|
|
131
|
+
|
|
132
|
+
<template>
|
|
133
|
+
<div class="LensSheetField" :class="{ editing }">
|
|
134
|
+
<div v-if="!editing" class="display">
|
|
135
|
+
<component :is="displayComponent" v-if="displayComponent" :value="record[fieldKey]" />
|
|
136
|
+
<SDataListItem v-else>
|
|
137
|
+
<template #label>{{ field.label() }}</template>
|
|
138
|
+
<template v-if="displayValue !== null" #value>{{ displayValue }}</template>
|
|
139
|
+
</SDataListItem>
|
|
140
|
+
<button
|
|
141
|
+
v-if="canEdit"
|
|
142
|
+
class="edit"
|
|
143
|
+
type="button"
|
|
144
|
+
:aria-label="`${t.edit} ${field.label()}`"
|
|
145
|
+
@click="start"
|
|
146
|
+
>
|
|
147
|
+
<IconPencilSimple class="edit-icon" />
|
|
148
|
+
</button>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<div v-else class="form">
|
|
152
|
+
<component :is="inputComponent" v-model="model" :validation="validation.input" />
|
|
153
|
+
<div class="actions">
|
|
154
|
+
<SButton size="mini" :label="t.cancel" @click="cancel" />
|
|
155
|
+
<SButton size="mini" mode="info" :label="t.apply" @click="apply" />
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
</template>
|
|
160
|
+
|
|
161
|
+
<style scoped lang="postcss">
|
|
162
|
+
.LensSheetField {
|
|
163
|
+
position: relative;
|
|
164
|
+
border-bottom: 1px dashed var(--c-divider);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.form {
|
|
168
|
+
padding: 8px 0;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.display {
|
|
172
|
+
position: relative;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.edit {
|
|
176
|
+
position: absolute;
|
|
177
|
+
top: 10px;
|
|
178
|
+
right: 0;
|
|
179
|
+
display: flex;
|
|
180
|
+
align-items: center;
|
|
181
|
+
justify-content: center;
|
|
182
|
+
width: 28px;
|
|
183
|
+
height: 28px;
|
|
184
|
+
border-radius: 6px;
|
|
185
|
+
color: var(--c-text-2);
|
|
186
|
+
opacity: 0;
|
|
187
|
+
transition: opacity 0.1s, background-color 0.1s, color 0.1s;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.LensSheetField:hover .edit {
|
|
191
|
+
opacity: 1;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.edit:hover {
|
|
195
|
+
background-color: var(--c-bg-mute-1);
|
|
196
|
+
color: var(--c-text-1);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.edit-icon {
|
|
200
|
+
width: 16px;
|
|
201
|
+
height: 16px;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.actions {
|
|
205
|
+
display: flex;
|
|
206
|
+
justify-content: flex-end;
|
|
207
|
+
gap: 8px;
|
|
208
|
+
margin-top: 8px;
|
|
209
|
+
}
|
|
210
|
+
</style>
|