@globalbrain/sefirot 4.52.0 → 4.53.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 +14 -0
- package/lib/blocks/lens/Rule.ts +12 -0
- package/lib/blocks/lens/components/LensAvatarInput.vue +44 -0
- package/lib/blocks/lens/components/LensCatalog.vue +107 -6
- package/lib/blocks/lens/components/LensFormView.vue +3 -0
- package/lib/blocks/lens/components/LensSheet.vue +20 -8
- package/lib/blocks/lens/components/LensSheetAvatarField.vue +138 -0
- package/lib/blocks/lens/components/LensTable.vue +51 -1
- package/lib/blocks/lens/components/LensTableAvatarCell.vue +442 -0
- package/lib/blocks/lens/composables/LensEdit.ts +22 -0
- package/lib/blocks/lens/fields/AvatarField.ts +50 -2
- package/lib/blocks/lens/validation/RuleMapper.ts +6 -0
- package/lib/components/SInputImage.vue +9 -1
- package/package.json +12 -12
|
@@ -70,9 +70,23 @@ export interface AvatarFieldData extends FieldDataBase {
|
|
|
70
70
|
* avatar uses the name (resolved against the active language) for the
|
|
71
71
|
* initials fallback / tooltip when the image is unavailable. The field
|
|
72
72
|
* value itself is the image URL.
|
|
73
|
+
*
|
|
74
|
+
* When the field is editable, these companion keys also become editable from
|
|
75
|
+
* the inline cell (the pencil affordance beside the avatar edits both names),
|
|
76
|
+
* so the avatar and its names can be updated together.
|
|
73
77
|
*/
|
|
74
78
|
nameEn?: string | null
|
|
75
79
|
nameJa?: string | null
|
|
80
|
+
/**
|
|
81
|
+
* The `accept` attribute for the file picker (e.g. `image/jpeg,image/png`).
|
|
82
|
+
* Defaults to `image/*`.
|
|
83
|
+
*/
|
|
84
|
+
accept?: string | null
|
|
85
|
+
/** Help text shown beneath the picker, per language. */
|
|
86
|
+
helpEn?: string | null
|
|
87
|
+
helpJa?: string | null
|
|
88
|
+
/** Picker preview shape. Defaults to `circle` (avatars are round). */
|
|
89
|
+
imageType?: 'circle' | 'rectangle' | null
|
|
76
90
|
}
|
|
77
91
|
|
|
78
92
|
export interface BooleanFieldData extends FieldDataBase {
|
package/lib/blocks/lens/Rule.ts
CHANGED
|
@@ -20,6 +20,8 @@ export type Rule =
|
|
|
20
20
|
| BeforeOrEqualRule
|
|
21
21
|
| AfterRule
|
|
22
22
|
| AfterOrEqualRule
|
|
23
|
+
| FileExtensionRule
|
|
24
|
+
| MaxFileSizeRule
|
|
23
25
|
| EachRule
|
|
24
26
|
|
|
25
27
|
export interface MaxLengthRule {
|
|
@@ -115,6 +117,16 @@ export interface AfterOrEqualRule {
|
|
|
115
117
|
date: string
|
|
116
118
|
}
|
|
117
119
|
|
|
120
|
+
export interface FileExtensionRule {
|
|
121
|
+
type: 'file_extension'
|
|
122
|
+
extensions: string[]
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface MaxFileSizeRule {
|
|
126
|
+
type: 'max_file_size'
|
|
127
|
+
size: string
|
|
128
|
+
}
|
|
129
|
+
|
|
118
130
|
/**
|
|
119
131
|
* Applies the nested `rules` to every element of an array value, mirroring
|
|
120
132
|
* Laravel's `field.*` wildcard on the backend.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import SInputImage, { type ImageType, type Size } from '../../../components/SInputImage.vue'
|
|
3
|
+
import { useTrans } from '../../../composables/Lang'
|
|
4
|
+
import { type Validatable } from '../../../composables/Validation'
|
|
5
|
+
|
|
6
|
+
withDefaults(defineProps<{
|
|
7
|
+
label?: string
|
|
8
|
+
help?: string
|
|
9
|
+
accept?: string
|
|
10
|
+
imageType?: ImageType
|
|
11
|
+
size?: Size
|
|
12
|
+
disabled?: boolean
|
|
13
|
+
validation?: Validatable
|
|
14
|
+
}>(), {
|
|
15
|
+
imageType: 'circle',
|
|
16
|
+
size: 'small'
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
// The model is the raw `File` the user just picked, the existing image URL
|
|
20
|
+
// (a string), or `null` when there is no image / it was removed. `SInputImage`
|
|
21
|
+
// renders a preview from any of these via `useImageSrcFromFile`.
|
|
22
|
+
const model = defineModel<File | string | null>()
|
|
23
|
+
|
|
24
|
+
const { t } = useTrans({
|
|
25
|
+
en: { select: 'Select image', remove: 'Remove image' },
|
|
26
|
+
ja: { select: '画像を選択', remove: '画像を削除' }
|
|
27
|
+
})
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<template>
|
|
31
|
+
<SInputImage
|
|
32
|
+
v-model="model"
|
|
33
|
+
class="LensAvatarInput"
|
|
34
|
+
:label
|
|
35
|
+
:help
|
|
36
|
+
:accept
|
|
37
|
+
:image-type
|
|
38
|
+
:size
|
|
39
|
+
:disabled
|
|
40
|
+
:select-text="t.select"
|
|
41
|
+
:remove-text="t.remove"
|
|
42
|
+
:validation
|
|
43
|
+
/>
|
|
44
|
+
</template>
|
|
@@ -212,7 +212,7 @@ const { t } = useTrans({
|
|
|
212
212
|
delete_failed: (label: string) => `We couldn’t delete “${label}”.`,
|
|
213
213
|
delete_failed_anon: 'We couldn’t delete this record.',
|
|
214
214
|
write_error_reload: 'Please reload and try again.',
|
|
215
|
-
write_error_recover: 'Please reload to see the latest.',
|
|
215
|
+
write_error_recover: 'Please reload to see the latest data.',
|
|
216
216
|
busy_warning: 'Still saving — please wait a moment before changing the view.',
|
|
217
217
|
refresh_text: 'Newer results are available.',
|
|
218
218
|
refresh_failed_text: 'Some changes couldn’t be saved. Refresh to restore the latest values.',
|
|
@@ -839,7 +839,7 @@ function resolveLabel(record: Record<string, any>): string | null {
|
|
|
839
839
|
}
|
|
840
840
|
|
|
841
841
|
const { execute: executeUpdate } = useMutation((http, id: any, values: Record<string, any>) =>
|
|
842
|
-
http.post(`${endpointBase.value}/update`, {
|
|
842
|
+
http.post<LensResult & { data: Record<string, any> }>(`${endpointBase.value}/update`, {
|
|
843
843
|
entity: entityName.value, id, values, settings: { lang }
|
|
844
844
|
})
|
|
845
845
|
)
|
|
@@ -1071,8 +1071,8 @@ function hasPendingWrite(recordId: any): boolean {
|
|
|
1071
1071
|
// one-click path when it appears, but it can't always (a failed recovery reconcile
|
|
1072
1072
|
// shows none, and `isSameResult` can't see detail-only sheet fields absent from
|
|
1073
1073
|
// the search), so the reload hint stays as the guaranteed fallback. With a reason
|
|
1074
|
-
// present, "reload to see the latest" (the change didn't take; reload to
|
|
1075
|
-
// without one (network / 5xx / opaque), "reload and try again".
|
|
1074
|
+
// present, "reload to see the latest data" (the change didn't take; reload to
|
|
1075
|
+
// resync); without one (network / 5xx / opaque), "reload and try again".
|
|
1076
1076
|
function writeErrorText(op: 'save' | 'delete', label: string | null, reason: string | null): string {
|
|
1077
1077
|
const subject = op === 'delete'
|
|
1078
1078
|
? (label != null ? t.delete_failed(label) : t.delete_failed_anon)
|
|
@@ -1196,6 +1196,103 @@ function save(record: Record<string, any>, values: Record<string, any>): void {
|
|
|
1196
1196
|
trackWrite(id, key, 'save', resolveLabel(record), () => gate.then(() => executeUpdate(id, values)))
|
|
1197
1197
|
}
|
|
1198
1198
|
|
|
1199
|
+
// Reflect a change on the in-memory record. Pure in-memory mutation: the caller
|
|
1200
|
+
// (`saveBlocking`) owns the write accounting (invalidation, reconcile). The
|
|
1201
|
+
// catalog row and any open sheet share the object, so the change shows
|
|
1202
|
+
// immediately. The row identifier is stripped so it can never re-key.
|
|
1203
|
+
function patch(record: Record<string, any>, values: Record<string, any>): void {
|
|
1204
|
+
const indexField = idField.value
|
|
1205
|
+
if (indexField in values) {
|
|
1206
|
+
values = { ...values }
|
|
1207
|
+
delete values[indexField]
|
|
1208
|
+
}
|
|
1209
|
+
if (Object.keys(values).length === 0) { return }
|
|
1210
|
+
Object.assign(record, values)
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// Update — blocking. For a field whose input value can't be shown optimistically
|
|
1214
|
+
// (a file input holds a raw `File`, not the displayed URL), the write must finish
|
|
1215
|
+
// before the row can reflect it. Awaits the Lens update (sent multipart when a
|
|
1216
|
+
// `File` is present) and patches the submitted columns from the canonical
|
|
1217
|
+
// response, with the same accounting as an optimistic write so it stays
|
|
1218
|
+
// consistent with concurrent reads/writes:
|
|
1219
|
+
// - the invalidation preamble (mirroring `trackWrite`) supersedes any stashed /
|
|
1220
|
+
// in-flight reconcile or search, so a request that predates the write can't
|
|
1221
|
+
// reassign stale rows mid-flight;
|
|
1222
|
+
// - `pendingWrites` locks the query controls while it's in flight, and
|
|
1223
|
+
// `pendingByRecord` arms `openSheet`'s `/show` stale-read guard;
|
|
1224
|
+
// - writes to the same record+columns serialize on `writeChains` (last issued
|
|
1225
|
+
// wins, and a delete gates on them);
|
|
1226
|
+
// - only the submitted columns are patched — not the whole row, so a concurrent
|
|
1227
|
+
// optimistic edit to another column isn't reverted — onto the live displayed
|
|
1228
|
+
// row (resolved by id), not only the passed `record` (an orphan after a
|
|
1229
|
+
// create refetched);
|
|
1230
|
+
// - a reconcile resyncs once the batch settles.
|
|
1231
|
+
// Rejects if the write fails: nothing was patched, so the row keeps its previous
|
|
1232
|
+
// value and the caller need only surface the error.
|
|
1233
|
+
async function saveBlocking(record: Record<string, any>, values: Record<string, any>): Promise<void> {
|
|
1234
|
+
const id = resolveId(record)
|
|
1235
|
+
// Never let an update re-key the row (mirrors save()).
|
|
1236
|
+
const indexField = idField.value
|
|
1237
|
+
if (indexField in values) {
|
|
1238
|
+
values = { ...values }
|
|
1239
|
+
delete values[indexField]
|
|
1240
|
+
}
|
|
1241
|
+
if (Object.keys(values).length === 0) { return }
|
|
1242
|
+
|
|
1243
|
+
latestState.value = null
|
|
1244
|
+
reconcileToken++
|
|
1245
|
+
searchToken++
|
|
1246
|
+
reconciling.value = false
|
|
1247
|
+
pendingWrites.value++
|
|
1248
|
+
pendingByRecord.set(id, (pendingByRecord.get(id) ?? 0) + 1)
|
|
1249
|
+
|
|
1250
|
+
// Serialize writes to the same record+columns: chain after any in-flight one so
|
|
1251
|
+
// its request is sent (and patched) only once the earlier resolves. Keyed like
|
|
1252
|
+
// save() so the delete gate (which scans writeChains) waits for it too.
|
|
1253
|
+
const key = `${id}:${Object.keys(values).sort().join(',')}`
|
|
1254
|
+
const prev = writeChains.get(key) ?? Promise.resolve()
|
|
1255
|
+
const run = prev.catch(() => {}).then(() => executeUpdate(id, values))
|
|
1256
|
+
writeChains.set(key, run)
|
|
1257
|
+
|
|
1258
|
+
try {
|
|
1259
|
+
const res = await run
|
|
1260
|
+
// Patch only the submitted columns the canonical row actually carries — a
|
|
1261
|
+
// withheld (write-only) response mustn't null a column we can't read back —
|
|
1262
|
+
// plus the server timestamp (falling back to ~now) like save(); the reconcile
|
|
1263
|
+
// ignores updated_at, so it never surfaces the banner just for this.
|
|
1264
|
+
const data = res.data ?? {}
|
|
1265
|
+
const patched: Record<string, any> = {}
|
|
1266
|
+
for (const k of Object.keys(values)) {
|
|
1267
|
+
if (k in data) { patched[k] = data[k] }
|
|
1268
|
+
}
|
|
1269
|
+
if (UPDATED_AT_KEY in record) {
|
|
1270
|
+
patched[UPDATED_AT_KEY] = data[UPDATED_AT_KEY] ?? day().toISOString()
|
|
1271
|
+
}
|
|
1272
|
+
patch(record, patched)
|
|
1273
|
+
const row = result.value?.data.find((r) => resolveId(r) === id)
|
|
1274
|
+
if (row && row !== record) {
|
|
1275
|
+
patch(row, patched)
|
|
1276
|
+
}
|
|
1277
|
+
} finally {
|
|
1278
|
+
if (writeChains.get(key) === run) {
|
|
1279
|
+
writeChains.delete(key)
|
|
1280
|
+
}
|
|
1281
|
+
const remaining = (pendingByRecord.get(id) ?? 1) - 1
|
|
1282
|
+
remaining > 0 ? pendingByRecord.set(id, remaining) : pendingByRecord.delete(id)
|
|
1283
|
+
pendingWrites.value--
|
|
1284
|
+
if (pendingWrites.value === 0) {
|
|
1285
|
+
// Drive the post-batch reconcile like trackWrite does — this write shares
|
|
1286
|
+
// the `pendingWrites` batch, so when it settles last it must forward (and
|
|
1287
|
+
// reset) `batchErrored` so a concurrent failed write's diverged row still
|
|
1288
|
+
// gets the recovery banner, and the flag doesn't leak into the next batch.
|
|
1289
|
+
const errored = batchErrored
|
|
1290
|
+
batchErrored = false
|
|
1291
|
+
reconcile(errored)
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1199
1296
|
// Create — blocking. The slow POST runs to completion, then the catalog is
|
|
1200
1297
|
// refreshed with the now-synced canonical state (preserving the active query).
|
|
1201
1298
|
// The create sheet shows a button spinner meanwhile.
|
|
@@ -1290,7 +1387,9 @@ function remove(record: Record<string, any>): void {
|
|
|
1290
1387
|
.filter(([key]) => key.startsWith(`${id}:`))
|
|
1291
1388
|
.map(([, chain]) => chain)
|
|
1292
1389
|
// `Promise.allSettled([])` resolves immediately, so this is a no-op gate when
|
|
1293
|
-
// the record has no in-flight writes.
|
|
1390
|
+
// the record has no in-flight writes. Blocking avatar writes share writeChains,
|
|
1391
|
+
// so they're gated too — a delete can't race an in-flight avatar write, which
|
|
1392
|
+
// would otherwise fail against a removed row or write after the delete.
|
|
1294
1393
|
const gate = Promise.allSettled(pendingForRecord)
|
|
1295
1394
|
trackWrite(id, `${id}:__delete__`, 'delete', resolveLabel(record), () => gate.then(() => executeDelete(id)))
|
|
1296
1395
|
}
|
|
@@ -1407,10 +1506,12 @@ provideLensEdit({
|
|
|
1407
1506
|
canDelete,
|
|
1408
1507
|
resolveId,
|
|
1409
1508
|
save,
|
|
1509
|
+
saveBlocking,
|
|
1410
1510
|
create,
|
|
1411
1511
|
remove,
|
|
1412
1512
|
openSheet,
|
|
1413
|
-
openCreate
|
|
1513
|
+
openCreate,
|
|
1514
|
+
refresh: refreshCatalog
|
|
1414
1515
|
})
|
|
1415
1516
|
|
|
1416
1517
|
// Warn the user (native prompt) if they try to leave while a write is still
|
|
@@ -87,6 +87,8 @@ const selectOptions = ref(createSelectOptions())
|
|
|
87
87
|
|
|
88
88
|
const selectedOption = ref<SelectOption | null>(null)
|
|
89
89
|
|
|
90
|
+
const hasSelection = computed(() => selectOptions.value.some((s) => s.value))
|
|
91
|
+
|
|
90
92
|
useDraggable(el, selectOptions, {
|
|
91
93
|
handle: '.handle'
|
|
92
94
|
})
|
|
@@ -262,6 +264,7 @@ async function onApply() {
|
|
|
262
264
|
size="md"
|
|
263
265
|
mode="info"
|
|
264
266
|
:label="t.a_apply"
|
|
267
|
+
:disabled="!hasSelection"
|
|
265
268
|
@click="onApply"
|
|
266
269
|
/>
|
|
267
270
|
</SCardFooter>
|
|
@@ -14,6 +14,7 @@ import { type FieldData } from '../FieldData'
|
|
|
14
14
|
import { useFieldFactory } from '../composables/FieldFactory'
|
|
15
15
|
import { useLensEdit } from '../composables/LensEdit'
|
|
16
16
|
import { extractServerErrors, extractServerMessage } from '../validation/ServerErrors'
|
|
17
|
+
import LensSheetAvatarField from './LensSheetAvatarField.vue'
|
|
17
18
|
import LensSheetField from './LensSheetField.vue'
|
|
18
19
|
|
|
19
20
|
const props = withDefaults(defineProps<{
|
|
@@ -97,7 +98,9 @@ const createFieldViews = computed(() =>
|
|
|
97
98
|
// The subset of create views that actually contribute a value. Display-only
|
|
98
99
|
// fields (e.g. a `content` field showing instructions) still render, but
|
|
99
100
|
// `formInputComponent()` returns static markup with no value, so they must not
|
|
100
|
-
// be seeded, validated, or submitted.
|
|
101
|
+
// be seeded, validated, or submitted. An avatar field is submittable (its `File`
|
|
102
|
+
// rides the create payload as a multipart part), so it's seeded and validated
|
|
103
|
+
// here like any other input.
|
|
101
104
|
const createInputViews = computed(() =>
|
|
102
105
|
createFieldViews.value.filter((v) => v.field.isSubmittable())
|
|
103
106
|
)
|
|
@@ -201,6 +204,8 @@ async function onCreate() {
|
|
|
201
204
|
for (const { key, field } of createInputViews.value) {
|
|
202
205
|
values[key] = field.inputToPayload(createModel[key])
|
|
203
206
|
}
|
|
207
|
+
// A picked avatar `File` rides `values`; `edit.create` sends the payload
|
|
208
|
+
// multipart when one is present, so the avatar persists with the new record.
|
|
204
209
|
await edit!.create(values)
|
|
205
210
|
emit('close')
|
|
206
211
|
} catch (e) {
|
|
@@ -327,13 +332,20 @@ const slotProps = computed(() => ({
|
|
|
327
332
|
{{ t.load_error }}
|
|
328
333
|
</div>
|
|
329
334
|
<div v-else class="sheet-rows">
|
|
330
|
-
<
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
335
|
+
<template v-for="entry in detailFields" :key="entry.key">
|
|
336
|
+
<LensSheetAvatarField
|
|
337
|
+
v-if="entry.fieldData.type === 'avatar'"
|
|
338
|
+
:field="entry.field"
|
|
339
|
+
:field-key="entry.key"
|
|
340
|
+
:record
|
|
341
|
+
/>
|
|
342
|
+
<LensSheetField
|
|
343
|
+
v-else
|
|
344
|
+
:field="entry.field"
|
|
345
|
+
:field-key="entry.key"
|
|
346
|
+
:record
|
|
347
|
+
/>
|
|
348
|
+
</template>
|
|
337
349
|
</div>
|
|
338
350
|
</template>
|
|
339
351
|
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, reactive, ref, watch } from 'vue'
|
|
3
|
+
import SDataListItem from '../../../components/SDataListItem.vue'
|
|
4
|
+
import { useTrans } from '../../../composables/Lang'
|
|
5
|
+
import { useValidation } from '../../../composables/Validation'
|
|
6
|
+
import { useSnackbars } from '../../../stores/Snackbars'
|
|
7
|
+
import { type FieldData } from '../FieldData'
|
|
8
|
+
import { useLensEdit } from '../composables/LensEdit'
|
|
9
|
+
import { type AvatarField } from '../fields/AvatarField'
|
|
10
|
+
import { type Field } from '../fields/Field'
|
|
11
|
+
import LensAvatarInput from './LensAvatarInput.vue'
|
|
12
|
+
|
|
13
|
+
const props = defineProps<{
|
|
14
|
+
field: Field<FieldData>
|
|
15
|
+
fieldKey: string
|
|
16
|
+
record: Record<string, any>
|
|
17
|
+
}>()
|
|
18
|
+
|
|
19
|
+
// The table only renders this component for `avatar` fields, so the field is
|
|
20
|
+
// always an `AvatarField` — narrow it for the avatar-specific config getters.
|
|
21
|
+
const avatarField = computed(() => props.field as AvatarField)
|
|
22
|
+
|
|
23
|
+
const { t } = useTrans({
|
|
24
|
+
en: { save_error: 'We couldn’t save the image. Please try again.' },
|
|
25
|
+
ja: { save_error: '画像を保存できませんでした。もう一度お試しください。' }
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const edit = useLensEdit()
|
|
29
|
+
const snackbars = useSnackbars()
|
|
30
|
+
|
|
31
|
+
// Editable only when the row passes the edit gate and the field is marked
|
|
32
|
+
// editable; the image is written through the Lens update (a blocking save).
|
|
33
|
+
const editable = computed(() =>
|
|
34
|
+
!!edit?.canEdit(props.record)
|
|
35
|
+
&& (props.field as any).data?.showOnUpdate === true
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
// Materialize the read-only display component once (computed off `props.field`):
|
|
39
|
+
// `dataListItemComponent()` mints a fresh definition each call, so resolving it
|
|
40
|
+
// inline in the template would unmount/remount the avatar on every reactive row
|
|
41
|
+
// update (e.g. a write's `patch`), causing flicker.
|
|
42
|
+
const displayComponent = computed(() => props.field.dataListItemComponent())
|
|
43
|
+
|
|
44
|
+
const saving = ref(false)
|
|
45
|
+
|
|
46
|
+
// The value the picker shows: the row's current image URL, except while a save
|
|
47
|
+
// is in flight (then the local file preview the change handler set).
|
|
48
|
+
const model = ref<File | string | null>(props.record[props.fieldKey] ?? null)
|
|
49
|
+
|
|
50
|
+
watch(
|
|
51
|
+
() => props.record[props.fieldKey],
|
|
52
|
+
(value) => { if (!saving.value) { model.value = value ?? null } }
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
// Validate the picked value (a File, or null on removal) against the field rules
|
|
56
|
+
// before the blocking save. The model usually holds the existing image URL — a
|
|
57
|
+
// string the file rules reject — so validation runs on what's submitted, here,
|
|
58
|
+
// rather than inline on the input (which would flag the unchanged URL).
|
|
59
|
+
const pending = reactive<{ value: File | null }>({ value: null })
|
|
60
|
+
const { validation, validate } = useValidation(
|
|
61
|
+
() => pending,
|
|
62
|
+
() => ({ value: avatarField.value.generateValidationRules() })
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
async function onChange(value: File | string | null | undefined) {
|
|
66
|
+
// A string is the existing URL still selected — nothing to save.
|
|
67
|
+
if (typeof value === 'string') {
|
|
68
|
+
model.value = value
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
const file = value ?? null
|
|
72
|
+
// Capture the record being edited: the sheet can be closed and reopened on
|
|
73
|
+
// another row while this save is in flight (only the input is disabled, not
|
|
74
|
+
// the sheet), so `props.record` may point elsewhere by the time we resolve.
|
|
75
|
+
const record = props.record
|
|
76
|
+
// Show the picked file (or the cleared empty state) immediately.
|
|
77
|
+
model.value = file
|
|
78
|
+
|
|
79
|
+
pending.value = file
|
|
80
|
+
if (!(await validate())) {
|
|
81
|
+
const message = validation.value.$errors[0]?.$message
|
|
82
|
+
snackbars.push({ mode: 'danger', text: message ? String(message) : t.save_error })
|
|
83
|
+
if (props.record === record) {
|
|
84
|
+
model.value = record[props.fieldKey] ?? null
|
|
85
|
+
}
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
saving.value = true
|
|
90
|
+
try {
|
|
91
|
+
// The catalog writes through the Lens update and patches the captured
|
|
92
|
+
// record's column itself (with write accounting); reflect the canonical
|
|
93
|
+
// value on this picker if it still shows the same record.
|
|
94
|
+
await edit?.saveBlocking(record, { [props.fieldKey]: file })
|
|
95
|
+
if (props.record === record) {
|
|
96
|
+
model.value = record[props.fieldKey] ?? null
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
if (props.record === record) {
|
|
100
|
+
model.value = record[props.fieldKey] ?? null
|
|
101
|
+
}
|
|
102
|
+
snackbars.push({ mode: 'danger', text: t.save_error })
|
|
103
|
+
} finally {
|
|
104
|
+
saving.value = false
|
|
105
|
+
// If the sheet rebound to another row mid-save, resync the picker to it
|
|
106
|
+
// (the `props.record` watcher skipped re-seeding while `saving` was true).
|
|
107
|
+
if (props.record !== record) {
|
|
108
|
+
model.value = props.record[props.fieldKey] ?? null
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
</script>
|
|
113
|
+
|
|
114
|
+
<template>
|
|
115
|
+
<div class="LensSheetAvatarField">
|
|
116
|
+
<SDataListItem v-if="editable">
|
|
117
|
+
<template #label>{{ field.label() }}</template>
|
|
118
|
+
<template #value>
|
|
119
|
+
<LensAvatarInput
|
|
120
|
+
:model-value="model"
|
|
121
|
+
:accept="avatarField.accept()"
|
|
122
|
+
:image-type="avatarField.imageType()"
|
|
123
|
+
:help="field.help() || undefined"
|
|
124
|
+
:disabled="saving"
|
|
125
|
+
@update:model-value="onChange"
|
|
126
|
+
/>
|
|
127
|
+
</template>
|
|
128
|
+
</SDataListItem>
|
|
129
|
+
<component :is="displayComponent" v-else :value="record[fieldKey]" />
|
|
130
|
+
</div>
|
|
131
|
+
</template>
|
|
132
|
+
|
|
133
|
+
<style scoped lang="postcss">
|
|
134
|
+
.LensSheetAvatarField {
|
|
135
|
+
position: relative;
|
|
136
|
+
border-bottom: 1px dashed var(--c-divider);
|
|
137
|
+
}
|
|
138
|
+
</style>
|
|
@@ -5,13 +5,14 @@ import { computed, markRaw } from 'vue'
|
|
|
5
5
|
import STable from '../../../components/STable.vue'
|
|
6
6
|
import { type DropdownSection } from '../../../composables/Dropdown'
|
|
7
7
|
import { type TableCell, type TableColumns, useTable } from '../../../composables/Table'
|
|
8
|
-
import { type FieldData } from '../FieldData'
|
|
8
|
+
import { type AvatarFieldData, 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
12
|
import { useLensEdit } from '../composables/LensEdit'
|
|
13
13
|
import { provideLensInlineEdit } from '../composables/LensInlineEdit'
|
|
14
14
|
import { type Field } from '../fields/Field'
|
|
15
|
+
import LensTableAvatarCell from './LensTableAvatarCell.vue'
|
|
15
16
|
import LensTableEditableCell from './LensTableEditableCell.vue'
|
|
16
17
|
|
|
17
18
|
const props = defineProps<{
|
|
@@ -44,6 +45,7 @@ const edit = useLensEdit()
|
|
|
44
45
|
provideLensInlineEdit()
|
|
45
46
|
|
|
46
47
|
const editableCellComponent = markRaw(LensTableEditableCell)
|
|
48
|
+
const avatarCellComponent = markRaw(LensTableAvatarCell)
|
|
47
49
|
|
|
48
50
|
const records = computed(() => props.result?.data ?? [])
|
|
49
51
|
|
|
@@ -146,6 +148,31 @@ const columns = computedAsync(async () => {
|
|
|
146
148
|
onClick: () => edit.openSheet(r)
|
|
147
149
|
}
|
|
148
150
|
}
|
|
151
|
+
} else if (
|
|
152
|
+
props.inlineEditable
|
|
153
|
+
&& edit?.editable
|
|
154
|
+
&& key !== edit.indexField
|
|
155
|
+
&& overriddenFieldData.type === 'avatar'
|
|
156
|
+
&& overriddenFieldData.showOnUpdate === true
|
|
157
|
+
) {
|
|
158
|
+
// Avatars get a dedicated cell: a hover overlay to change the image
|
|
159
|
+
// (written through a blocking Lens update) plus an optional pencil that
|
|
160
|
+
// edits the display-name companions inline (optimistic). The image and
|
|
161
|
+
// names are persisted differently, so the generic editable cell (a single
|
|
162
|
+
// optimistic field write) can't serve them.
|
|
163
|
+
const avatarData = overriddenFieldData as AvatarFieldData
|
|
164
|
+
column.cell = {
|
|
165
|
+
type: 'component',
|
|
166
|
+
component: avatarCellComponent,
|
|
167
|
+
props: {
|
|
168
|
+
field,
|
|
169
|
+
fieldKey: key,
|
|
170
|
+
nameEnKey: avatarData.nameEn ?? null,
|
|
171
|
+
nameJaKey: avatarData.nameJa ?? null,
|
|
172
|
+
nameFieldEn: makeEditableField(r, avatarData.nameEn),
|
|
173
|
+
nameFieldJa: makeEditableField(r, avatarData.nameJa)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
149
176
|
} else if (
|
|
150
177
|
props.inlineEditable
|
|
151
178
|
&& edit?.editable
|
|
@@ -237,6 +264,29 @@ function hasFormInput(field: Field<FieldData>): boolean {
|
|
|
237
264
|
return false
|
|
238
265
|
}
|
|
239
266
|
}
|
|
267
|
+
|
|
268
|
+
// Build a sibling field instance for inline editing from the current result's
|
|
269
|
+
// field set — but only when it's editable (`showOnUpdate`) and safe to edit the
|
|
270
|
+
// same way the generic inline cell requires: submittable, optimistically
|
|
271
|
+
// patchable, and with a real form input. Used by the avatar cell to edit its
|
|
272
|
+
// display-name companions; this omits a companion that is non-editable, display
|
|
273
|
+
// only (`content`), holds raw files (`file_upload`), or has no input (`id` /
|
|
274
|
+
// `slack_message`), so the name editor can't send an unsafe value or crash.
|
|
275
|
+
function makeEditableField(r: LensResult, key?: string | null): Field<FieldData> | null {
|
|
276
|
+
if (!key) {
|
|
277
|
+
return null
|
|
278
|
+
}
|
|
279
|
+
const fieldData = r.fields[key]
|
|
280
|
+
if (!fieldData || fieldData.showOnUpdate !== true) {
|
|
281
|
+
return null
|
|
282
|
+
}
|
|
283
|
+
const data = Object.assign(cloneDeep(fieldData), props.overrides?.[key] ?? {})
|
|
284
|
+
const field = fieldFactory.make(data)
|
|
285
|
+
if (!field.isSubmittable() || !field.supportsOptimisticUpdate() || !hasFormInput(field)) {
|
|
286
|
+
return null
|
|
287
|
+
}
|
|
288
|
+
return field
|
|
289
|
+
}
|
|
240
290
|
</script>
|
|
241
291
|
|
|
242
292
|
<template>
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import IconCamera from '~icons/ph/camera'
|
|
3
|
+
import IconPencilSimple from '~icons/ph/pencil-simple'
|
|
4
|
+
import { useElementBounding } from '@vueuse/core'
|
|
5
|
+
import { computed, nextTick, onUnmounted, reactive, ref, watch } from 'vue'
|
|
6
|
+
import SAvatar from '../../../components/SAvatar.vue'
|
|
7
|
+
import SButton from '../../../components/SButton.vue'
|
|
8
|
+
import SSpinner from '../../../components/SSpinner.vue'
|
|
9
|
+
import { useManualDropdownPosition } from '../../../composables/Dropdown'
|
|
10
|
+
import { useTrans } from '../../../composables/Lang'
|
|
11
|
+
import { useValidation } from '../../../composables/Validation'
|
|
12
|
+
import { useSnackbars } from '../../../stores/Snackbars'
|
|
13
|
+
import { type FieldData } from '../FieldData'
|
|
14
|
+
import { useLensEdit } from '../composables/LensEdit'
|
|
15
|
+
import { useLensInlineEdit } from '../composables/LensInlineEdit'
|
|
16
|
+
import { type AvatarField } from '../fields/AvatarField'
|
|
17
|
+
import { type Field } from '../fields/Field'
|
|
18
|
+
|
|
19
|
+
const props = defineProps<{
|
|
20
|
+
field: AvatarField
|
|
21
|
+
fieldKey: string
|
|
22
|
+
// The English / Japanese display-name companion fields, resolved by the table
|
|
23
|
+
// from the avatar field's `nameEn` / `nameJa` keys. Present only when the
|
|
24
|
+
// avatar declares them, so the names can be edited alongside the image.
|
|
25
|
+
nameEnKey?: string | null
|
|
26
|
+
nameJaKey?: string | null
|
|
27
|
+
nameFieldEn?: Field<FieldData> | null
|
|
28
|
+
nameFieldJa?: Field<FieldData> | null
|
|
29
|
+
// Injected by STable for a `component` cell: the cell value (image URL) and
|
|
30
|
+
// the full row record.
|
|
31
|
+
value: any
|
|
32
|
+
record: Record<string, any>
|
|
33
|
+
}>()
|
|
34
|
+
|
|
35
|
+
const { t } = useTrans({
|
|
36
|
+
en: { cancel: 'Cancel', save: 'Save', edit_image: 'Change image', edit_names: 'Edit name', upload_error: 'We couldn’t upload the image. Please try again.' },
|
|
37
|
+
ja: { cancel: 'キャンセル', save: '保存', edit_image: '画像を変更', edit_names: '名前を編集', upload_error: '画像をアップロードできませんでした。もう一度お試しください。' }
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const edit = useLensEdit()
|
|
41
|
+
const inline = useLensInlineEdit()
|
|
42
|
+
const snackbars = useSnackbars()
|
|
43
|
+
|
|
44
|
+
const canEdit = computed(() => !!edit?.canEdit(props.record))
|
|
45
|
+
|
|
46
|
+
// The image is written through the Lens update (a blocking save); the overlay
|
|
47
|
+
// affordance is shown whenever the row is editable.
|
|
48
|
+
const canEditImage = computed(() => canEdit.value)
|
|
49
|
+
|
|
50
|
+
const nameEntries = computed(() => {
|
|
51
|
+
const entries: { key: string; field: Field<FieldData> }[] = []
|
|
52
|
+
const seen = new Set<string>()
|
|
53
|
+
const add = (key: string | null | undefined, field: Field<FieldData> | null | undefined): void => {
|
|
54
|
+
// Skip a companion that isn't fetched on this row — editing the visible name
|
|
55
|
+
// would otherwise send an empty value for the unfetched sibling and clear it
|
|
56
|
+
// (e.g. `loadSelectable` exposes `name_ja` in `fields` while the select omits
|
|
57
|
+
// it). De-duplicate too: both languages may point at one shared name column.
|
|
58
|
+
if (!key || !field || seen.has(key) || !(key in props.record)) {
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
seen.add(key)
|
|
62
|
+
entries.push({ key, field })
|
|
63
|
+
}
|
|
64
|
+
add(props.nameEnKey, props.nameFieldEn)
|
|
65
|
+
add(props.nameJaKey, props.nameFieldJa)
|
|
66
|
+
return entries
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const canEditNames = computed(() => canEdit.value && nameEntries.value.length > 0)
|
|
70
|
+
|
|
71
|
+
// --- Display (mirrors STableCellAvatar) -------------------------------------
|
|
72
|
+
|
|
73
|
+
const base = computed<any>(() => props.field.tableCell(props.value, props.record))
|
|
74
|
+
|
|
75
|
+
// While an upload is in flight, show a local preview of the picked file so the
|
|
76
|
+
// new image appears immediately instead of waiting on the round-trip.
|
|
77
|
+
const previewSrc = ref<string | null>(null)
|
|
78
|
+
const displayImage = computed(() => previewSrc.value ?? base.value.image ?? null)
|
|
79
|
+
const displayName = computed(() => base.value.name || null)
|
|
80
|
+
|
|
81
|
+
// --- Image edit (file picker -> blocking Lens update) -----------------------
|
|
82
|
+
|
|
83
|
+
const fileInput = ref<HTMLInputElement | null>(null)
|
|
84
|
+
const uploading = ref(false)
|
|
85
|
+
|
|
86
|
+
// Validate the picked image against the field rules before the blocking save.
|
|
87
|
+
// The picked value is a raw `File` (or null), so the file rules apply directly.
|
|
88
|
+
const imagePending = reactive<{ value: File | null }>({ value: null })
|
|
89
|
+
const { validation: imageValidation, validate: validateImage } = useValidation(
|
|
90
|
+
() => imagePending,
|
|
91
|
+
() => ({ value: props.field.generateValidationRules() })
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
function pickImage() {
|
|
95
|
+
if (!canEditImage.value || uploading.value) {
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
fileInput.value?.click()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function onFilePicked(event: Event) {
|
|
102
|
+
const input = event.target as HTMLInputElement
|
|
103
|
+
const file = (input.files ?? [])[0] ?? null
|
|
104
|
+
// Reset the input so picking the same file again still fires `change`.
|
|
105
|
+
input.value = ''
|
|
106
|
+
if (!file) {
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
await upload(file)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function upload(file: File | null) {
|
|
113
|
+
if (!canEditImage.value) {
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
imagePending.value = file
|
|
117
|
+
if (!(await validateImage())) {
|
|
118
|
+
const message = imageValidation.value.$errors[0]?.$message
|
|
119
|
+
snackbars.push({ mode: 'danger', text: message ? String(message) : t.upload_error })
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
uploading.value = true
|
|
123
|
+
previewSrc.value = file ? URL.createObjectURL(file) : null
|
|
124
|
+
try {
|
|
125
|
+
// The catalog writes through the Lens update and patches the row's column
|
|
126
|
+
// itself, with write accounting so a concurrent query change can't land
|
|
127
|
+
// stale rows over it; the patched value is the URL, never the raw file.
|
|
128
|
+
await edit?.saveBlocking(props.record, { [props.fieldKey]: file })
|
|
129
|
+
} catch {
|
|
130
|
+
// saveBlocking rejects on a failed write; guard with a generic message so a
|
|
131
|
+
// failure never goes unreported.
|
|
132
|
+
snackbars.push({ mode: 'danger', text: t.upload_error })
|
|
133
|
+
} finally {
|
|
134
|
+
if (previewSrc.value) {
|
|
135
|
+
URL.revokeObjectURL(previewSrc.value)
|
|
136
|
+
previewSrc.value = null
|
|
137
|
+
}
|
|
138
|
+
uploading.value = false
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// --- Name edit (floating editor anchored to the cell) -----------------------
|
|
143
|
+
|
|
144
|
+
const myKey = computed(() => `${edit?.resolveId(props.record)}:${props.fieldKey}`)
|
|
145
|
+
const editingNames = computed(() => inline?.activeKey.value === myKey.value)
|
|
146
|
+
|
|
147
|
+
const anchor = ref<HTMLElement | null>(null)
|
|
148
|
+
const editorEl = ref<HTMLElement | null>(null)
|
|
149
|
+
const bounds = useElementBounding(anchor)
|
|
150
|
+
const { inset, update } = useManualDropdownPosition(anchor)
|
|
151
|
+
|
|
152
|
+
const nameModel = reactive<Record<string, any>>({})
|
|
153
|
+
|
|
154
|
+
// Materialize the name inputs once so the editor doesn't mint a fresh component
|
|
155
|
+
// definition each render (which breaks Vue's patching).
|
|
156
|
+
const nameInputs = computed(() =>
|
|
157
|
+
nameEntries.value.map((entry) => ({
|
|
158
|
+
key: entry.key,
|
|
159
|
+
field: entry.field,
|
|
160
|
+
component: entry.field.formInputComponent()
|
|
161
|
+
}))
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
const { validation, validate, reset } = useValidation(
|
|
165
|
+
() => nameModel,
|
|
166
|
+
() => Object.fromEntries(nameEntries.value.map((e) => [e.key, e.field.generateValidationRules()]))
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
const editorStyle = computed(() => ({
|
|
170
|
+
...inset.value,
|
|
171
|
+
width: `${Math.max(bounds.width.value, 280)}px`
|
|
172
|
+
}))
|
|
173
|
+
|
|
174
|
+
// If the backing record is replaced while the name editor is open (a refresh
|
|
175
|
+
// apply, a parent refresh, a requery), close it so a stale model can't be saved.
|
|
176
|
+
watch(
|
|
177
|
+
() => nameEntries.value.map((e) => props.record[e.key]),
|
|
178
|
+
() => { if (editingNames.value) { inline?.stop() } },
|
|
179
|
+
{ deep: true }
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
// STable virtualization can unmount this row while scrolling; clear the shared
|
|
183
|
+
// active key on unmount so a remount doesn't reopen a blank editor.
|
|
184
|
+
onUnmounted(() => {
|
|
185
|
+
if (inline?.activeKey.value === myKey.value) {
|
|
186
|
+
inline.stop()
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
function startNames() {
|
|
191
|
+
if (!canEditNames.value) {
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
for (const entry of nameEntries.value) {
|
|
195
|
+
nameModel[entry.key] = entry.field.payloadToInput(props.record[entry.key] ?? entry.field.inputEmptyValue())
|
|
196
|
+
}
|
|
197
|
+
reset()
|
|
198
|
+
inline?.start(myKey.value)
|
|
199
|
+
nextTick(() => {
|
|
200
|
+
update()
|
|
201
|
+
const el = editorEl.value?.querySelector('input, textarea, [contenteditable], [tabindex]') as HTMLElement | null
|
|
202
|
+
el?.focus()
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function cancelNames() {
|
|
207
|
+
inline?.stop()
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function applyNames() {
|
|
211
|
+
// Snapshot the companion values we're editing so we can detect the backing row
|
|
212
|
+
// being replaced (a refresh apply, a parent refresh, a requery, or a sibling
|
|
213
|
+
// save) during the async validate, and avoid clobbering the fresh state.
|
|
214
|
+
const edited = nameEntries.value.map((entry) => props.record[entry.key])
|
|
215
|
+
|
|
216
|
+
if (!(await validate())) {
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// A refresh / requery can swap a companion value during the validate microtask;
|
|
221
|
+
// the watcher above closes the editor but flushes asynchronously and can't abort
|
|
222
|
+
// this running apply, so re-check against the snapshot. If any changed, `model`
|
|
223
|
+
// predates the fresh value — bail rather than overwrite it.
|
|
224
|
+
if (nameEntries.value.some((entry, i) => props.record[entry.key] !== edited[i])) {
|
|
225
|
+
if (inline?.activeKey.value === myKey.value) {
|
|
226
|
+
inline.stop()
|
|
227
|
+
}
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// The editable predicate can flip to reject this row while the editor is open.
|
|
232
|
+
if (!canEditNames.value) {
|
|
233
|
+
if (inline?.activeKey.value === myKey.value) {
|
|
234
|
+
inline.stop()
|
|
235
|
+
}
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
const values: Record<string, any> = {}
|
|
239
|
+
for (const entry of nameEntries.value) {
|
|
240
|
+
values[entry.key] = entry.field.inputToPayload(nameModel[entry.key])
|
|
241
|
+
}
|
|
242
|
+
edit!.save(props.record, values)
|
|
243
|
+
if (inline?.activeKey.value === myKey.value) {
|
|
244
|
+
inline.stop()
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function onEditorKeydown(event: KeyboardEvent) {
|
|
249
|
+
if (event.key === 'Escape') {
|
|
250
|
+
event.preventDefault()
|
|
251
|
+
cancelNames()
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
</script>
|
|
255
|
+
|
|
256
|
+
<template>
|
|
257
|
+
<div ref="anchor" class="LensTableAvatarCell" :class="{ editing: editingNames }">
|
|
258
|
+
<div v-if="displayImage || displayName || canEditImage" class="avatar">
|
|
259
|
+
<button
|
|
260
|
+
class="image"
|
|
261
|
+
type="button"
|
|
262
|
+
:class="{ editable: canEditImage }"
|
|
263
|
+
:disabled="!canEditImage || uploading"
|
|
264
|
+
:aria-label="`${t.edit_image} ${field.label()}`"
|
|
265
|
+
@click="pickImage"
|
|
266
|
+
>
|
|
267
|
+
<SAvatar size="mini" :avatar="displayImage" :name="displayName" />
|
|
268
|
+
<span v-if="canEditImage" class="image-overlay">
|
|
269
|
+
<SSpinner v-if="uploading" class="image-spinner" />
|
|
270
|
+
<IconCamera v-else class="image-icon" />
|
|
271
|
+
</span>
|
|
272
|
+
</button>
|
|
273
|
+
<span v-if="displayName" class="name">{{ displayName }}</span>
|
|
274
|
+
</div>
|
|
275
|
+
|
|
276
|
+
<input
|
|
277
|
+
v-if="canEditImage"
|
|
278
|
+
ref="fileInput"
|
|
279
|
+
class="file-input"
|
|
280
|
+
type="file"
|
|
281
|
+
:accept="field.accept()"
|
|
282
|
+
@change="onFilePicked"
|
|
283
|
+
>
|
|
284
|
+
|
|
285
|
+
<button
|
|
286
|
+
v-if="canEditNames"
|
|
287
|
+
class="edit"
|
|
288
|
+
type="button"
|
|
289
|
+
:aria-label="`${t.edit_names} ${field.label()}`"
|
|
290
|
+
@click="startNames"
|
|
291
|
+
>
|
|
292
|
+
<IconPencilSimple class="edit-icon" />
|
|
293
|
+
</button>
|
|
294
|
+
|
|
295
|
+
<Teleport v-if="editingNames" to="#sefirot-modals">
|
|
296
|
+
<div
|
|
297
|
+
ref="editorEl"
|
|
298
|
+
class="LensTableAvatarCellEditor"
|
|
299
|
+
:style="editorStyle"
|
|
300
|
+
@keydown="onEditorKeydown"
|
|
301
|
+
>
|
|
302
|
+
<component
|
|
303
|
+
:is="input.component"
|
|
304
|
+
v-for="input in nameInputs"
|
|
305
|
+
:key="input.key"
|
|
306
|
+
v-model="nameModel[input.key]"
|
|
307
|
+
:validation="validation[input.key]"
|
|
308
|
+
/>
|
|
309
|
+
<div class="actions">
|
|
310
|
+
<SButton size="mini" :label="t.cancel" @click="cancelNames" />
|
|
311
|
+
<SButton size="mini" mode="info" :label="t.save" @click="applyNames" />
|
|
312
|
+
</div>
|
|
313
|
+
</div>
|
|
314
|
+
</Teleport>
|
|
315
|
+
</div>
|
|
316
|
+
</template>
|
|
317
|
+
|
|
318
|
+
<style scoped lang="postcss">
|
|
319
|
+
.LensTableAvatarCell {
|
|
320
|
+
position: relative;
|
|
321
|
+
display: flex;
|
|
322
|
+
align-items: center;
|
|
323
|
+
min-width: 0;
|
|
324
|
+
min-height: 40px;
|
|
325
|
+
padding: 8px 36px 8px 16px;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
.avatar {
|
|
329
|
+
display: flex;
|
|
330
|
+
align-items: center;
|
|
331
|
+
min-width: 0;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
.image {
|
|
335
|
+
position: relative;
|
|
336
|
+
display: flex;
|
|
337
|
+
flex-shrink: 0;
|
|
338
|
+
border-radius: 50%;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
.image.editable {
|
|
342
|
+
cursor: pointer;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
.image:disabled {
|
|
346
|
+
cursor: default;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
.image-overlay {
|
|
350
|
+
position: absolute;
|
|
351
|
+
inset: 0;
|
|
352
|
+
display: flex;
|
|
353
|
+
align-items: center;
|
|
354
|
+
justify-content: center;
|
|
355
|
+
border-radius: 50%;
|
|
356
|
+
background-color: rgba(0, 0, 0, 0.5);
|
|
357
|
+
color: #fff;
|
|
358
|
+
opacity: 0;
|
|
359
|
+
transition: opacity 0.1s;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
.image.editable:hover .image-overlay,
|
|
363
|
+
.image:disabled .image-overlay {
|
|
364
|
+
opacity: 1;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
.image-icon {
|
|
368
|
+
width: 12px;
|
|
369
|
+
height: 12px;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
.image-spinner {
|
|
373
|
+
width: 12px;
|
|
374
|
+
height: 12px;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
.name {
|
|
378
|
+
display: inline-block;
|
|
379
|
+
margin-left: 8px;
|
|
380
|
+
line-height: 24px;
|
|
381
|
+
font-size: var(--table-cell-font-size);
|
|
382
|
+
font-weight: var(--table-cell-font-weight);
|
|
383
|
+
color: var(--c-text-1);
|
|
384
|
+
white-space: nowrap;
|
|
385
|
+
overflow: hidden;
|
|
386
|
+
text-overflow: ellipsis;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
.file-input {
|
|
390
|
+
display: none;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
.edit {
|
|
394
|
+
position: absolute;
|
|
395
|
+
top: 50%;
|
|
396
|
+
right: 8px;
|
|
397
|
+
display: flex;
|
|
398
|
+
align-items: center;
|
|
399
|
+
justify-content: center;
|
|
400
|
+
width: 24px;
|
|
401
|
+
height: 24px;
|
|
402
|
+
transform: translateY(-50%);
|
|
403
|
+
border-radius: 6px;
|
|
404
|
+
color: var(--c-text-2);
|
|
405
|
+
opacity: 0;
|
|
406
|
+
transition: opacity 0.1s, background-color 0.1s, color 0.1s;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
.LensTableAvatarCell:hover .edit,
|
|
410
|
+
.LensTableAvatarCell.editing .edit {
|
|
411
|
+
opacity: 1;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
.edit:hover {
|
|
415
|
+
background-color: var(--c-bg-mute-1);
|
|
416
|
+
color: var(--c-text-1);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
.edit-icon {
|
|
420
|
+
width: 14px;
|
|
421
|
+
height: 14px;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
.LensTableAvatarCellEditor {
|
|
425
|
+
position: fixed;
|
|
426
|
+
z-index: var(--z-index-sheet, 2000);
|
|
427
|
+
display: flex;
|
|
428
|
+
flex-direction: column;
|
|
429
|
+
gap: 8px;
|
|
430
|
+
padding: 8px;
|
|
431
|
+
border: 1px solid var(--c-border);
|
|
432
|
+
border-radius: 8px;
|
|
433
|
+
background-color: var(--c-bg-1);
|
|
434
|
+
box-shadow: var(--shadow-depth-3);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
.actions {
|
|
438
|
+
display: flex;
|
|
439
|
+
justify-content: flex-end;
|
|
440
|
+
gap: 8px;
|
|
441
|
+
}
|
|
442
|
+
</style>
|
|
@@ -35,6 +35,21 @@ export interface LensEditContext {
|
|
|
35
35
|
*/
|
|
36
36
|
save: (record: Record<string, any>, values: Record<string, any>) => void
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Apply an update that can't be shown optimistically and wait for it. Some
|
|
40
|
+
* fields hold a value whose shape differs from what the row renders — a file
|
|
41
|
+
* input holds a raw `File`, not the displayed image URL — so the row can only
|
|
42
|
+
* reflect the change once the write returns the canonical value. Awaits the
|
|
43
|
+
* Lens update (sent multipart when a `File` is present) and patches the
|
|
44
|
+
* submitted columns from the response. Accounted for like an optimistic write
|
|
45
|
+
* (the query controls lock while it's in flight and a reconcile resyncs once it
|
|
46
|
+
* settles), so a concurrent refetch can't land stale rows over it. Patches the
|
|
47
|
+
* live displayed row (resolved by id) as well as the `record` passed at call
|
|
48
|
+
* time. Rejects if the write fails (the caller surfaces it and keeps the
|
|
49
|
+
* previous value).
|
|
50
|
+
*/
|
|
51
|
+
saveBlocking: (record: Record<string, any>, values: Record<string, any>) => Promise<void>
|
|
52
|
+
|
|
38
53
|
/**
|
|
39
54
|
* Create a new record. Blocking: resolves once the record is persisted and
|
|
40
55
|
* the catalog has been refreshed with the canonical state.
|
|
@@ -52,6 +67,13 @@ export interface LensEditContext {
|
|
|
52
67
|
|
|
53
68
|
/** Open the record sheet in create mode. */
|
|
54
69
|
openCreate: () => void
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Re-run the current query against the server, preserving the catalog's
|
|
73
|
+
* in-memory state. Used to reflect an external change once it has settled.
|
|
74
|
+
* No-ops while a write or create is in flight.
|
|
75
|
+
*/
|
|
76
|
+
refresh: () => Promise<void>
|
|
55
77
|
}
|
|
56
78
|
|
|
57
79
|
const LensEditKey: InjectionKey<LensEditContext> = Symbol('LensEdit')
|
|
@@ -3,6 +3,7 @@ import SAvatar from '../../../components/SAvatar.vue'
|
|
|
3
3
|
import { type TableCell } from '../../../composables/Table'
|
|
4
4
|
import { type AvatarFieldData } from '../FieldData'
|
|
5
5
|
import { type FilterOperator } from '../FilterOperator'
|
|
6
|
+
import LensAvatarInput from '../components/LensAvatarInput.vue'
|
|
6
7
|
import { type FilterInput } from '../filter-inputs/FilterInput'
|
|
7
8
|
import { Field } from './Field'
|
|
8
9
|
|
|
@@ -10,7 +11,7 @@ export class AvatarField extends Field<AvatarFieldData> {
|
|
|
10
11
|
override tableCell(v: any, r: any): TableCell {
|
|
11
12
|
// The display name lives on a sibling key of the record (resolved by the
|
|
12
13
|
// active language), not on the field value, which is the image URL.
|
|
13
|
-
const nameKey = this.
|
|
14
|
+
const nameKey = this.nameKey()
|
|
14
15
|
return {
|
|
15
16
|
type: 'avatar',
|
|
16
17
|
// The field value is the image URL itself. A missing value renders an
|
|
@@ -37,7 +38,54 @@ export class AvatarField extends Field<AvatarFieldData> {
|
|
|
37
38
|
})
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
// The avatar value is a raw `File` on edit (or null on removal), submitted
|
|
42
|
+
// through the Lens create/update write as a multipart part — so it stays
|
|
43
|
+
// submittable (the base default). On read it's the image URL, a different
|
|
44
|
+
// shape that can't be shown optimistically: the write must complete before the
|
|
45
|
+
// row can reflect the new URL. So editing routes through the dedicated avatar
|
|
46
|
+
// cell / sheet field (a blocking save), never the generic optimistic cell/field
|
|
47
|
+
// editors, which would patch the row with a raw `File` and crash the URL renderer.
|
|
48
|
+
override supportsOptimisticUpdate(): boolean {
|
|
49
|
+
return false
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
override inputEmptyValue(): any {
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
|
|
40
56
|
override formInputComponent(): any {
|
|
41
|
-
|
|
57
|
+
return this.defineFormInputComponent((props, { emit }) => {
|
|
58
|
+
return () => h(LensAvatarInput, {
|
|
59
|
+
'label': this.formInputLabel(),
|
|
60
|
+
'help': this.help() || undefined,
|
|
61
|
+
'accept': this.accept(),
|
|
62
|
+
'imageType': this.imageType(),
|
|
63
|
+
'modelValue': props.modelValue,
|
|
64
|
+
'validation': props.validation,
|
|
65
|
+
'onUpdate:modelValue': (value: any) => {
|
|
66
|
+
emit('update:modelValue', value)
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The `accept` attribute for the file picker. */
|
|
73
|
+
accept(): string {
|
|
74
|
+
return this.data.accept || 'image/*'
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The picker preview shape. */
|
|
78
|
+
imageType(): 'circle' | 'rectangle' {
|
|
79
|
+
return this.data.imageType || 'circle'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The companion name key resolved against the active language, if any. */
|
|
83
|
+
nameKey(): string | null {
|
|
84
|
+
return (this.ctx.lang === 'ja' ? this.data.nameJa : this.data.nameEn) ?? null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The configured English / Japanese display-name keys. */
|
|
88
|
+
nameKeys(): { en: string | null; ja: string | null } {
|
|
89
|
+
return { en: this.data.nameEn ?? null, ja: this.data.nameJa ?? null }
|
|
42
90
|
}
|
|
43
91
|
}
|
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
decimal,
|
|
10
10
|
decimalOrHyphen,
|
|
11
11
|
email,
|
|
12
|
+
fileExtension,
|
|
13
|
+
maxFileSize,
|
|
12
14
|
maxLength,
|
|
13
15
|
maxValue,
|
|
14
16
|
minLength,
|
|
@@ -126,6 +128,10 @@ function mapRule(rule: Exclude<Rule, EachRule>): ValidationRuleWithParams | null
|
|
|
126
128
|
return after(resolveDate(rule.date))
|
|
127
129
|
case 'after_or_equal':
|
|
128
130
|
return afterOrEqual(resolveDate(rule.date))
|
|
131
|
+
case 'file_extension':
|
|
132
|
+
return fileExtension(rule.extensions)
|
|
133
|
+
case 'max_file_size':
|
|
134
|
+
return maxFileSize(rule.size)
|
|
129
135
|
default: {
|
|
130
136
|
const _exhaustive: never = rule
|
|
131
137
|
throw new Error(`Unsupported rule type: ${(_exhaustive as Rule).type}`)
|
|
@@ -63,12 +63,20 @@ function openFileSelect() {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
function onFileSelect(e: Event) {
|
|
66
|
-
const
|
|
66
|
+
const input = e.target as HTMLInputElement
|
|
67
|
+
const file = (input.files ?? [])[0]
|
|
67
68
|
|
|
68
69
|
emit('update:model-value', file ?? null)
|
|
69
70
|
emit('change', file ?? null)
|
|
70
71
|
|
|
71
72
|
file && props.validation?.$touch()
|
|
73
|
+
|
|
74
|
+
// Clear the native input after capturing the file, so re-selecting the *same*
|
|
75
|
+
// file fires `change` again (the browser suppresses it when the value is
|
|
76
|
+
// unchanged). Matters when a consumer rejects/undoes a pick (e.g. a failed
|
|
77
|
+
// upload) and the user re-picks the same image. The preview reads the model,
|
|
78
|
+
// not the input, so clearing it is safe.
|
|
79
|
+
input.value = ''
|
|
72
80
|
}
|
|
73
81
|
|
|
74
82
|
function onFileDelete() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@globalbrain/sefirot",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.53.0",
|
|
4
4
|
"description": "Vue Components for Global Brain Design System.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"components",
|
|
@@ -59,8 +59,8 @@
|
|
|
59
59
|
"@iconify-json/ph": "^1.2.2",
|
|
60
60
|
"@iconify-json/ri": "^1.2.10",
|
|
61
61
|
"@popperjs/core": "^2.11.8",
|
|
62
|
-
"@sentry/browser": "^10.
|
|
63
|
-
"@sentry/vue": "^10.
|
|
62
|
+
"@sentry/browser": "^10.62.0",
|
|
63
|
+
"@sentry/vue": "^10.62.0",
|
|
64
64
|
"@tanstack/vue-virtual": "3.0.0-beta.62",
|
|
65
65
|
"@tinyhttp/content-disposition": "^2.2.4",
|
|
66
66
|
"@tinyhttp/cookie": "^2.1.1",
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
"@types/markdown-it": "^14.1.2",
|
|
72
72
|
"@types/qs": "^6.15.1",
|
|
73
73
|
"@vitejs/plugin-vue": "^6.0.7",
|
|
74
|
-
"@vue/reactivity": "^3.5.
|
|
74
|
+
"@vue/reactivity": "^3.5.39",
|
|
75
75
|
"@vuelidate/core": "^2.0.3",
|
|
76
76
|
"@vuelidate/validators": "^2.0.4",
|
|
77
77
|
"@vueuse/core": "^14.3.0",
|
|
@@ -91,11 +91,11 @@
|
|
|
91
91
|
"postcss": "^8.5.15",
|
|
92
92
|
"postcss-nested": "^7.0.2",
|
|
93
93
|
"punycode": "^2.3.1",
|
|
94
|
-
"qs": "^6.15.
|
|
94
|
+
"qs": "^6.15.3",
|
|
95
95
|
"unplugin-icons": "^23.0.1",
|
|
96
96
|
"v-calendar": "3.0.1",
|
|
97
|
-
"vite": "^7.3.
|
|
98
|
-
"vue": "^3.5.
|
|
97
|
+
"vite": "^7.3.6",
|
|
98
|
+
"vue": "^3.5.39",
|
|
99
99
|
"vue-draggable-plus": "^0.6.1",
|
|
100
100
|
"vue-router": "^5.1.0"
|
|
101
101
|
},
|
|
@@ -104,18 +104,18 @@
|
|
|
104
104
|
"@histoire/plugin-vue": "1.0.0-beta.1",
|
|
105
105
|
"@release-it/conventional-changelog": "^11.0.1",
|
|
106
106
|
"@types/jsdom": "^28.0.3",
|
|
107
|
-
"@types/node": "^26.0.
|
|
108
|
-
"@typescript-eslint/rule-tester": "^8.
|
|
107
|
+
"@types/node": "^26.0.1",
|
|
108
|
+
"@typescript-eslint/rule-tester": "^8.62.0",
|
|
109
109
|
"@vitest/coverage-v8": "^4.1.9",
|
|
110
110
|
"@vue/test-utils": "^2.4.11",
|
|
111
111
|
"eslint": "^9.39.4",
|
|
112
|
-
"happy-dom": "^20.10.
|
|
112
|
+
"happy-dom": "^20.10.6",
|
|
113
113
|
"histoire": "1.0.0-beta.1",
|
|
114
|
-
"release-it": "^20.2.
|
|
114
|
+
"release-it": "^20.2.1",
|
|
115
115
|
"typescript": "~6.0.3",
|
|
116
116
|
"vitepress": "^2.0.0-alpha.17",
|
|
117
117
|
"vitest": "^4.1.9",
|
|
118
118
|
"vue-tsc": "^3.3.5"
|
|
119
119
|
},
|
|
120
|
-
"packageManager": "pnpm@11.
|
|
120
|
+
"packageManager": "pnpm@11.9.0"
|
|
121
121
|
}
|