feat(12.1-02): password and form-only fields, rules per operation and preset
- pact.FormVirtualFields lists form fields that are not model columns: never bound, filled or projected; their values reach the Form hooks through cabana.VirtualFieldsFromContext when the field's context allows the operation - type: password is a masked field that must be listed as virtual - pact.FormRules supplies the rule set per operation and replaces the model's Rules() for admin saves; a rule on a virtual field sees the submitted value - preset on a text field follows another text field on the create form - SPA: PasswordField, preset handling in FormView, empty password left out of an update - README, docs, OpenAPI document, TS types and dist updated
This commit is contained in:
4
admin/tests/fixtures/roster.form-schema.json
vendored
4
admin/tests/fixtures/roster.form-schema.json
vendored
@@ -5,6 +5,10 @@
|
||||
"fields": [
|
||||
{ "name": "name", "type": "text", "label": "Name", "span": "left" },
|
||||
{ "name": "email", "type": "text", "label": "Email", "span": "right" },
|
||||
{ "name": "slug", "type": "text", "label": "Slug", "preset": { "field": "name", "type": "slug" } },
|
||||
{ "name": "password", "type": "password", "label": "Password", "span": "left", "context": ["create", "update"] },
|
||||
{ "name": "password_confirmation", "type": "password", "label": "Repeat the password", "span": "right", "context": ["create", "update"] },
|
||||
{ "name": "notify", "type": "checkbox", "label": "Send a welcome message", "default": true, "context": "create" },
|
||||
{ "name": "joined_ip", "type": "text", "label": "Joined from IP address", "context": "preview" }
|
||||
],
|
||||
"preview": { "headerPartial": "status" },
|
||||
|
||||
2
admin/tests/fixtures/roster.record.json
vendored
2
admin/tests/fixtures/roster.record.json
vendored
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"data": { "id": 1, "name": "Ada Lovelace", "email": "ada@example.test", "joined_ip": "203.0.113.7" },
|
||||
"data": { "id": 1, "name": "Ada Lovelace", "email": "ada@example.test", "slug": "ada-lovelace", "joined_ip": "203.0.113.7" },
|
||||
"meta": {
|
||||
"labels": {},
|
||||
"actions": [
|
||||
|
||||
@@ -102,7 +102,7 @@ describe('preview screen (UI-SPEC S3, D-11)', () => {
|
||||
|
||||
const grid = wrapper.find('[data-preview] dl')
|
||||
expect(grid.exists()).toBe(true)
|
||||
expect(grid.findAll('dt').map((item) => item.text())).toEqual(['Name', 'Email', 'Joined from IP address'])
|
||||
expect(grid.findAll('dt').map((item) => item.text())).toEqual(['Name', 'Email', 'Slug', 'Joined from IP address'])
|
||||
expect(value(wrapper, 'name').text()).toBe('Ada Lovelace')
|
||||
expect(value(wrapper, 'email').text()).toBe('ada@example.test')
|
||||
// Values wrap and are never truncated; labels carry no required mark.
|
||||
|
||||
204
admin/tests/smoke/seams.smoke.test.ts
Normal file
204
admin/tests/smoke/seams.smoke.test.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
// Phase 12.1 form seams in the admin SPA: the password field (UI-SPEC S7,
|
||||
// D-19) and preset fields (D-27 G7). Fixtures are neutral acme.roster.* data;
|
||||
// no application names appear in framework tests.
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { editablePayload, presetValue } from '../../src/components/form/formState'
|
||||
import { setBundle } from '../../src/app/i18n'
|
||||
import { clone, langFixture, rosterFormSchemaFixture, rosterRecordFixture } from '../fixtures/typed'
|
||||
import { API, mountApp, requestsTo, resetState, type Reply, type Route } from '../helpers'
|
||||
|
||||
const LIST = `${API}/acme/roster/people`
|
||||
const RECORD = `${LIST}/1`
|
||||
|
||||
// The framework strings these controls use, as GET /lang serves them in en.
|
||||
const strings = {
|
||||
'backend::lang.form.show_password': { other: 'Show password' },
|
||||
'backend::lang.form.hide_password': { other: 'Hide password' },
|
||||
}
|
||||
|
||||
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
return {
|
||||
[`GET ${LIST}/schema/form`]: { body: rosterFormSchemaFixture },
|
||||
[`GET ${RECORD}`]: { body: rosterRecordFixture },
|
||||
[`GET ${LIST}/partials/status`]: { body: { data: { nodes: [] }, meta: {} } },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const input = (wrapper: VueWrapper, name: string) => wrapper.find<HTMLInputElement>(`#field-${name}`)
|
||||
const toggle = (wrapper: VueWrapper, name: string) => wrapper.find(`[data-field="${name}"] [data-password-toggle]`)
|
||||
|
||||
async function save(wrapper: VueWrapper): Promise<void> {
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
setBundle({ ...langFixture.data, ...strings }, 'en')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('password field (UI-SPEC S7, D-19)', () => {
|
||||
it('renders empty and masked although the record is loaded, with the toggle after the input', async () => {
|
||||
// The record response carries no password, as on the server.
|
||||
const { wrapper } = await mountApp('/acme/roster/people/1', routes())
|
||||
const password = input(wrapper, 'password')
|
||||
expect(password.element.value).toBe('')
|
||||
expect(password.attributes('type')).toBe('password')
|
||||
expect(password.attributes('autocomplete')).toBe('new-password')
|
||||
expect(password.attributes('spellcheck')).toBe('false')
|
||||
expect(password.classes()).toEqual(expect.arrayContaining(['h-input', 'pr-12']))
|
||||
expect(input(wrapper, 'password_confirmation').element.value).toBe('')
|
||||
|
||||
const button = toggle(wrapper, 'password')
|
||||
expect(button.attributes('type')).toBe('button')
|
||||
expect(button.attributes('aria-pressed')).toBe('false')
|
||||
expect(button.attributes('aria-label')).toBe('Show password')
|
||||
// The toggle follows the input in the DOM, so Tab reaches it second.
|
||||
expect(password.element.compareDocumentPosition(button.element) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows and hides the text with the toggle', async () => {
|
||||
const { wrapper } = await mountApp('/acme/roster/people/1', routes())
|
||||
await input(wrapper, 'password').setValue('correct horse')
|
||||
await toggle(wrapper, 'password').trigger('click')
|
||||
expect(input(wrapper, 'password').attributes('type')).toBe('text')
|
||||
expect(input(wrapper, 'password').element.value).toBe('correct horse')
|
||||
expect(toggle(wrapper, 'password').attributes('aria-pressed')).toBe('true')
|
||||
expect(toggle(wrapper, 'password').attributes('aria-label')).toBe('Hide password')
|
||||
// The confirmation has its own toggle and stays masked.
|
||||
expect(input(wrapper, 'password_confirmation').attributes('type')).toBe('password')
|
||||
await toggle(wrapper, 'password').trigger('click')
|
||||
expect(input(wrapper, 'password').attributes('type')).toBe('password')
|
||||
})
|
||||
|
||||
it('leaves an empty password out of the update body', async () => {
|
||||
const { wrapper, calls } = await mountApp('/acme/roster/people/1', routes({ [`PUT ${RECORD}`]: { body: rosterRecordFixture } }))
|
||||
await input(wrapper, 'name').setValue('Ada B')
|
||||
// Typed and removed again: still "unchanged".
|
||||
await input(wrapper, 'password').setValue('x')
|
||||
await input(wrapper, 'password').setValue('')
|
||||
await save(wrapper)
|
||||
|
||||
const [request] = requestsTo(calls, 'PUT', RECORD)
|
||||
const body = (await request!.clone().json()) as Record<string, unknown>
|
||||
expect(body.name).toBe('Ada B')
|
||||
expect('password' in body).toBe(false)
|
||||
expect('password_confirmation' in body).toBe(false)
|
||||
// notify is a create-only field and joined_ip a preview-only one.
|
||||
expect('notify' in body).toBe(false)
|
||||
expect('joined_ip' in body).toBe(false)
|
||||
})
|
||||
|
||||
it('sends the password pair as entered and clears and hides both after a successful save', async () => {
|
||||
const { wrapper, calls } = await mountApp('/acme/roster/people/1', routes({ [`PUT ${RECORD}`]: { body: rosterRecordFixture } }))
|
||||
await input(wrapper, 'password').setValue('correct horse')
|
||||
await input(wrapper, 'password_confirmation').setValue('correct horse')
|
||||
await toggle(wrapper, 'password').trigger('click')
|
||||
await toggle(wrapper, 'password_confirmation').trigger('click')
|
||||
await save(wrapper)
|
||||
|
||||
const [request] = requestsTo(calls, 'PUT', RECORD)
|
||||
const body = (await request!.clone().json()) as Record<string, unknown>
|
||||
expect(body.password).toBe('correct horse')
|
||||
expect(body.password_confirmation).toBe('correct horse')
|
||||
for (const name of ['password', 'password_confirmation']) {
|
||||
expect(input(wrapper, name).element.value).toBe('')
|
||||
expect(input(wrapper, name).attributes('type')).toBe('password')
|
||||
expect(toggle(wrapper, name).attributes('aria-pressed')).toBe('false')
|
||||
}
|
||||
// The password never reaches a toast or the URL.
|
||||
expect(document.body.textContent).not.toContain('correct horse')
|
||||
expect(window.location.href).not.toContain('correct')
|
||||
})
|
||||
|
||||
it('sends a lone password as entered and shows the server 422 on the field', async () => {
|
||||
const refused: Reply = {
|
||||
status: 422,
|
||||
body: {
|
||||
error: {
|
||||
code: 'validation_failed',
|
||||
message: 'Validation failed',
|
||||
details: { password: ['The password confirmation does not match.'] },
|
||||
},
|
||||
},
|
||||
}
|
||||
const { wrapper, calls } = await mountApp('/acme/roster/people/1', routes({ [`PUT ${RECORD}`]: refused }), { attach: true })
|
||||
await input(wrapper, 'password').setValue('correct horse')
|
||||
await save(wrapper)
|
||||
|
||||
const [request] = requestsTo(calls, 'PUT', RECORD)
|
||||
const body = (await request!.clone().json()) as Record<string, unknown>
|
||||
// The SPA does not compare the two fields: the save goes out as entered.
|
||||
expect(body.password).toBe('correct horse')
|
||||
expect('password_confirmation' in body).toBe(false)
|
||||
expect(wrapper.find('[data-field="password"]').text()).toContain('The password confirmation does not match.')
|
||||
expect(input(wrapper, 'password').attributes('aria-invalid')).toBe('true')
|
||||
// A refused save keeps what was typed.
|
||||
expect(input(wrapper, 'password').element.value).toBe('correct horse')
|
||||
})
|
||||
|
||||
it('keeps an empty password in a create body and drops it only on update', () => {
|
||||
const fields = rosterFormSchemaFixture.data.fields.filter((field) => field.type === 'password')
|
||||
expect(editablePayload(fields, { password: '' }, 'create')).toEqual({ password: '' })
|
||||
expect(editablePayload(fields, { password: '' }, 'update')).toEqual({})
|
||||
expect(editablePayload(fields, { password: 'abc' }, 'update')).toEqual({ password: 'abc' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('preset (D-27 G7)', () => {
|
||||
it('slugs lower-case ASCII with single hyphens and no cut', () => {
|
||||
expect(presetValue('slug', 'Ada Lovelace')).toBe('ada-lovelace')
|
||||
expect(presetValue('slug', ' Hello, World!! ')).toBe('hello-world')
|
||||
expect(presetValue('slug', 'Zażółć 42')).toBe('za-42')
|
||||
expect(presetValue('slug', '')).toBe('')
|
||||
expect(presetValue('exact', 'Ada Lovelace')).toBe('Ada Lovelace')
|
||||
const long = 'word '.repeat(80).trim()
|
||||
expect(presetValue('slug', long)).toHaveLength(long.length)
|
||||
})
|
||||
|
||||
it('fills the target from the source on create until the target is edited by hand', async () => {
|
||||
const { wrapper } = await mountApp('/acme/roster/people/create', routes())
|
||||
expect(input(wrapper, 'slug').element.value).toBe('')
|
||||
await input(wrapper, 'name').setValue('Grace Hopper')
|
||||
expect(input(wrapper, 'slug').element.value).toBe('grace-hopper')
|
||||
await input(wrapper, 'name').setValue('Grace B. Hopper')
|
||||
expect(input(wrapper, 'slug').element.value).toBe('grace-b-hopper')
|
||||
// An empty source leaves the target empty.
|
||||
await input(wrapper, 'name').setValue('')
|
||||
expect(input(wrapper, 'slug').element.value).toBe('')
|
||||
|
||||
// The first manual edit stops it for the session.
|
||||
await input(wrapper, 'slug').setValue('admiral')
|
||||
await input(wrapper, 'name').setValue('Grace Hopper')
|
||||
expect(input(wrapper, 'slug').element.value).toBe('admiral')
|
||||
// The target is an ordinary input with no marker.
|
||||
expect(input(wrapper, 'slug').attributes('readonly')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('sends the preset value with the create body', async () => {
|
||||
const created = clone(rosterRecordFixture)
|
||||
created.data.id = 7
|
||||
const { wrapper, calls } = await mountApp('/acme/roster/people/create', routes({ [`POST ${LIST}`]: { status: 201, body: created }, [`GET ${LIST}/7`]: { body: created } }))
|
||||
await input(wrapper, 'name').setValue('Grace Hopper')
|
||||
await save(wrapper)
|
||||
const [request] = requestsTo(calls, 'POST', LIST)
|
||||
const body = (await request!.clone().json()) as Record<string, unknown>
|
||||
expect(body.slug).toBe('grace-hopper')
|
||||
expect(body.notify).toBe(true)
|
||||
})
|
||||
|
||||
it('does not follow the source on update', async () => {
|
||||
const { wrapper } = await mountApp('/acme/roster/people/1', routes())
|
||||
expect(input(wrapper, 'slug').element.value).toBe('ada-lovelace')
|
||||
await input(wrapper, 'name').setValue('Ada King')
|
||||
expect(input(wrapper, 'slug').element.value).toBe('ada-lovelace')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user