mirror of
https://github.com/YunoHost/yunohost-admin.git
synced 2024-09-03 20:06:15 +02:00
refactor: FormField ts + composition
This commit is contained in:
parent
b9cf8b92fe
commit
01ff371eed
4 changed files with 243 additions and 89 deletions
|
@ -1,102 +1,99 @@
|
||||||
<script setup lang="ts">
|
<script
|
||||||
import type { BaseValidation } from '@vuelidate/core'
|
setup
|
||||||
import type { BaseColorVariant } from 'bootstrap-vue-next'
|
lang="ts"
|
||||||
import { computed, provide, useAttrs, type Component } from 'vue'
|
generic="C extends AnyWritableComponents, MV extends any"
|
||||||
|
>
|
||||||
|
import { createReusableTemplate } from '@vueuse/core'
|
||||||
|
import { computed, useAttrs } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import type { Obj } from '@/types/commons'
|
import { useTouch } from '@/composables/form'
|
||||||
|
import { omit } from '@/helpers/commons'
|
||||||
|
import type {
|
||||||
|
AnyWritableComponents,
|
||||||
|
BaseItemComputedProps,
|
||||||
|
FormField,
|
||||||
|
FormFieldProps,
|
||||||
|
} from '@/types/form'
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
inheritAttrs: false,
|
|
||||||
name: 'FormField',
|
name: 'FormField',
|
||||||
|
inheritAttrs: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(defineProps<FormFieldProps<C, MV>>(), {
|
||||||
defineProps<{
|
append: undefined,
|
||||||
// Component props (other <form-group> related attrs are passed thanks to $attrs)
|
asInputGroup: false,
|
||||||
id?: string
|
|
||||||
description?: string
|
|
||||||
descriptionVariant?: BaseColorVariant
|
|
||||||
link?: { href: string; text: string }
|
|
||||||
component?: Component | string // FIXME limit to formItems?
|
|
||||||
modelValue?: unknown
|
|
||||||
props?: Obj
|
|
||||||
validation?: BaseValidation
|
|
||||||
validationIndex?: number
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
id: undefined,
|
|
||||||
description: undefined,
|
description: undefined,
|
||||||
descriptionVariant: undefined,
|
descriptionVariant: undefined,
|
||||||
|
id: undefined,
|
||||||
|
label: undefined,
|
||||||
|
labelFor: undefined,
|
||||||
link: undefined,
|
link: undefined,
|
||||||
component: 'InputItem',
|
prepend: undefined,
|
||||||
modelValue: undefined,
|
rules: undefined,
|
||||||
props: () => ({}),
|
|
||||||
validation: undefined,
|
|
||||||
validationIndex: undefined,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
modelValue: undefined,
|
||||||
'update:modelValue': [value: string]
|
validation: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
'update:modelValue': [value: MV]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const slots = defineSlots<{
|
||||||
|
default?: (
|
||||||
|
componentProps: FormField<C, MV>['props'] & BaseItemComputedProps<MV>,
|
||||||
|
) => any
|
||||||
|
description?: any
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const model = defineModel<MV>()
|
||||||
|
|
||||||
|
const attrs = useAttrs()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
useTouch(() => props.validation)
|
||||||
|
|
||||||
function touch(name: string) {
|
const computedAttrs = computed(() => {
|
||||||
if (props.validation) {
|
const attrs_ = { ...omit(attrs, ['hr', 'readonly', 'visible']) }
|
||||||
// For fields that have multiple elements
|
|
||||||
if (name) {
|
|
||||||
props.validation[name].$touch()
|
|
||||||
} else {
|
|
||||||
props.validation.$touch()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
provide('touch', touch)
|
if (props.label) {
|
||||||
|
|
||||||
const attrs_ = useAttrs()
|
|
||||||
const attrs = computed(() => {
|
|
||||||
const attrs = { ...attrs_ }
|
|
||||||
|
|
||||||
if ('label' in attrs) {
|
|
||||||
const defaultAttrs = {
|
const defaultAttrs = {
|
||||||
'label-cols-md': 4,
|
'label-cols-md': 4,
|
||||||
'label-cols-lg': 3,
|
'label-cols-lg': 3,
|
||||||
'label-class': ['fw-bold', 'py-0'],
|
'label-class': ['fw-bold', 'py-0'],
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!('label-cols' in attrs)) {
|
if (!('label-cols' in attrs_)) {
|
||||||
let attr: keyof typeof defaultAttrs
|
let attr: keyof typeof defaultAttrs
|
||||||
for (attr in defaultAttrs) {
|
for (attr in defaultAttrs) {
|
||||||
if (!(attr in attrs)) attrs[attr] = defaultAttrs[attr]
|
if (!(attr in attrs)) attrs_[attr] = defaultAttrs[attr]
|
||||||
}
|
}
|
||||||
} else if (!('label-class' in attrs)) {
|
} else if (!('label-class' in attrs)) {
|
||||||
attrs['label-class'] = defaultAttrs['label-class']
|
attrs_['label-class'] = defaultAttrs['label-class']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return attrs
|
if (props.asInputGroup) {
|
||||||
|
attrs_['label-class'] = [
|
||||||
|
...((attrs_['label-class'] as []) || []),
|
||||||
|
'visually-hidden',
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return attrs_
|
||||||
})
|
})
|
||||||
|
|
||||||
const id = computed(() => {
|
const id = computed(() => {
|
||||||
if (props.id) return props.id
|
if (props.id) return props.id
|
||||||
const childId = props.props.id || attrs_['label-for']
|
const childId = props.props?.id || props.labelFor
|
||||||
return childId ? childId + '_group' : null
|
return childId ? `${childId}-field` : undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
const error = computed(() => {
|
const error = computed(() => {
|
||||||
const v = props.validation
|
const v = props.validation
|
||||||
if (v) {
|
if (v && v.$anyDirty) {
|
||||||
if (props.validationIndex !== undefined) {
|
return v.$errors.length ? { errors: v.$errors, $model: v.$model } : null
|
||||||
const errors = v.$each.$response.$errors[props.validationIndex]
|
|
||||||
const err = Object.values(errors).find((part) => {
|
|
||||||
return part.length
|
|
||||||
})
|
|
||||||
return err?.length ? err[0] : null
|
|
||||||
}
|
|
||||||
return v.$errors.length ? { ...v.$errors[0], $model: v.$model } : null
|
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
@ -107,49 +104,89 @@ const state = computed(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const errorMessage = computed(() => {
|
const errorMessage = computed(() => {
|
||||||
const err = error.value
|
if (!error.value) return ''
|
||||||
|
const { errors, $model } = error.value
|
||||||
|
// FIXME maybe handle translation in validators directly
|
||||||
|
// https://vuelidate-next.netlify.app/advanced_usage.html#i18n-support
|
||||||
|
|
||||||
|
return errors
|
||||||
|
.map((err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
if (err.$message) return err.$message
|
if (err.$validator === '$externalResults') return err.$message
|
||||||
return t('form_errors.' + err.$validator, {
|
return t('form_errors.' + err.$validator, {
|
||||||
value: err.$model,
|
value: $model,
|
||||||
...err.$params,
|
...err.$params,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return ''
|
|
||||||
})
|
})
|
||||||
|
.join('<br>')
|
||||||
|
})
|
||||||
|
|
||||||
|
const [DefineTemplate, ReuseTemplate] = createReusableTemplate<{
|
||||||
|
ariaDescribedby: string[]
|
||||||
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- v-bind="$attrs" allow to pass default attrs not specified in this component slots -->
|
<DefineTemplate v-slot="{ ariaDescribedby }">
|
||||||
<BFormGroup
|
|
||||||
v-bind="attrs"
|
|
||||||
:id="id"
|
|
||||||
:label-for="attrs['label-for'] || props.id"
|
|
||||||
:state="state"
|
|
||||||
@touch="touch"
|
|
||||||
>
|
|
||||||
<!-- Make field props and state available as scoped slot data -->
|
<!-- Make field props and state available as scoped slot data -->
|
||||||
<slot v-bind="{ self: { ...props, state }, touch }">
|
<slot
|
||||||
|
v-bind="{
|
||||||
|
...props.props,
|
||||||
|
ariaDescribedby,
|
||||||
|
modelValue: props.modelValue,
|
||||||
|
state,
|
||||||
|
validation: validation,
|
||||||
|
}"
|
||||||
|
>
|
||||||
<!-- if no component was passed as slot, render a component from the props -->
|
<!-- if no component was passed as slot, render a component from the props -->
|
||||||
<Component
|
<Component
|
||||||
v-bind="props.props"
|
v-bind="props.props"
|
||||||
:is="component"
|
:is="component"
|
||||||
:modelValue="modelValue"
|
v-model="model"
|
||||||
@update:modelValue="emit('update:modelValue', $event)"
|
:aria-describedby="ariaDescribedby"
|
||||||
:state="state"
|
:state="state"
|
||||||
:required="validation ? 'required' in validation : false"
|
:validation="validation"
|
||||||
/>
|
/>
|
||||||
</slot>
|
</slot>
|
||||||
|
</DefineTemplate>
|
||||||
|
|
||||||
|
<!-- FIXME better use `labelSrOnly` prop instead of class but it is currently bugged -->
|
||||||
|
<BFormGroup
|
||||||
|
v-bind="computedAttrs"
|
||||||
|
:id="id"
|
||||||
|
:label="label"
|
||||||
|
:label-for="labelFor || props.props?.id"
|
||||||
|
:state="state"
|
||||||
|
>
|
||||||
|
<template #default="{ ariaDescribedby }">
|
||||||
|
<BInputGroup v-if="asInputGroup || append || prepend" :append="append">
|
||||||
|
<BInputGroupText
|
||||||
|
v-if="asInputGroup || prepend"
|
||||||
|
:aria-hidden="asInputGroup"
|
||||||
|
>
|
||||||
|
{{ asInputGroup ? label : prepend }}
|
||||||
|
</BInputGroupText>
|
||||||
|
<ReuseTemplate v-bind="{ ariaDescribedby }" />
|
||||||
|
<BInputGroupText v-if="append">{{ append }}</BInputGroupText>
|
||||||
|
</BInputGroup>
|
||||||
|
<ReuseTemplate v-else v-bind="{ ariaDescribedby }" />
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #invalid-feedback>
|
<template #invalid-feedback>
|
||||||
<span v-html="errorMessage" />
|
<span v-html="errorMessage" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #description>
|
<template v-if="description || link || 'description' in slots" #description>
|
||||||
<!-- Render description -->
|
<!-- Render description -->
|
||||||
<template v-if="description || link">
|
<template v-if="description || link">
|
||||||
<div class="d-flex">
|
<div class="d-flex">
|
||||||
<BLink v-if="link" :to="link" :href="link.href" class="ms-auto">
|
<BLink
|
||||||
|
v-if="link"
|
||||||
|
:to="'name' in link ? link.name : undefined"
|
||||||
|
:href="'href' in link ? link.href : undefined"
|
||||||
|
class="ms-auto"
|
||||||
|
>
|
||||||
{{ link.text }}
|
{{ link.text }}
|
||||||
</BLink>
|
</BLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,5 +1,32 @@
|
||||||
import type { InjectionKey } from 'vue'
|
import type { BaseValidation } from '@vuelidate/core'
|
||||||
|
import type { InjectionKey, MaybeRefOrGetter } from 'vue'
|
||||||
|
import { inject, provide, toValue } from 'vue'
|
||||||
|
|
||||||
|
export const clearServerErrorsSymbol = Symbol() as InjectionKey<
|
||||||
|
(key?: string) => void
|
||||||
|
>
|
||||||
export const ValidationTouchSymbol = Symbol() as InjectionKey<
|
export const ValidationTouchSymbol = Symbol() as InjectionKey<
|
||||||
(key?: string) => void
|
(key?: string) => void
|
||||||
>
|
>
|
||||||
|
|
||||||
|
export function useTouch(
|
||||||
|
validation: MaybeRefOrGetter<BaseValidation | undefined>,
|
||||||
|
) {
|
||||||
|
function touch(key?: string) {
|
||||||
|
const v = toValue(validation)
|
||||||
|
if (v) {
|
||||||
|
// For fields that have multiple elements
|
||||||
|
if (key) {
|
||||||
|
v[key].$touch()
|
||||||
|
clear?.(v[key].$path)
|
||||||
|
} else {
|
||||||
|
v.$touch()
|
||||||
|
clear?.(v.$path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
provide(ValidationTouchSymbol, touch)
|
||||||
|
const clear = inject(clearServerErrorsSymbol)
|
||||||
|
|
||||||
|
return touch
|
||||||
|
}
|
||||||
|
|
|
@ -144,3 +144,14 @@ export function getFileContent(
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function omit<T extends Obj, K extends (keyof T)[]>(
|
||||||
|
obj: T,
|
||||||
|
keys: K,
|
||||||
|
): Omit<T, K[number]> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.keys(obj)
|
||||||
|
.filter((key) => !keys.includes(key))
|
||||||
|
.map((key) => [key, obj[key]]),
|
||||||
|
) as Omit<T, K[number]>
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,12 @@
|
||||||
import type { BaseValidation } from '@vuelidate/core'
|
import type {
|
||||||
|
BaseValidation,
|
||||||
|
ValidationArgs,
|
||||||
|
ValidationRuleCollection,
|
||||||
|
} from '@vuelidate/core'
|
||||||
|
import type { RouteLocationRaw } from 'vue-router'
|
||||||
|
|
||||||
type StateValidation = false | null
|
type StateValidation = false | null
|
||||||
|
type StateVariant = 'success' | 'info' | 'warning' | 'danger'
|
||||||
|
|
||||||
// DISPLAY
|
// DISPLAY
|
||||||
|
|
||||||
|
@ -120,3 +127,75 @@ export type TagsSelectizeItemProps = BaseWritableItemProps & {
|
||||||
export type TextAreaItemProps = BaseWritableItemProps & {
|
export type TextAreaItemProps = BaseWritableItemProps & {
|
||||||
// type?: string // FIXME unused?
|
// type?: string // FIXME unused?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FIELDS
|
||||||
|
|
||||||
|
const ANY_WRITABLE_COMPONENTS = [
|
||||||
|
'AdressItem',
|
||||||
|
'CheckboxItem',
|
||||||
|
'FileItem',
|
||||||
|
'InputItem',
|
||||||
|
'SelectItem',
|
||||||
|
'TagsItem',
|
||||||
|
'TagsSelectizeItem',
|
||||||
|
'TextAreaItem',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type AnyWritableComponents = (typeof ANY_WRITABLE_COMPONENTS)[number]
|
||||||
|
type ItemComponentToItemProps = {
|
||||||
|
// WRITABLE
|
||||||
|
AdressItem: AdressItemProps
|
||||||
|
CheckboxItem: CheckboxItemProps
|
||||||
|
FileItem: FileItemProps
|
||||||
|
InputItem: InputItemProps
|
||||||
|
SelectItem: SelectItemProps
|
||||||
|
TagsItem: TagsItemProps
|
||||||
|
TagsSelectizeItem: TagsSelectizeItemProps
|
||||||
|
TextAreaItem: TextAreaItemProps
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormFieldRules<MV extends any> = MV extends object
|
||||||
|
? MV extends any[]
|
||||||
|
? ValidationArgs<FormFieldRules<ArrInnerType<MV>>>
|
||||||
|
: ValidationArgs<MV | Partial<MV>>
|
||||||
|
: ValidationRuleCollection<MV>
|
||||||
|
|
||||||
|
type BaseFormFieldComputedProps<MV extends any = any> = {
|
||||||
|
modelValue?: MV
|
||||||
|
validation?: BaseValidation
|
||||||
|
}
|
||||||
|
|
||||||
|
type BaseFormField<C extends AnyItemComponents> = {
|
||||||
|
component: C
|
||||||
|
hr?: boolean
|
||||||
|
id?: string
|
||||||
|
label?: string
|
||||||
|
props?: ItemComponentToItemProps[C]
|
||||||
|
readonly?: boolean
|
||||||
|
// FIXME compute visible JSExpression
|
||||||
|
visible?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FormField<
|
||||||
|
C extends AnyWritableComponents = AnyWritableComponents,
|
||||||
|
MV extends any = any,
|
||||||
|
> = BaseFormField<C> & {
|
||||||
|
append?: string
|
||||||
|
asInputGroup?: boolean
|
||||||
|
description?: string
|
||||||
|
descriptionVariant?: StateVariant
|
||||||
|
labelFor?: string
|
||||||
|
link?:
|
||||||
|
| { text: string; name: RouteLocationRaw }
|
||||||
|
| { text: string; href: string }
|
||||||
|
props: ItemComponentToItemProps[C]
|
||||||
|
rules?: FormFieldRules<MV>
|
||||||
|
prepend?: string
|
||||||
|
readonly?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FormFieldProps<
|
||||||
|
C extends AnyWritableComponents,
|
||||||
|
MV extends any,
|
||||||
|
> = Omit<FormField<C, MV>, 'hr' | 'visible' | 'readonly'> &
|
||||||
|
BaseFormFieldComputedProps<MV>
|
||||||
|
|
Loading…
Reference in a new issue