feat(12.1-02): permissioneditor field in radio or checkbox mode

- type: permissioneditor with mode radio (1, -1) or checkbox (1); the
  controller serves the options per request through
  cabana.PermissionEditorProvider and reads and stores the values
- a save answers 422 for a non-object, an unknown code or a value outside the
  mode's set and 403 for a changed locked code; stored codes that are not
  offered are kept
- record responses carry the stored permissions as an object
- SPA: PermissionEditorField with sections by tab, locked rows and a read-only
  mode for the preview
- README, docs, OpenAPI document, TS types and dist updated
This commit is contained in:
Jakub Zych
2026-10-05 10:44:50 +02:00
parent a1c6bb1ce6
commit f50d9b8f10
38 changed files with 1300 additions and 34 deletions

View File

@@ -765,7 +765,7 @@
"type": "string"
},
"mode": {
"description": "Mode is the fileupload mode (image or file, default file) or the\ndatepicker mode (date, datetime or time, default datetime).",
"description": "Mode is the fileupload mode (image or file, default file), the\ndatepicker mode (date, datetime or time, default datetime) or the\npermissioneditor mode (radio or checkbox).",
"type": "string"
},
"multiple": {
@@ -787,6 +787,13 @@
"description": "Path names the controller partial of a `type: partial` field: the\ntemplate {ConfigDir}/_{path}.htm (D-09).",
"type": "string"
},
"permissionOptions": {
"description": "PermissionOptions are the permissions a `type: permissioneditor` field\noffers the requesting administrator, in display order. They are filled\nper request by the form schema route.",
"items": {
"$ref": "#/components/schemas/cabana.PermissionOption"
},
"type": "array"
},
"preset": {
"allOf": [
{
@@ -1454,6 +1461,30 @@
],
"type": "object"
},
"cabana.PermissionOption": {
"properties": {
"code": {
"type": "string"
},
"comment": {
"type": "string"
},
"label": {
"type": "string"
},
"locked": {
"type": "boolean"
},
"tab": {
"type": "string"
}
},
"required": [
"code",
"label"
],
"type": "object"
},
"cabana.RecordAction": {
"properties": {
"confirm": {
@@ -3314,7 +3345,7 @@
},
"/{vendor}/{plugin}/{controller}/schema/form": {
"get": {
"description": "The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text field names the field it follows on the create form (type slug or exact) until the administrator edits it.",
"description": "The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text field names the field it follows on the create form (type slug or exact) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1).",
"parameters": [
{
"description": "Vendor",

View File

@@ -1216,7 +1216,7 @@ export interface paths {
};
/**
* Admin form schema
* @description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text field names the field it follows on the create form (type slug or exact) until the administrator edits it.
* @description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text field names the field it follows on the create form (type slug or exact) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1).
*/
get: {
parameters: {
@@ -4434,8 +4434,9 @@ export interface components {
*/
minDate?: string;
/**
* @description Mode is the fileupload mode (image or file, default file) or the
* datepicker mode (date, datetime or time, default datetime).
* @description Mode is the fileupload mode (image or file, default file), the
* datepicker mode (date, datetime or time, default datetime) or the
* permissioneditor mode (radio or checkbox).
*/
mode?: string;
multiple?: boolean;
@@ -4447,6 +4448,12 @@ export interface components {
* template {ConfigDir}/_{path}.htm (D-09).
*/
path?: string;
/**
* @description PermissionOptions are the permissions a `type: permissioneditor` field
* offers the requesting administrator, in display order. They are filled
* per request by the form schema route.
*/
permissionOptions?: components["schemas"]["cabana.PermissionOption"][];
/**
* @description Preset makes a text field follow another field of the form while the
* administrator has not edited it, on create only (fields.yaml preset).
@@ -4652,6 +4659,13 @@ export interface components {
"cabana.PartialView": {
nodes: components["schemas"]["cabana.PartialNode"][];
};
"cabana.PermissionOption": {
code: string;
comment?: string;
label: string;
locked?: boolean;
tab?: string;
};
"cabana.RecordAction": {
confirm?: string;
label: string;

View File

@@ -27,6 +27,7 @@ export type FormRedirects = Schemas['cabana.FormRedirects']
/** A form's preview screen: present when config_form.yaml declares `preview:`. */
export type FormPreview = Schemas['cabana.FormPreview']
export type FieldPreset = Schemas['cabana.FieldPreset']
export type PermissionOption = Schemas['cabana.PermissionOption']
export type RelationSchema = Schemas['cabana.RelationSchema']
export type RelationMessages = Schemas['cabana.RelationMessages']
export type RelationMutationResult = Schemas['cabana.RelationMutationResult']

View File

@@ -67,3 +67,24 @@ export function toggleOn(value: unknown): boolean {
export function toggleValue(current: unknown, on: boolean): boolean | number {
return typeof current === 'number' ? (on ? 1 : 0) : on
}
/**
* A permissioneditor value as it may be sent (UI-SPEC S5): only codes the
* field's options offer, with a value of the mode's set. Radio keeps 1
* (allow) and -1 (deny) and leaves an inherited code out; checkbox keeps 1.
* A stored code that is not offered is never sent: the server keeps it.
*/
export function permissionValues(field: FormField, value: unknown): Record<string, number> {
const out: Record<string, number> = {}
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return out
}
const stored = value as Record<string, unknown>
for (const option of field.permissionOptions ?? []) {
const n = Number(stored[option.code])
if (n === 1 || (field.mode === 'radio' && n === -1)) {
out[option.code] = n
}
}
return out
}

View File

@@ -0,0 +1,218 @@
<script setup lang="ts">
import { computed } from 'vue'
import { CheckboxIndicator, CheckboxRoot, RadioGroupItem, RadioGroupRoot } from 'reka-ui'
import { Check, Lock } from '@lucide/vue'
import type { PermissionOption } from '../../../api/types'
import { t } from '../../../app/i18n'
import { controlAttributes, permissionValues, type FieldControlProps } from '../control'
// Permission editor (UI-SPEC S5, D-16). The options come with the form
// schema, already filtered and marked for the signed-in administrator. They
// are grouped by `tab` into sections of one list; there is no inner tablist.
// Radio mode edits allow (1), inherit (no value) and deny (-1) per
// permission; checkbox mode edits allow (1). The value is an object of code
// to integer holding only offered codes: a stored code that is not offered is
// kept by the server and never sent. A locked row shows its stored value and
// cannot change; the server refuses a changed locked code anyway.
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: Record<string, number>] }>()
interface Section {
key: string
label: string
options: PermissionOption[]
}
const options = computed<PermissionOption[]>(() => props.field.permissionOptions ?? [])
const radio = computed(() => props.field.mode === 'radio')
const readOnly = computed(() => !!props.field.readOnly || !!controlAttributes(props.field).readonly)
const current = computed(() => permissionValues(props.field, props.modelValue))
// Sections keep the order the server sends; options without a tab form a
// last section.
const sections = computed<Section[]>(() => {
const out: Section[] = []
const untabbed: PermissionOption[] = []
for (const option of options.value) {
if (!option.tab) {
untabbed.push(option)
continue
}
let section = out.find((item) => item.label === option.tab)
if (!section) {
section = { key: `tab:${option.tab}`, label: option.tab, options: [] }
out.push(section)
}
section.options.push(option)
}
if (untabbed.length > 0) {
out.push({ key: 'other', label: t('backend::lang.permissioneditor.other'), options: untabbed })
}
return out
})
const segments = [
{
value: '1',
label: 'backend::lang.permissioneditor.allow',
checked: 'data-[state=checked]:bg-ok-bg data-[state=checked]:text-ok-text',
},
{
value: '0',
label: 'backend::lang.permissioneditor.inherit',
checked: 'data-[state=checked]:bg-surface data-[state=checked]:text-text data-[state=checked]:shadow-tab',
},
{
value: '-1',
label: 'backend::lang.permissioneditor.deny',
checked: 'data-[state=checked]:bg-danger-soft data-[state=checked]:text-danger',
},
]
const labelledBy = computed(() => `${props.controlId}-label`)
function rowId(sectionIndex: number, optionIndex: number): string {
return `${props.controlId}-${sectionIndex}-${optionIndex}`
}
function disabled(option: PermissionOption): boolean {
return readOnly.value || !!option.locked
}
/** Whether the row shows the lock icon and text: never on a read-only screen. */
function showsLock(option: PermissionOption): boolean {
return !!option.locked && !readOnly.value
}
function valueOf(option: PermissionOption): number {
return current.value[option.code] ?? 0
}
function set(option: PermissionOption, value: number): void {
if (disabled(option)) {
return
}
const next = { ...current.value }
if (value === 1 || (radio.value && value === -1)) {
next[option.code] = value
} else {
delete next[option.code]
}
emit('update:modelValue', next)
}
</script>
<template>
<div
v-if="options.length === 0"
:id="controlId"
data-permission-empty
class="flex min-h-input items-center rounded-control border border-border bg-subtle px-3.5 text-muted"
>
{{ t('backend::lang.permissioneditor.empty') }}
</div>
<div
v-else
:id="controlId"
role="group"
:aria-labelledby="field.label ? labelledBy : undefined"
:aria-label="field.label ? undefined : field.name"
:aria-describedby="describedBy || undefined"
:class="invalid ? 'border-danger' : 'border-border'"
class="overflow-hidden rounded-inner border"
data-permission-editor
:data-mode="radio ? 'radio' : 'checkbox'"
>
<div
v-for="(section, sectionIndex) in sections"
:key="section.key"
role="group"
:aria-labelledby="`${controlId}-section-${sectionIndex}`"
data-permission-section
>
<h3
:id="`${controlId}-section-${sectionIndex}`"
:class="sectionIndex > 0 ? 'border-t border-border' : ''"
class="flex h-row-head items-center justify-between gap-4 bg-subtle px-4 font-semibold"
>
<span class="min-w-0 [overflow-wrap:anywhere]">{{ section.label }}</span>
<span v-if="!radio" class="shrink-0 text-[12px] font-semibold text-muted" aria-hidden="true">
{{ t('backend::lang.permissioneditor.allow') }}
</span>
</h3>
<div
v-for="(option, optionIndex) in section.options"
:key="option.code"
:data-permission="option.code"
:data-locked="showsLock(option) ? '' : undefined"
class="flex min-h-[56px] items-center justify-between gap-x-4 gap-y-2 border-t border-border px-4 py-3 max-sm:flex-col max-sm:items-start"
>
<div class="flex min-w-0 flex-col">
<component
:is="radio ? 'span' : 'label'"
:id="`${rowId(sectionIndex, optionIndex)}-label`"
:for="radio ? undefined : rowId(sectionIndex, optionIndex)"
class="[overflow-wrap:anywhere]"
>
{{ option.label || option.code }}
<Lock v-if="showsLock(option)" :size="14" class="ml-1 inline-block align-[-2px] text-muted" aria-hidden="true" />
</component>
<p
v-if="showsLock(option)"
:id="`${rowId(sectionIndex, optionIndex)}-note`"
class="text-[13px] text-muted [overflow-wrap:anywhere]"
data-permission-locked
>
{{ t('backend::lang.permissioneditor.locked') }}
</p>
<p
v-else-if="option.comment"
:id="`${rowId(sectionIndex, optionIndex)}-note`"
class="text-[13px] text-muted [overflow-wrap:anywhere]"
>
{{ option.comment }}
</p>
</div>
<RadioGroupRoot
v-if="radio"
orientation="horizontal"
:model-value="String(valueOf(option))"
:disabled="disabled(option)"
:aria-disabled="disabled(option) ? 'true' : undefined"
:aria-labelledby="`${rowId(sectionIndex, optionIndex)}-label`"
:aria-describedby="showsLock(option) || option.comment ? `${rowId(sectionIndex, optionIndex)}-note` : undefined"
class="inline-flex shrink-0 gap-1 rounded-inner border border-border bg-subtle p-1"
@update:model-value="(value) => set(option, Number(value))"
>
<RadioGroupItem
v-for="segment in segments"
:key="segment.value"
:value="segment.value"
:data-segment="segment.value"
:class="segment.checked"
class="inline-flex h-pager items-center rounded-tab px-4 text-[13px] whitespace-nowrap text-muted transition-colors duration-150 ease-out hover:text-text disabled:cursor-not-allowed disabled:hover:text-muted data-[state=checked]:text-[14px] data-[state=checked]:font-semibold"
>
{{ t(segment.label) }}
</RadioGroupItem>
</RadioGroupRoot>
<CheckboxRoot
v-else
:id="rowId(sectionIndex, optionIndex)"
:model-value="valueOf(option) === 1"
:disabled="disabled(option)"
:aria-disabled="disabled(option) ? 'true' : undefined"
:aria-labelledby="`${rowId(sectionIndex, optionIndex)}-label`"
:aria-describedby="showsLock(option) || option.comment ? `${rowId(sectionIndex, optionIndex)}-note` : undefined"
class="flex size-[18px] shrink-0 items-center justify-center rounded-checkbox border border-border-strong bg-surface disabled:cursor-not-allowed disabled:opacity-60 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-on-primary"
@update:model-value="(value) => set(option, value === true ? 1 : 0)"
>
<CheckboxIndicator>
<Check :size="14" aria-hidden="true" />
</CheckboxIndicator>
</CheckboxRoot>
</div>
</div>
</div>
</template>

View File

@@ -1,6 +1,7 @@
// Helpers shared by the record form and the settings form: context
// filtering, the save payload, 422 detail mapping and focus.
import type { AdminRecord, ErrorBody, FormField } from '../../api/types'
import { permissionValues } from './control'
import { isRegistered } from './registry'
/** The screen a field is filtered for: the two form modes and the read-only preview (D-11). */
@@ -56,6 +57,11 @@ export function editablePayload(fields: FormField[], values: AdminRecord, mode:
if (field.type === 'password' && mode === 'update' && (value === '' || value === null)) {
continue
}
if (field.type === 'permissioneditor') {
// Sent whenever it is shown, reduced to offered codes and values.
out[field.name] = permissionValues(field, value)
continue
}
if (value !== undefined) {
out[field.name] = value
}

View File

@@ -11,7 +11,9 @@
// value either and renders on create and update. The datepicker control is
// a plain value field: its string value is part of the save body (D-18).
// Phase 12.1 adds the password control: a plain value field whose value the
// server never sends back, so it is empty on load and after every save.
// server never sends back, so it is empty on load and after every save. The
// permissioneditor control is a value field too: an object of permission code
// to value, labelled as a group.
import type { Component } from 'vue'
import CheckboxField from './fields/CheckboxField.vue'
import DatepickerField from './fields/DatepickerField.vue'
@@ -20,6 +22,7 @@ import FileuploadField from './fields/FileuploadField.vue'
import NumberField from './fields/NumberField.vue'
import PartialField from './fields/PartialField.vue'
import PasswordField from './fields/PasswordField.vue'
import PermissionEditorField from './fields/PermissionEditorField.vue'
import RelationManager from '../relation/RelationManager.vue'
import RelationField from './fields/RelationField.vue'
import SwitchField from './fields/SwitchField.vue'
@@ -56,6 +59,7 @@ const renderers = new Map<string, Component>([
['fileupload', FileuploadField],
['datepicker', DatepickerField],
['password', PasswordField],
['permissioneditor', PermissionEditorField],
])
/** Types whose control shows the label itself (toggle cards, relation manager). */
@@ -75,7 +79,7 @@ const valueless = new Set<string>([RELATION_MANAGER, 'widget', 'partial', 'fileu
* Types whose control is a group rather than one focusable element: the
* visible label is a span the group points at, not a label for an input.
*/
const groupLabelledTypes = new Set<string>(['widget', 'partial', 'fileupload'])
const groupLabelledTypes = new Set<string>(['widget', 'partial', 'fileupload', 'permissioneditor'])
export function rendererFor(type: string): Component {
return renderers.get(type) ?? lazyRenderers.get(type)?.() ?? UnsupportedField

View File

@@ -9,6 +9,20 @@
{ "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": "permissions",
"type": "permissioneditor",
"label": "Permissions",
"tab": "Permissions",
"context": "update",
"mode": "radio",
"permissionOptions": [
{ "code": "posts.edit", "label": "Edit posts", "tab": "Content", "comment": "Change the text of any post." },
{ "code": "posts.publish", "label": "Publish posts", "tab": "Content" },
{ "code": "reports.export", "label": "Export reports", "tab": "Reports", "locked": true },
{ "code": "misc.beta", "label": "Try beta features" }
]
},
{ "name": "joined_ip", "type": "text", "label": "Joined from IP address", "context": "preview" }
],
"preview": { "headerPartial": "status" },

View File

@@ -1,5 +1,5 @@
{
"data": { "id": 1, "name": "Ada Lovelace", "email": "ada@example.test", "slug": "ada-lovelace", "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", "permissions": { "legacy.code": 1, "posts.edit": 1, "reports.export": 1 } },
"meta": {
"labels": {},
"actions": [

View File

@@ -1,5 +1,6 @@
// 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;
// D-19), preset fields (D-27 G7) and the permission editor (UI-SPEC S5,
// D-16). 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'
@@ -15,6 +16,12 @@ const RECORD = `${LIST}/1`
const strings = {
'backend::lang.form.show_password': { other: 'Show password' },
'backend::lang.form.hide_password': { other: 'Hide password' },
'backend::lang.permissioneditor.allow': { other: 'Allow' },
'backend::lang.permissioneditor.inherit': { other: 'Inherit' },
'backend::lang.permissioneditor.deny': { other: 'Deny' },
'backend::lang.permissioneditor.locked': { other: 'You cannot change this permission.' },
'backend::lang.permissioneditor.empty': { other: 'No permissions are defined yet.' },
'backend::lang.permissioneditor.other': { other: 'Other' },
}
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
@@ -202,3 +209,184 @@ describe('preset (D-27 G7)', () => {
expect(input(wrapper, 'slug').element.value).toBe('ada-lovelace')
})
})
describe('permission editor (UI-SPEC S5, D-16)', () => {
const editor = (wrapper: VueWrapper) => wrapper.find('[data-permission-editor]')
const row = (wrapper: VueWrapper, code: string) => wrapper.find(`[data-permission="${code}"]`)
const segment = (wrapper: VueWrapper, code: string, value: string) => row(wrapper, code).find(`[data-segment="${value}"]`)
const checked = (wrapper: VueWrapper, code: string) => row(wrapper, code).find('[data-state="checked"]')
/** Opens the update form on its Permissions tab. */
async function open(overrides: Record<string, Route> = {}, schema = rosterFormSchemaFixture) {
const mounted = await mountApp(
'/acme/roster/people/1',
routes({ [`GET ${LIST}/schema/form`]: { body: schema }, ...overrides }),
{ attach: true },
)
const tab = mounted.wrapper.findAll('[role="tab"]').find((item) => item.text().includes('Permissions'))
await tab!.trigger('click')
await flushPromises()
return mounted
}
function withField(change: (field: (typeof rosterFormSchemaFixture.data.fields)[number]) => void) {
const schema = clone(rosterFormSchemaFixture)
change(schema.data.fields.find((field) => field.name === 'permissions')!)
return schema
}
it('groups the options by tab into sections of one list, untabbed ones last', async () => {
const { wrapper } = await open()
expect(editor(wrapper).attributes('role')).toBe('group')
expect(editor(wrapper).attributes('aria-labelledby')).toBe('field-permissions-label')
expect(wrapper.find('#field-permissions-label').text()).toBe('Permissions')
expect(editor(wrapper).classes()).toEqual(expect.arrayContaining(['overflow-hidden', 'rounded-inner', 'border', 'border-border']))
const sections = wrapper.findAll('[data-permission-section]')
expect(sections.map((section) => section.find('h3').text())).toEqual(['Content', 'Reports', 'Other'])
expect(sections.map((section) => section.findAll('[data-permission]').map((item) => item.attributes('data-permission')))).toEqual([
['posts.edit', 'posts.publish'],
['reports.export'],
['misc.beta'],
])
// Each section is a group named by its header; there is no inner tablist
// and no inner scroll.
expect(sections[0]!.attributes('aria-labelledby')).toBe(sections[0]!.find('h3').attributes('id'))
expect(editor(wrapper).find('[role="tablist"]').exists()).toBe(false)
expect(editor(wrapper).html()).not.toContain('overflow-y-auto')
expect(editor(wrapper).html()).not.toContain('sticky')
// Label and comment; a radio group of three named segments per row.
expect(row(wrapper, 'posts.edit').text()).toContain('Edit posts')
expect(row(wrapper, 'posts.edit').text()).toContain('Change the text of any post.')
expect(row(wrapper, 'posts.edit').findAll('[role="radio"]').map((item) => item.text())).toEqual(['Allow', 'Inherit', 'Deny'])
expect(row(wrapper, 'posts.edit').find('[role="radiogroup"]').attributes('aria-labelledby')).toBe(
row(wrapper, 'posts.edit').find('span[id$="-label"]').attributes('id'),
)
// Stored allow shows as Allow; a code with no value shows as Inherit.
expect(checked(wrapper, 'posts.edit').text()).toBe('Allow')
expect(checked(wrapper, 'posts.publish').text()).toBe('Inherit')
expect(checked(wrapper, 'posts.edit').classes()).toEqual(
expect.arrayContaining(['data-[state=checked]:bg-ok-bg', 'data-[state=checked]:text-ok-text']),
)
// The row wraps below 640px and the control never shrinks.
expect(row(wrapper, 'posts.edit').classes()).toEqual(expect.arrayContaining(['min-h-[56px]', 'max-sm:flex-col']))
expect(row(wrapper, 'posts.edit').find('[role="radiogroup"]').classes()).toContain('shrink-0')
})
it('sends a chosen Deny as -1, leaves inherited codes out and never sends a code that is not offered', async () => {
const { wrapper, calls } = await open({ [`PUT ${RECORD}`]: { body: rosterRecordFixture } })
await segment(wrapper, 'posts.publish', '-1').trigger('click')
expect(checked(wrapper, 'posts.publish').text()).toBe('Deny')
expect(checked(wrapper, 'posts.publish').classes()).toContain('data-[state=checked]:text-danger')
await save(wrapper)
const [request] = requestsTo(calls, 'PUT', RECORD)
const body = (await request!.clone().json()) as Record<string, unknown>
expect(body.permissions).toEqual({ 'posts.edit': 1, 'posts.publish': -1, 'reports.export': 1 })
})
it('goes back to inherit by leaving the code out', async () => {
const { wrapper, calls } = await open({ [`PUT ${RECORD}`]: { body: rosterRecordFixture } })
await segment(wrapper, 'posts.edit', '0').trigger('click')
await save(wrapper)
const [request] = requestsTo(calls, 'PUT', RECORD)
const body = (await request!.clone().json()) as Record<string, unknown>
expect(body.permissions).toEqual({ 'reports.export': 1 })
})
it('shows a locked row with its stored value, a disabled control, the lock and its text', async () => {
const { wrapper, calls } = await open({ [`PUT ${RECORD}`]: { body: rosterRecordFixture } })
const locked = row(wrapper, 'reports.export')
expect(locked.attributes('data-locked')).toBeDefined()
expect(locked.find('[data-permission-locked]').text()).toBe('You cannot change this permission.')
expect(locked.find('svg').attributes('aria-hidden')).toBe('true')
expect(checked(wrapper, 'reports.export').text()).toBe('Allow')
for (const item of locked.findAll('[role="radio"]')) {
expect(item.attributes('disabled')).toBeDefined()
}
expect(locked.find('[role="radiogroup"]').attributes('aria-disabled')).toBe('true')
// A click changes nothing, and other rows stay editable.
await segment(wrapper, 'reports.export', '-1').trigger('click')
expect(checked(wrapper, 'reports.export').text()).toBe('Allow')
expect(row(wrapper, 'posts.edit').find('[role="radio"]').attributes('disabled')).toBeUndefined()
expect(row(wrapper, 'posts.edit').find('[data-permission-locked]').exists()).toBe(false)
await segment(wrapper, 'misc.beta', '1').trigger('click')
await save(wrapper)
const [request] = requestsTo(calls, 'PUT', RECORD)
const body = (await request!.clone().json()) as Record<string, unknown>
expect(body.permissions).toEqual({ 'posts.edit': 1, 'reports.export': 1, 'misc.beta': 1 })
})
it('edits allow with a labelled checkbox per row in checkbox mode', async () => {
const schema = withField((field) => (field.mode = 'checkbox'))
const { wrapper, calls } = await open({ [`PUT ${RECORD}`]: { body: rosterRecordFixture } }, schema)
expect(editor(wrapper).attributes('data-mode')).toBe('checkbox')
// The section header carries the column heading.
expect(wrapper.find('[data-permission-section] h3').text()).toContain('Allow')
const box = (code: string) => row(wrapper, code).find('[role="checkbox"]')
expect(box('posts.edit').attributes('aria-checked')).toBe('true')
expect(box('posts.publish').attributes('aria-checked')).toBe('false')
expect(box('posts.edit').classes()).toEqual(expect.arrayContaining(['size-[18px]', 'rounded-checkbox']))
// The row label is a label for the box.
expect(row(wrapper, 'posts.publish').find('label').attributes('for')).toBe(box('posts.publish').attributes('id'))
expect(box('reports.export').attributes('disabled')).toBeDefined()
await box('posts.publish').trigger('click')
await box('posts.edit').trigger('click')
await save(wrapper)
const [request] = requestsTo(calls, 'PUT', RECORD)
const body = (await request!.clone().json()) as Record<string, unknown>
expect(body.permissions).toEqual({ 'posts.publish': 1, 'reports.export': 1 })
})
it('shows the empty text when the server supplies no options', async () => {
const { wrapper } = await open({}, withField((field) => delete field.permissionOptions))
expect(editor(wrapper).exists()).toBe(false)
const empty = wrapper.find('[data-permission-empty]')
expect(empty.text()).toBe('No permissions are defined yet.')
expect(empty.classes()).toEqual(expect.arrayContaining(['min-h-input', 'bg-subtle', 'text-muted']))
})
it('renders a 422 on the field error line with a danger border and clears it on a change', async () => {
const refused: Reply = {
status: 422,
body: {
error: {
code: 'validation_failed',
message: 'Validation failed',
details: { permissions: ['The permissions field contains an unknown permission.'] },
},
},
}
const { wrapper } = await open({ [`PUT ${RECORD}`]: refused })
await segment(wrapper, 'posts.publish', '1').trigger('click')
await save(wrapper)
expect(wrapper.find('[data-field="permissions"]').text()).toContain('The permissions field contains an unknown permission.')
expect(editor(wrapper).classes()).toContain('border-danger')
expect(editor(wrapper).attributes('aria-describedby')).toBe('field-permissions-error')
await segment(wrapper, 'posts.publish', '0').trigger('click')
expect(editor(wrapper).classes()).toContain('border-border')
expect(wrapper.find('#field-permissions-error').exists()).toBe(false)
})
it('is read-only on the preview: every control disabled, no lock icon and no locked text', async () => {
const schema = withField((field) => (field.context = ['update', 'preview']))
const { wrapper } = await mountApp('/acme/roster/people/1/preview', routes({ [`GET ${LIST}/schema/form`]: { body: schema } }))
await flushPromises()
const tab = wrapper.findAll('[role="tab"]').find((item) => item.text().includes('Permissions'))
await tab!.trigger('click')
await flushPromises()
const shown = wrapper.find('[data-preview-field="permissions"]')
expect(shown.find('[data-permission-editor]').exists()).toBe(true)
const radios = shown.findAll('[role="radio"]')
expect(radios).toHaveLength(12)
for (const item of radios) {
expect(item.attributes('disabled')).toBeDefined()
}
expect(shown.find('[data-permission-locked]').exists()).toBe(false)
expect(shown.find('[data-locked]').exists()).toBe(false)
expect(shown.find('[data-permission="posts.edit"] [data-state="checked"]').text()).toBe('Allow')
})
})