- RepeaterField: rows with drag handle, index, titleFrom title, error chip, move up/down (buttons, ArrowUp/ArrowDown on the handle, native drag and drop), expand/collapse, remove (asks only for a row holding a value), add with nested defaults, maxItems counter, empty state, collapse all; every change edits form state only - 422 row paths (content.3.answer) open the row, mark it and focus the nested control; the banner no longer repeats row messages - DropdownField dependsOn: waiting, loading, not used, empty, loaded with depth indent, failed with retry; cached per sibling value, last request wins, a stale value is cleared after a sibling change and announced - FormView provides form feedback (errors and notices by row path, the polite live region, its confirm dialog) and the options cache; FormGrid provides the sibling scope for repeater rows - repeater.* and form.dependent_* phrase keys (en, pl), smoke specs, docs
56 lines
2.1 KiB
Vue
56 lines
2.1 KiB
Vue
<script setup lang="ts">
|
|
import { computed } from 'vue'
|
|
import { CircleAlert } from '@lucide/vue'
|
|
import { t, tc } from '../../app/i18n'
|
|
import { keyField } from './formState'
|
|
|
|
// 422 banner (design screen 4): "Nie udało się zapisać. Popraw N pola…".
|
|
// Messages of keys that are not form fields are listed here, so no server
|
|
// message is lost. With `forbidden` (UI-SPEC S6: a save the server refused
|
|
// with 403) the same geometry shows that text instead; it stays until the
|
|
// next save attempt, and field messages still render on their fields.
|
|
const props = withDefaults(
|
|
defineProps<{ errors: Record<string, string[]>; fieldNames: string[]; forbidden?: string | null }>(),
|
|
{ forbidden: null },
|
|
)
|
|
|
|
const count = computed(() => Object.keys(props.errors).length)
|
|
// A repeater row path (`content.3.answer`) belongs to its field: it renders
|
|
// under the nested control, not here.
|
|
const orphans = computed(() =>
|
|
Object.entries(props.errors)
|
|
.filter(([name]) => !props.fieldNames.includes(name) && !props.fieldNames.includes(keyField(name)))
|
|
.flatMap(([, messages]) => messages),
|
|
)
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
v-if="forbidden"
|
|
role="alert"
|
|
data-forbidden-banner
|
|
class="flex items-start gap-3 rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger"
|
|
>
|
|
<CircleAlert :size="20" class="mt-px shrink-0" aria-hidden="true" />
|
|
<div class="flex min-w-0 flex-col gap-1">
|
|
<p class="[overflow-wrap:anywhere]">{{ forbidden }}</p>
|
|
<p v-for="(text, index) in orphans" :key="index" class="text-[13px] [overflow-wrap:anywhere]">{{ text }}</p>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-else-if="count > 0"
|
|
role="alert"
|
|
data-error-banner
|
|
class="flex items-start gap-3 rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger"
|
|
>
|
|
<CircleAlert :size="20" class="mt-px shrink-0" aria-hidden="true" />
|
|
<div class="flex flex-col gap-1">
|
|
<p>
|
|
<strong>{{ t('backend::lang.form.error_title') }}</strong>
|
|
{{ tc('backend::lang.form.error_fields', count) }}
|
|
</p>
|
|
<p v-for="(text, index) in orphans" :key="index" class="text-[13px]">{{ text }}</p>
|
|
</div>
|
|
</div>
|
|
</template>
|