feat(19.6.1-05): persist picked relation labels across FormView tab remount

- FORM_REMEMBER_LABEL inject so hosts keep picked option names
- RelationField choose() writes the label before emit
- Vitest backstops remount and tab-switch labeling

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-10-22 14:40:35 +02:00
parent ba0501c274
commit 14baafb27c
6 changed files with 122 additions and 4 deletions

View File

@@ -1,10 +1,11 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { computed, inject, onBeforeUnmount, ref, watch } from 'vue'
import { ChevronDown, Lock, X } from '@lucide/vue'
import { api } from '../../../api/client'
import type { RelationOption } from '../../../api/types'
import { t } from '../../../app/i18n'
import type { FieldControlProps } from '../control'
import { FORM_REMEMBER_LABEL } from '../formContext'
// Relation field (D-17, D-18, D-26), three modes from the schema:
// readOnly the label from meta.labels only, no control
@@ -22,6 +23,7 @@ import type { FieldControlProps } from '../control'
// refuses the change in any case.
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: number | number[] | null] }>()
const rememberLabel = inject(FORM_REMEMBER_LABEL, undefined)
const PER_PAGE = 20
const SEARCH_DEBOUNCE = 300
@@ -226,6 +228,13 @@ function choose(entry: Entry): void {
if (entry.locked) {
return
}
if (entry.value !== null) {
rememberLabel?.(props.field.name, {
value: entry.value,
label: entry.label,
locked: entry.locked || undefined,
})
}
if (mode.value === 'multiple') {
if (entry.value !== null && !selectedIds.value.includes(entry.value)) {
emit('update:modelValue', [...selectedIds.value, entry.value])

View File

@@ -5,7 +5,7 @@
// provider (settings pages) gets the defaults of the injecting control.
import { ref, type InjectionKey, type Ref } from 'vue'
import type { FileRoutes } from '../../api/files'
import type { AdminDependentOptionsResult, AdminRecord, FormField, PermissionRecordLock } from '../../api/types'
import type { AdminDependentOptionsResult, AdminRecord, FormField, PermissionRecordLock, RelationOption } from '../../api/types'
/** Read-only view of the form's current values. */
export const FORM_VALUES: InjectionKey<Readonly<Ref<AdminRecord>>> = Symbol('summer.form.values')
@@ -171,5 +171,13 @@ export interface FieldScope {
export const FIELD_SCOPE: InjectionKey<FieldScope> = Symbol('summer.form.fieldScope')
/**
* Form hosts persist a picked relation option so a remounted RelationField
* can seed its id→label cache from props.labels; a host without a provider
* is a no-op.
*/
export const FORM_REMEMBER_LABEL: InjectionKey<(field: string, option: RelationOption) => void> =
Symbol('summer.form.rememberLabel')
/** Dependent options per field path and sibling value, for the life of a form view. */
export const DEPENDENT_CACHE: InjectionKey<Map<string, AdminDependentOptionsResult>> = Symbol('summer.form.dependentCache')

View File

@@ -11,7 +11,7 @@ import {
import { Trash2, X } from '@lucide/vue'
import { api } from '../../api/client'
import { childFileRoutes } from '../../api/files'
import type { AdminRecord, ControllerParams, FormField, RecordMeta, RelationSchema } from '../../api/types'
import type { AdminRecord, ControllerParams, FormField, RecordMeta, RelationOption, RelationSchema } from '../../api/types'
import { message, t } from '../../app/i18n'
import { CHILD_SESSION_HEADER, SESSION_HEADER, newSessionKey } from '../../app/sessionKey'
import FormErrorBanner from '../form/FormErrorBanner.vue'
@@ -20,6 +20,7 @@ import FormTabs from '../form/FormTabs.vue'
import {
FORM_ENABLED_LOCALES,
FORM_PATCH,
FORM_REMEMBER_LABEL,
FORM_SESSION,
FORM_TRIGGER_NOTES,
FORM_VALUES,
@@ -214,6 +215,19 @@ provide(FORM_ENABLED_LOCALES, enabledLocales)
// a child field of the same name.
provide(FORM_TRIGGER_NOTES, readonly(ref<Record<string, string>>({})))
function rememberRelationLabel(field: string, option: RelationOption): void {
const current = labels.value[field] ?? []
const index = current.findIndex((item) => item.value === option.value)
const next = [...current]
if (index >= 0) {
next[index] = option
} else {
next.push(option)
}
labels.value = { ...labels.value, [field]: next }
}
provide(FORM_REMEMBER_LABEL, rememberRelationLabel)
function update(name: string, value: unknown): void {
values.value = { ...values.value, [name]: value }
if (errors.value[name]) {

View File

@@ -4,7 +4,7 @@ import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vu
import { ArrowLeft, Trash2 } from '@lucide/vue'
import { api } from '../api/client'
import { parentFileRoutes } from '../api/files'
import type { AdminRecord, ErrorEnvelope, FormView, RecordEnvelope, RecordMeta } from '../api/types'
import type { AdminRecord, ErrorEnvelope, FormView, RecordEnvelope, RecordMeta, RelationOption } from '../api/types'
import { controllerIdFromParams, controllerPath } from '../app/controllerRoutes'
import { currentLocale, message, t } from '../app/i18n'
import { loadControllerAssets } from '../app/pluginAssets'
@@ -21,6 +21,7 @@ import {
FORM_LOCALE,
FORM_PATCH,
FORM_PERMISSION_LOCKS,
FORM_REMEMBER_LABEL,
FORM_SESSION,
FORM_TRIGGER_NOTES,
FORM_VALUES,
@@ -247,6 +248,18 @@ function adopt(record: RecordEnvelope | undefined): void {
saved.value = snapshot(editablePayload(fields.value, values.value, mode, permissionLocks.value))
}
function rememberRelationLabel(field: string, option: RelationOption): void {
const current = labels.value[field] ?? []
const index = current.findIndex((item) => item.value === option.value)
const next = [...current]
if (index >= 0) {
next[index] = option
} else {
next.push(option)
}
labels.value = { ...labels.value, [field]: next }
}
async function load(): Promise<void> {
loading.value = true
failed.value = false
@@ -336,6 +349,7 @@ function update(name: string, value: unknown): void {
// that field's errors like an edit (D-07, D-08).
provide(FORM_VALUES, computed(() => values.value))
provide(FORM_PATCH, update)
provide(FORM_REMEMBER_LABEL, rememberRelationLabel)
provide(FORM_LOCALE, computed(() => schema.value?.meta.locale || currentLocale.value))
provide(FORM_ENABLED_LOCALES, computed(() => schemaEnabledLocales(schema.value?.meta)))
provide(FORM_ASSETS, () => assetsReady)