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:
@@ -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])
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
langFixture,
|
||||
listFixture,
|
||||
listSchemaFixture,
|
||||
optionsFixture,
|
||||
recordFixture,
|
||||
rosterFormSchemaFixture,
|
||||
rosterRecordFixture,
|
||||
@@ -931,3 +932,31 @@ describe('field triggers on the form (D-21, UI-SPEC C6 backstops)', () => {
|
||||
expect(banner.text()).toContain('Overrides are refused.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('picked relation labels across tab remount', () => {
|
||||
type Mounted = Awaited<ReturnType<typeof mountApp>>['wrapper']
|
||||
|
||||
async function tab(wrapper: Mounted, key: string): Promise<void> {
|
||||
await wrapper.find(`[role="tab"][data-tab="${key}"]`).trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
it('keeps a picked relation label after switching tabs', async () => {
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/demo/widgets/1',
|
||||
routes({ [`GET ${BASE}/fields/maker/options`]: { body: optionsFixture.makerField } }),
|
||||
)
|
||||
const maker = () => wrapper.find('[data-field="maker"] [role="combobox"]')
|
||||
expect((maker().element as HTMLInputElement).value).toBe('Bolt & Co')
|
||||
|
||||
await maker().trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.find('[data-field="maker"] [role="option"][data-value="1"]').trigger('mousedown')
|
||||
await flushPromises()
|
||||
|
||||
await tab(wrapper, 'tab:Details')
|
||||
await tab(wrapper, 'default')
|
||||
expect((maker().element as HTMLInputElement).value).toBe('Acme Works')
|
||||
expect((maker().element as HTMLInputElement).value).not.toBe('1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { setBundle } from '../../src/app/i18n'
|
||||
import type { RelationOption } from '../../src/api/types'
|
||||
import RelationField from '../../src/components/form/fields/RelationField.vue'
|
||||
import { FORM_REMEMBER_LABEL } from '../../src/components/form/formContext'
|
||||
import { formField, langFixture, optionsFixture, recordFixture } from '../fixtures/typed'
|
||||
import { API, keydown, mockApi, queryOf, requestsTo, resetState, wait, type Route } from '../helpers'
|
||||
|
||||
@@ -243,6 +245,48 @@ describe('multiple relation (D-18)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('remembered relation labels across remount', () => {
|
||||
it('shows a picked label after remount when the form remembered it', async () => {
|
||||
mockApi(routes())
|
||||
const previous: RelationOption[] = [{ value: 2, label: 'Bolt & Co' }]
|
||||
const bag: RelationOption[] = []
|
||||
const wrapper = mount(RelationField, {
|
||||
props: {
|
||||
field: formField('maker'),
|
||||
modelValue: 2,
|
||||
controlId: 'r-maker',
|
||||
labels: previous,
|
||||
source,
|
||||
},
|
||||
global: {
|
||||
provide: {
|
||||
[FORM_REMEMBER_LABEL as symbol]: (_field: string, option: RelationOption) => {
|
||||
bag.push(option)
|
||||
},
|
||||
},
|
||||
},
|
||||
attachTo: document.body,
|
||||
})
|
||||
await openList(wrapper)
|
||||
await wrapper.find('[role="option"][data-value="1"]').trigger('mousedown')
|
||||
expect(bag).toEqual([expect.objectContaining({ value: 1, label: 'Acme Works' })])
|
||||
wrapper.unmount()
|
||||
|
||||
const remounted = mount(RelationField, {
|
||||
props: {
|
||||
field: formField('maker'),
|
||||
modelValue: 1,
|
||||
controlId: 'r-maker',
|
||||
labels: [...previous, ...bag],
|
||||
source,
|
||||
},
|
||||
attachTo: document.body,
|
||||
})
|
||||
expect((combobox(remounted).element as HTMLInputElement).value).toBe('Acme Works')
|
||||
expect((combobox(remounted).element as HTMLInputElement).value).not.toBe('1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('locked relation options (UI-SPEC S6, D-07)', () => {
|
||||
const NOTE = 'Items marked with a lock need an additional permission to change.'
|
||||
const strings = {
|
||||
|
||||
Reference in New Issue
Block a user