@globalbrain/sefirot 4.47.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.
@@ -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>