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:
@@ -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
|
||||
}
|
||||
|
||||
218
admin/src/components/form/fields/PermissionEditorField.vue
Normal file
218
admin/src/components/form/fields/PermissionEditorField.vue
Normal 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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user