fix: form types & useForm fields rules reactivity

This commit is contained in:
axolotle 2024-08-11 17:04:33 +02:00
parent 1b14c78195
commit 931e74ff29
7 changed files with 126 additions and 92 deletions

View file

@ -11,6 +11,7 @@ import type {
AnyDisplayComponents,
AnyWritableComponents,
BaseItemComputedProps,
ButtonItemProps,
FormFieldDict,
} from '@/types/form'
import { isDisplayComponent, isWritableComponent } from '@/types/form'
@ -62,9 +63,9 @@ const slots = defineSlots<
} & {
[K in KeyOfStr<FFD> as `component:${K}`]?: (
_: FFD[K]['component'] extends AnyWritableComponents
? FFD[K]['props'] & BaseItemComputedProps<MV[K]>
? FFD[K]['cProps'] & BaseItemComputedProps<MV[K]>
: FFD[K]['component'] extends AnyDisplayComponents
? FFD[K]['props']
? FFD[K]['cProps']
: never,
) => any
}
@ -135,17 +136,17 @@ const Fields = createReusableTemplate<{
<slot
v-else-if="isDisplayComponent(field)"
:name="`component:${k}`"
v-bind="field.props"
v-bind="field.cProps"
>
<Component
:is="field.component"
v-if="field.component !== 'ButtonItem'"
v-bind="field.props"
v-bind="field.cProps"
/>
<ButtonItem
v-else
v-bind="field.props"
@action="emit('action', $event as typeof field.props.id)"
v-bind="field.cProps as ButtonItemProps"
@action="emit('action', $event as KeyOfStr<FFD>)"
/>
</slot>
@ -181,12 +182,13 @@ const Fields = createReusableTemplate<{
{{ section.name }}
<small v-if="section.help">{{ section.help }}</small>
</BCardTitle>
<!-- @vue-ignore-next-line -->
<Fields.reuse :fields-props="section.fields" />
</Component>
</template>
</template>
<template v-else-if="fields">
<!-- @vue-ignore-next-line -->
<Fields.reuse :fields-props="fields" />
</template>
</slot>

View file

@ -14,6 +14,7 @@ import type {
BaseItemComputedProps,
FormField,
FormFieldProps,
ItemComponentToItemProps,
} from '@/types/form'
defineOptions({
@ -24,6 +25,8 @@ defineOptions({
const props = withDefaults(defineProps<FormFieldProps<C, MV>>(), {
append: undefined,
asInputGroup: false,
component: undefined,
cProps: undefined,
description: undefined,
descriptionVariant: undefined,
id: undefined,
@ -43,7 +46,7 @@ defineEmits<{
const slots = defineSlots<{
default?: (
componentProps: FormField<C, MV>['props'] & BaseItemComputedProps<MV>,
componentProps: FormField<C, MV>['cProps'] & BaseItemComputedProps<MV>,
) => any
description?: any
}>()
@ -86,7 +89,7 @@ const computedAttrs = computed(() => {
const id = computed(() => {
if (props.id) return props.id
const childId = props.props?.id || props.labelFor
const childId = props.cProps?.id || props.labelFor
return childId ? `${childId}-field` : undefined
})
@ -132,7 +135,7 @@ const [DefineTemplate, ReuseTemplate] = createReusableTemplate<{
<!-- Make field props and state available as scoped slot data -->
<slot
v-bind="{
...props.props,
...(props.cProps ?? ({} as ItemComponentToItemProps[C])),
ariaDescribedby,
modelValue: props.modelValue,
state,
@ -141,7 +144,7 @@ const [DefineTemplate, ReuseTemplate] = createReusableTemplate<{
>
<!-- if no component was passed as slot, render a component from the props -->
<Component
v-bind="props.props"
v-bind="props.cProps"
:is="component"
v-model="model"
:aria-describedby="ariaDescribedby"
@ -156,7 +159,7 @@ const [DefineTemplate, ReuseTemplate] = createReusableTemplate<{
v-bind="computedAttrs"
:id="id"
:label="label"
:label-for="labelFor || props.props?.id"
:label-for="labelFor || props.cProps?.id"
:state="state"
>
<template #default="{ ariaDescribedby }">

View file

@ -13,6 +13,7 @@ import type {
BaseItemComputedProps,
FormField,
FormFieldProps,
ItemComponentToItemProps,
} from '@/types/form'
defineOptions({
@ -23,13 +24,15 @@ defineOptions({
const props = withDefaults(
defineProps<
FormFieldProps<C, MV> & {
defaultValue: () => ArrInnerType<MV>
addBtnText: string
defaultValue?: () => ArrInnerType<MV>
addBtnText?: string
}
>(),
{
append: undefined,
asInputGroup: false,
component: undefined,
cProps: undefined,
description: undefined,
descriptionVariant: undefined,
id: undefined,
@ -38,6 +41,8 @@ const props = withDefaults(
link: undefined,
prepend: undefined,
rules: undefined,
defaultValue: undefined,
addBtnText: undefined,
modelValue: undefined,
validation: undefined,
@ -50,7 +55,7 @@ const emit = defineEmits<{
const slots = defineSlots<{
default?: (_: {
componentProps: FormField<C, ArrInnerType<MV>>['props'] &
componentProps: FormField<C, ArrInnerType<MV>>['cProps'] &
BaseItemComputedProps<ArrInnerType<MV>>
index: number
}) => any
@ -86,7 +91,7 @@ const computedAttrs = computed(() => {
const id = computed(() => {
if (props.id) return props.id
return props.props?.id ? props.props?.id + '_group' : undefined
return props.cProps?.id ? props.cProps?.id + '_group' : undefined
})
const error = computed(() => {
@ -101,9 +106,9 @@ const subProps = computed<FormFieldProps<C, ArrInnerType<MV>>[]>(() => {
return (
props.modelValue?.map((modelValue: ArrInnerType<MV>, i) => {
return {
props: {
...props.props,
id: `${props.props.id}.${i}`,
cProps: {
...(props.cProps ?? ({} as ItemComponentToItemProps[C])),
id: `${props.cProps?.id}.${i}`,
},
validation: props.validation?.[i],
modelValue,
@ -138,7 +143,7 @@ const errorMessage = computed(() => {
})
function addElement() {
const value = [...(props?.modelValue || []), props.defaultValue()] as MV
const value = [...(props?.modelValue || []), props.defaultValue!()] as MV
emit('update:modelValue', value)
// FIXME: Focus newly inserted form item
@ -172,16 +177,25 @@ function updateElement(index: number, newValue: ArrInnerType<MV>) {
</template>
</FormField>
<BButton variant="danger" @click="removeElement(index)">
<BButton
v-if="defaultValue !== undefined"
variant="danger"
@click="removeElement(index)"
>
<YIcon :title="$t('delete')" iname="trash-o" />
<span class="visually-hidden">{{ $t('delete') }}</span>
</BButton>
</div>
<BButton variant="success" @click="addElement()">
<YIcon iname="plus" /> {{ $t('user_emailaliases_add') }}
<BButton
v-if="defaultValue !== undefined"
variant="success"
@click="addElement()"
>
<YIcon iname="plus" /> {{ addBtnText ?? $t('add') }}
</BButton>
<!-- FIXME is it needed? or more generic error like "errors in this multiple fields" -->
<template #invalid-feedback>
<span v-html="errorMessage" />
</template>

View file

@ -4,14 +4,13 @@ import { computed, toValue } from 'vue'
import type { ButtonItemProps } from '@/types/form'
const props = withDefaults(defineProps<ButtonItemProps>(), {
id: undefined,
enabled: true,
icon: undefined,
type: 'success',
})
const emit = defineEmits<{
action: [value: ButtonItemProps['id']]
action: [value: string]
}>()
const icon = computed(() => {

View file

@ -6,7 +6,6 @@ import type {
WritableComputedRef,
} from 'vue'
import { computed, ref, toValue, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { APIBadRequestError, APIError } from '@/api/errors'
@ -14,6 +13,7 @@ import { deepSetErrors, useForm, type FormValidation } from '@/composables/form'
import { isObjectLiteral } from '@/helpers/commons'
import * as validators from '@/helpers/validators'
import { formatForm, formatI18nField } from '@/helpers/yunohostArguments'
import i18n from '@/i18n'
import type { CustomRoute, KeyOfStr, MergeUnion, Obj } from '@/types/commons'
import type {
AnyFormField,
@ -96,23 +96,24 @@ function formatOption(option: AnyOption, form: Ref<Obj>): AnyFormField {
if (isIn(ANY_DISPLAY_OPTION_TYPE, option)) {
const component = OPTION_COMPONENT_RESOLVER[option.type]
// TODO: could be improved, for simplicity props can be be any display item props
// TODO: could be improved, for simplicity cProps can be be any display item props
// but this is not type safe.
const props = {
const cProps = {
label: formatI18nField(option.ask),
id: option.id,
} as MergeUnion<AnyDisplayItemProps>
const field: FormFieldDisplay<typeof component> = {
component,
visible,
props,
cProps,
rules: undefined,
}
if (isIn(['button', 'alert'], option)) {
props.type = option.style
props.icon = option.icon
cProps.type = option.style
cProps.icon = option.icon
if (option.type === 'button') {
props.enabled = useExpression(option.enabled, form)
cProps.enabled = useExpression(option.enabled, form)
}
}
@ -124,42 +125,42 @@ function formatOption(option: AnyOption, form: Ref<Obj>): AnyFormField {
}
const component = OPTION_COMPONENT_RESOLVER[option.type]
// TODO: could be improved, for simplicity props can be be any writable item props
// TODO: could be improved, for simplicity cProps can be be any writable item props
// but this is not type safe.
const props = {
const cProps = {
id: option.id,
placeholder: option.example,
} as MergeUnion<AnyWritableItemProps>
const rules: FormField['rules'] = {}
const field:
| FormField<typeof component>
| FormFieldReadonly<typeof component> = {
const field: FormField<typeof component> = {
component,
label: formatI18nField(option.ask),
props,
readonly: option.readonly,
rules,
rules: option.readonly ? undefined : rules,
visible,
description: formatI18nField(option.help),
}
// We don't care about component props in case of readonly
if (field.readonly) return field
if (option.readonly) {
return { ...field, readonly: true } as FormFieldReadonly<typeof component>
} else {
field.cProps = cProps
}
const { t } = useI18n()
const t = i18n.global.t
if (isIn(ANY_INPUT_OPTION_TYPE, option)) {
props.type = isIn(['string', 'path'], option) ? 'text' : option.type
cProps.type = isIn(['string', 'path'], option) ? 'text' : option.type
// trim
// autocomplete
if (option.type === 'password') {
field.description ??= t('good_practices_about_admin_password')
rules.passwordLenght = validators.minLength(8)
props.placeholder = '••••••••••••'
cProps.placeholder = '••••••••••••'
} else if (isIn(['number', 'range'], option)) {
rules.numValue = validators.integer
props.step = option.step
cProps.step = option.step
if (option.min !== undefined) {
rules.minValue = validators.minValue(option.min)
@ -169,7 +170,7 @@ function formatOption(option: AnyOption, form: Ref<Obj>): AnyFormField {
}
}
} else if (isIn(['select', 'user', 'domain', 'app', 'group'], option)) {
props.choices = isObjectLiteral(option.choices)
cProps.choices = isObjectLiteral(option.choices)
? Object.entries(option.choices).map(([k, v]) => ({
text: v,
value: k,
@ -182,23 +183,23 @@ function formatOption(option: AnyOption, form: Ref<Obj>): AnyFormField {
}
}
} else if (isIn(['tags', 'tags-select'], option)) {
// props.limit = option.limit // FIXME limit is not defined in core?
props.placeholder = option.placeholder
props.tagIcon = option.icon
// cProps.limit = option.limit // FIXME limit is not defined in core?
cProps.placeholder = option.placeholder
cProps.tagIcon = option.icon
if ('tags-select' === option.type) {
props.options = option.choices
props.auto = true
props.itemsName = ''
props.label = option.placeholder
cProps.options = option.choices
cProps.auto = true
cProps.itemsName = ''
cProps.label = option.placeholder
}
} else if ('boolean' === option.type) {
// FIXME
// props.choices = option.choices
// cProps.choices = option.choices
}
if ('file' === option.type) {
props.accept = option.accept
cProps.accept = option.accept
}
if ('boolean' !== option.type && option.optional === false) {
@ -233,6 +234,7 @@ export function formatOptions<MV extends Obj>(
fields: FormFieldDict<MV>
form: Ref<MV>
} {
// FIXME handle optional for app install ? or is already handled in core bookworm?
const form = ref(
Object.fromEntries(
options
@ -373,7 +375,6 @@ function useEvaluation(expression: string, form: MaybeRefOrGetter<Obj>) {
return computed(() => {
const { exp, ctx } = buildContext(toValue(form))
try {
return !!evaluate(ctx, exp)
} catch {

View file

@ -1,12 +1,13 @@
// eslint-disable-next-line vue/prefer-import-from-vue
import { isFunction } from '@vue/shared'
import type {
BaseValidation,
ServerErrors,
Validation,
ValidationArgs,
ValidationRuleCollection,
} from '@vuelidate/core'
import useVuelidate from '@vuelidate/core'
import { computedWithControl } from '@vueuse/core'
import { watchImmediate } from '@vueuse/core'
import type {
ComputedRef,
InjectionKey,
@ -14,11 +15,11 @@ import type {
Ref,
WritableComputedRef,
} from 'vue'
import { computed, inject, provide, reactive, toValue } from 'vue'
import { computed, inject, provide, reactive, ref, toValue } from 'vue'
import { APIBadRequestError, type APIError } from '@/api/errors'
import type { Obj } from '@/types/commons'
import type { FormField, FormFieldDict } from '@/types/form'
import type { FormFieldDict } from '@/types/form'
export const clearServerErrorsSymbol = Symbol() as InjectionKey<
(key?: string) => void
@ -57,25 +58,32 @@ export type FormValidation<MV extends Obj> = Validation<
export function useForm<
MV extends Obj,
FFD extends FormFieldDict<MV> = FormFieldDict<MV>,
>(form: Ref<MV> | WritableComputedRef<MV>, fields: MaybeRefOrGetter<FFD>) {
>(form: Ref<MV> | WritableComputedRef<MV>, fields: FFD | (() => FFD)) {
const serverErrors = reactive<ServerErrors>({})
const validByDefault: ValidationRuleCollection = { true: () => true }
const rules = computedWithControl(
() => toValue(fields),
() => {
const fs = toValue(fields)
const validations = Object.keys(form.value).map((key: keyof MV) => [
key,
(fs[key] as FormField).rules ?? validByDefault,
])
const rules: ValidationArgs<MV> = Object.fromEntries(validations)
return {
// create a fake validation rule for global state to be able to add $externalResult errors to it
global: { true: () => true },
form: rules,
}
},
)
const validByDefault = { true: () => true as const }
// create a fake validation rule for global state to be able to add $externalResult errors to it
const rules = ref({ global: validByDefault, form: {} }) as Ref<{
global: { true: () => true }
form: ValidationArgs<MV>
}>
function updateRules(ffd: FFD) {
const validations = Object.keys(form.value).map((key: keyof MV) => [
key,
ffd[key].rules ?? validByDefault,
])
const formRules: ValidationArgs<MV> = Object.fromEntries(validations)
rules.value = { global: { true: () => true }, form: formRules }
}
if (isFunction(fields)) {
watchImmediate(fields, () => {
updateRules(toValue(fields))
})
} else {
watchImmediate(
Object.keys(form.value).map((key: keyof MV) => () => fields[key].rules),
() => updateRules(fields),
)
}
const v: Ref<FormValidation<MV>> = useVuelidate(
rules,

View file

@ -10,6 +10,7 @@ import { isObjectLiteral } from '@/helpers/commons'
import type { ArrInnerType, Cols, Obj, StateVariant } from '@/types/commons'
type StateValidation = false | null
type Choices = string[] | { text: string; value: string }[]
// DISPLAY
@ -20,6 +21,7 @@ type BaseDisplayItemProps = {
export type ButtonItemProps = BaseDisplayItemProps & {
// FIXME compute enabled JSExpression
id: string
enabled?: boolean | ComputedRef<boolean>
icon?: string
type?: StateVariant
@ -41,6 +43,7 @@ type BaseWritableItemProps = {
name?: string
placeholder?: string
touchKey?: string
disabled?: boolean
}
export type BaseItemComputedProps<MV extends any = any> = {
@ -51,7 +54,7 @@ export type BaseItemComputedProps<MV extends any = any> = {
}
export type AdressItemProps = BaseWritableItemProps & {
choices: string[]
choices: Choices
type?: 'domain' | 'email'
}
export type AdressModelValue = {
@ -64,7 +67,7 @@ export type CheckboxItemProps = BaseWritableItemProps & {
label?: string
labels?: { true: string; false: string }
// FIXME unused?
// choices: string[]
// choices: Choices
}
export type FileItemProps = BaseWritableItemProps & {
@ -89,7 +92,7 @@ export type InputItemProps = BaseWritableItemProps & {
| 'current-password'
| 'url'
// pattern?: object
// choices?: string[] FIXME rm ?
// choices?: Choices FIXME rm ?
step?: number
trim?: boolean
type?:
@ -107,7 +110,7 @@ export type InputItemProps = BaseWritableItemProps & {
}
export type SelectItemProps = BaseWritableItemProps & {
choices: string[] | { text: string; value: string }[]
choices: Choices
}
export type TagsItemProps = BaseWritableItemProps & {
@ -205,7 +208,7 @@ export function isNonWritableComponent(
return isDisplayComponent(field) || !!field.readonly
}
type ItemComponentToItemProps = {
export type ItemComponentToItemProps = {
// DISPLAY
ButtonItem: ButtonItemProps
DisplayTextItem: DisplayTextItemProps
@ -234,11 +237,11 @@ type BaseFormFieldComputedProps<MV extends any = any> = {
}
type BaseFormField<C extends AnyItemComponents> = {
component: C
component?: C
cProps?: ItemComponentToItemProps[C]
hr?: boolean
id?: string
label?: string
props?: ItemComponentToItemProps[C]
readonly?: boolean
visible?: boolean | ComputedRef<boolean>
}
@ -249,14 +252,14 @@ export type FormField<
> = BaseFormField<C> & {
append?: string
asInputGroup?: boolean
cProps?: ItemComponentToItemProps[C]
description?: string
descriptionVariant?: StateVariant
labelFor?: string
link?:
| { text: string; name: RouteLocationRaw }
| { text: string; href: string }
props: ItemComponentToItemProps[C]
rules?: FormFieldRules<MV>
rules?: FormFieldRules<MV> | ComputedRef<FormFieldRules<MV>>
prepend?: string
readonly?: false
}
@ -267,16 +270,18 @@ export type FormFieldReadonly<
label: string
cols?: Cols
readonly: true
rules: undefined
}
export type FormFieldDisplay<
C extends AnyDisplayComponents = AnyDisplayComponents,
> = {
component: C
props: ItemComponentToItemProps[C]
component?: C
cProps?: ItemComponentToItemProps[C]
visible?: boolean | ComputedRef<boolean>
hr?: boolean
readonly?: true
rules: undefined
}
export type FormFieldProps<
@ -305,12 +310,14 @@ export type FormFieldDict<T extends Obj = Obj> = {
// Type to check if object satisfies specified Field and Item
export type FieldProps<
C extends AnyItemComponents = 'InputItem',
C extends AnyItemComponents = AnyItemComponents,
MV extends any = never,
> = C extends AnyWritableComponents
? FormField<C, MV> | FormFieldReadonly<C>
?
| (FormField<C, MV> & { component: C })
| (FormFieldReadonly<C> & { component: C })
: C extends AnyDisplayComponents
? FormFieldDisplay<C>
? FormFieldDisplay<C> & { component: C }
: never
export function isFileModelValue(value: any): value is FileModelValue {