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

@@ -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>