mirror of
https://github.com/YunoHost/yunohost-admin.git
synced 2024-09-03 20:06:15 +02:00
fix: form types & useForm fields rules reactivity
This commit is contained in:
parent
1b14c78195
commit
931e74ff29
7 changed files with 126 additions and 92 deletions
|
@ -11,6 +11,7 @@ import type {
|
||||||
AnyDisplayComponents,
|
AnyDisplayComponents,
|
||||||
AnyWritableComponents,
|
AnyWritableComponents,
|
||||||
BaseItemComputedProps,
|
BaseItemComputedProps,
|
||||||
|
ButtonItemProps,
|
||||||
FormFieldDict,
|
FormFieldDict,
|
||||||
} from '@/types/form'
|
} from '@/types/form'
|
||||||
import { isDisplayComponent, isWritableComponent } from '@/types/form'
|
import { isDisplayComponent, isWritableComponent } from '@/types/form'
|
||||||
|
@ -62,9 +63,9 @@ const slots = defineSlots<
|
||||||
} & {
|
} & {
|
||||||
[K in KeyOfStr<FFD> as `component:${K}`]?: (
|
[K in KeyOfStr<FFD> as `component:${K}`]?: (
|
||||||
_: FFD[K]['component'] extends AnyWritableComponents
|
_: FFD[K]['component'] extends AnyWritableComponents
|
||||||
? FFD[K]['props'] & BaseItemComputedProps<MV[K]>
|
? FFD[K]['cProps'] & BaseItemComputedProps<MV[K]>
|
||||||
: FFD[K]['component'] extends AnyDisplayComponents
|
: FFD[K]['component'] extends AnyDisplayComponents
|
||||||
? FFD[K]['props']
|
? FFD[K]['cProps']
|
||||||
: never,
|
: never,
|
||||||
) => any
|
) => any
|
||||||
}
|
}
|
||||||
|
@ -135,17 +136,17 @@ const Fields = createReusableTemplate<{
|
||||||
<slot
|
<slot
|
||||||
v-else-if="isDisplayComponent(field)"
|
v-else-if="isDisplayComponent(field)"
|
||||||
:name="`component:${k}`"
|
:name="`component:${k}`"
|
||||||
v-bind="field.props"
|
v-bind="field.cProps"
|
||||||
>
|
>
|
||||||
<Component
|
<Component
|
||||||
:is="field.component"
|
:is="field.component"
|
||||||
v-if="field.component !== 'ButtonItem'"
|
v-if="field.component !== 'ButtonItem'"
|
||||||
v-bind="field.props"
|
v-bind="field.cProps"
|
||||||
/>
|
/>
|
||||||
<ButtonItem
|
<ButtonItem
|
||||||
v-else
|
v-else
|
||||||
v-bind="field.props"
|
v-bind="field.cProps as ButtonItemProps"
|
||||||
@action="emit('action', $event as typeof field.props.id)"
|
@action="emit('action', $event as KeyOfStr<FFD>)"
|
||||||
/>
|
/>
|
||||||
</slot>
|
</slot>
|
||||||
|
|
||||||
|
@ -181,12 +182,13 @@ const Fields = createReusableTemplate<{
|
||||||
{{ section.name }}
|
{{ section.name }}
|
||||||
<small v-if="section.help">{{ section.help }}</small>
|
<small v-if="section.help">{{ section.help }}</small>
|
||||||
</BCardTitle>
|
</BCardTitle>
|
||||||
|
<!-- @vue-ignore-next-line -->
|
||||||
<Fields.reuse :fields-props="section.fields" />
|
<Fields.reuse :fields-props="section.fields" />
|
||||||
</Component>
|
</Component>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="fields">
|
<template v-else-if="fields">
|
||||||
|
<!-- @vue-ignore-next-line -->
|
||||||
<Fields.reuse :fields-props="fields" />
|
<Fields.reuse :fields-props="fields" />
|
||||||
</template>
|
</template>
|
||||||
</slot>
|
</slot>
|
||||||
|
|
|
@ -14,6 +14,7 @@ import type {
|
||||||
BaseItemComputedProps,
|
BaseItemComputedProps,
|
||||||
FormField,
|
FormField,
|
||||||
FormFieldProps,
|
FormFieldProps,
|
||||||
|
ItemComponentToItemProps,
|
||||||
} from '@/types/form'
|
} from '@/types/form'
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
|
@ -24,6 +25,8 @@ defineOptions({
|
||||||
const props = withDefaults(defineProps<FormFieldProps<C, MV>>(), {
|
const props = withDefaults(defineProps<FormFieldProps<C, MV>>(), {
|
||||||
append: undefined,
|
append: undefined,
|
||||||
asInputGroup: false,
|
asInputGroup: false,
|
||||||
|
component: undefined,
|
||||||
|
cProps: undefined,
|
||||||
description: undefined,
|
description: undefined,
|
||||||
descriptionVariant: undefined,
|
descriptionVariant: undefined,
|
||||||
id: undefined,
|
id: undefined,
|
||||||
|
@ -43,7 +46,7 @@ defineEmits<{
|
||||||
|
|
||||||
const slots = defineSlots<{
|
const slots = defineSlots<{
|
||||||
default?: (
|
default?: (
|
||||||
componentProps: FormField<C, MV>['props'] & BaseItemComputedProps<MV>,
|
componentProps: FormField<C, MV>['cProps'] & BaseItemComputedProps<MV>,
|
||||||
) => any
|
) => any
|
||||||
description?: any
|
description?: any
|
||||||
}>()
|
}>()
|
||||||
|
@ -86,7 +89,7 @@ const computedAttrs = computed(() => {
|
||||||
|
|
||||||
const id = computed(() => {
|
const id = computed(() => {
|
||||||
if (props.id) return props.id
|
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
|
return childId ? `${childId}-field` : undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -132,7 +135,7 @@ const [DefineTemplate, ReuseTemplate] = createReusableTemplate<{
|
||||||
<!-- Make field props and state available as scoped slot data -->
|
<!-- Make field props and state available as scoped slot data -->
|
||||||
<slot
|
<slot
|
||||||
v-bind="{
|
v-bind="{
|
||||||
...props.props,
|
...(props.cProps ?? ({} as ItemComponentToItemProps[C])),
|
||||||
ariaDescribedby,
|
ariaDescribedby,
|
||||||
modelValue: props.modelValue,
|
modelValue: props.modelValue,
|
||||||
state,
|
state,
|
||||||
|
@ -141,7 +144,7 @@ const [DefineTemplate, ReuseTemplate] = createReusableTemplate<{
|
||||||
>
|
>
|
||||||
<!-- 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.cProps"
|
||||||
:is="component"
|
:is="component"
|
||||||
v-model="model"
|
v-model="model"
|
||||||
:aria-describedby="ariaDescribedby"
|
:aria-describedby="ariaDescribedby"
|
||||||
|
@ -156,7 +159,7 @@ const [DefineTemplate, ReuseTemplate] = createReusableTemplate<{
|
||||||
v-bind="computedAttrs"
|
v-bind="computedAttrs"
|
||||||
:id="id"
|
:id="id"
|
||||||
:label="label"
|
:label="label"
|
||||||
:label-for="labelFor || props.props?.id"
|
:label-for="labelFor || props.cProps?.id"
|
||||||
:state="state"
|
:state="state"
|
||||||
>
|
>
|
||||||
<template #default="{ ariaDescribedby }">
|
<template #default="{ ariaDescribedby }">
|
||||||
|
|
|
@ -13,6 +13,7 @@ import type {
|
||||||
BaseItemComputedProps,
|
BaseItemComputedProps,
|
||||||
FormField,
|
FormField,
|
||||||
FormFieldProps,
|
FormFieldProps,
|
||||||
|
ItemComponentToItemProps,
|
||||||
} from '@/types/form'
|
} from '@/types/form'
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
|
@ -23,13 +24,15 @@ defineOptions({
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<
|
defineProps<
|
||||||
FormFieldProps<C, MV> & {
|
FormFieldProps<C, MV> & {
|
||||||
defaultValue: () => ArrInnerType<MV>
|
defaultValue?: () => ArrInnerType<MV>
|
||||||
addBtnText: string
|
addBtnText?: string
|
||||||
}
|
}
|
||||||
>(),
|
>(),
|
||||||
{
|
{
|
||||||
append: undefined,
|
append: undefined,
|
||||||
asInputGroup: false,
|
asInputGroup: false,
|
||||||
|
component: undefined,
|
||||||
|
cProps: undefined,
|
||||||
description: undefined,
|
description: undefined,
|
||||||
descriptionVariant: undefined,
|
descriptionVariant: undefined,
|
||||||
id: undefined,
|
id: undefined,
|
||||||
|
@ -38,6 +41,8 @@ const props = withDefaults(
|
||||||
link: undefined,
|
link: undefined,
|
||||||
prepend: undefined,
|
prepend: undefined,
|
||||||
rules: undefined,
|
rules: undefined,
|
||||||
|
defaultValue: undefined,
|
||||||
|
addBtnText: undefined,
|
||||||
|
|
||||||
modelValue: undefined,
|
modelValue: undefined,
|
||||||
validation: undefined,
|
validation: undefined,
|
||||||
|
@ -50,7 +55,7 @@ const emit = defineEmits<{
|
||||||
|
|
||||||
const slots = defineSlots<{
|
const slots = defineSlots<{
|
||||||
default?: (_: {
|
default?: (_: {
|
||||||
componentProps: FormField<C, ArrInnerType<MV>>['props'] &
|
componentProps: FormField<C, ArrInnerType<MV>>['cProps'] &
|
||||||
BaseItemComputedProps<ArrInnerType<MV>>
|
BaseItemComputedProps<ArrInnerType<MV>>
|
||||||
index: number
|
index: number
|
||||||
}) => any
|
}) => any
|
||||||
|
@ -86,7 +91,7 @@ const computedAttrs = computed(() => {
|
||||||
|
|
||||||
const id = computed(() => {
|
const id = computed(() => {
|
||||||
if (props.id) return props.id
|
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(() => {
|
const error = computed(() => {
|
||||||
|
@ -101,9 +106,9 @@ const subProps = computed<FormFieldProps<C, ArrInnerType<MV>>[]>(() => {
|
||||||
return (
|
return (
|
||||||
props.modelValue?.map((modelValue: ArrInnerType<MV>, i) => {
|
props.modelValue?.map((modelValue: ArrInnerType<MV>, i) => {
|
||||||
return {
|
return {
|
||||||
props: {
|
cProps: {
|
||||||
...props.props,
|
...(props.cProps ?? ({} as ItemComponentToItemProps[C])),
|
||||||
id: `${props.props.id}.${i}`,
|
id: `${props.cProps?.id}.${i}`,
|
||||||
},
|
},
|
||||||
validation: props.validation?.[i],
|
validation: props.validation?.[i],
|
||||||
modelValue,
|
modelValue,
|
||||||
|
@ -138,7 +143,7 @@ const errorMessage = computed(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
function addElement() {
|
function addElement() {
|
||||||
const value = [...(props?.modelValue || []), props.defaultValue()] as MV
|
const value = [...(props?.modelValue || []), props.defaultValue!()] as MV
|
||||||
emit('update:modelValue', value)
|
emit('update:modelValue', value)
|
||||||
|
|
||||||
// FIXME: Focus newly inserted form item
|
// FIXME: Focus newly inserted form item
|
||||||
|
@ -172,16 +177,25 @@ function updateElement(index: number, newValue: ArrInnerType<MV>) {
|
||||||
</template>
|
</template>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<BButton variant="danger" @click="removeElement(index)">
|
<BButton
|
||||||
|
v-if="defaultValue !== undefined"
|
||||||
|
variant="danger"
|
||||||
|
@click="removeElement(index)"
|
||||||
|
>
|
||||||
<YIcon :title="$t('delete')" iname="trash-o" />
|
<YIcon :title="$t('delete')" iname="trash-o" />
|
||||||
<span class="visually-hidden">{{ $t('delete') }}</span>
|
<span class="visually-hidden">{{ $t('delete') }}</span>
|
||||||
</BButton>
|
</BButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BButton variant="success" @click="addElement()">
|
<BButton
|
||||||
<YIcon iname="plus" /> {{ $t('user_emailaliases_add') }}
|
v-if="defaultValue !== undefined"
|
||||||
|
variant="success"
|
||||||
|
@click="addElement()"
|
||||||
|
>
|
||||||
|
<YIcon iname="plus" /> {{ addBtnText ?? $t('add') }}
|
||||||
</BButton>
|
</BButton>
|
||||||
|
|
||||||
|
<!-- FIXME is it needed? or more generic error like "errors in this multiple fields" -->
|
||||||
<template #invalid-feedback>
|
<template #invalid-feedback>
|
||||||
<span v-html="errorMessage" />
|
<span v-html="errorMessage" />
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -4,14 +4,13 @@ import { computed, toValue } from 'vue'
|
||||||
import type { ButtonItemProps } from '@/types/form'
|
import type { ButtonItemProps } from '@/types/form'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<ButtonItemProps>(), {
|
const props = withDefaults(defineProps<ButtonItemProps>(), {
|
||||||
id: undefined,
|
|
||||||
enabled: true,
|
enabled: true,
|
||||||
icon: undefined,
|
icon: undefined,
|
||||||
type: 'success',
|
type: 'success',
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
action: [value: ButtonItemProps['id']]
|
action: [value: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const icon = computed(() => {
|
const icon = computed(() => {
|
||||||
|
|
|
@ -6,7 +6,6 @@ import type {
|
||||||
WritableComputedRef,
|
WritableComputedRef,
|
||||||
} from 'vue'
|
} from 'vue'
|
||||||
import { computed, ref, toValue, watch } from 'vue'
|
import { computed, ref, toValue, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { APIBadRequestError, APIError } from '@/api/errors'
|
import { APIBadRequestError, APIError } from '@/api/errors'
|
||||||
|
@ -14,6 +13,7 @@ import { deepSetErrors, useForm, type FormValidation } from '@/composables/form'
|
||||||
import { isObjectLiteral } from '@/helpers/commons'
|
import { isObjectLiteral } from '@/helpers/commons'
|
||||||
import * as validators from '@/helpers/validators'
|
import * as validators from '@/helpers/validators'
|
||||||
import { formatForm, formatI18nField } from '@/helpers/yunohostArguments'
|
import { formatForm, formatI18nField } from '@/helpers/yunohostArguments'
|
||||||
|
import i18n from '@/i18n'
|
||||||
import type { CustomRoute, KeyOfStr, MergeUnion, Obj } from '@/types/commons'
|
import type { CustomRoute, KeyOfStr, MergeUnion, Obj } from '@/types/commons'
|
||||||
import type {
|
import type {
|
||||||
AnyFormField,
|
AnyFormField,
|
||||||
|
@ -96,23 +96,24 @@ function formatOption(option: AnyOption, form: Ref<Obj>): AnyFormField {
|
||||||
|
|
||||||
if (isIn(ANY_DISPLAY_OPTION_TYPE, option)) {
|
if (isIn(ANY_DISPLAY_OPTION_TYPE, option)) {
|
||||||
const component = OPTION_COMPONENT_RESOLVER[option.type]
|
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.
|
// but this is not type safe.
|
||||||
const props = {
|
const cProps = {
|
||||||
label: formatI18nField(option.ask),
|
label: formatI18nField(option.ask),
|
||||||
id: option.id,
|
id: option.id,
|
||||||
} as MergeUnion<AnyDisplayItemProps>
|
} as MergeUnion<AnyDisplayItemProps>
|
||||||
const field: FormFieldDisplay<typeof component> = {
|
const field: FormFieldDisplay<typeof component> = {
|
||||||
component,
|
component,
|
||||||
visible,
|
visible,
|
||||||
props,
|
cProps,
|
||||||
|
rules: undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isIn(['button', 'alert'], option)) {
|
if (isIn(['button', 'alert'], option)) {
|
||||||
props.type = option.style
|
cProps.type = option.style
|
||||||
props.icon = option.icon
|
cProps.icon = option.icon
|
||||||
if (option.type === 'button') {
|
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]
|
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.
|
// but this is not type safe.
|
||||||
const props = {
|
const cProps = {
|
||||||
id: option.id,
|
id: option.id,
|
||||||
placeholder: option.example,
|
placeholder: option.example,
|
||||||
} as MergeUnion<AnyWritableItemProps>
|
} as MergeUnion<AnyWritableItemProps>
|
||||||
const rules: FormField['rules'] = {}
|
const rules: FormField['rules'] = {}
|
||||||
const field:
|
const field: FormField<typeof component> = {
|
||||||
| FormField<typeof component>
|
|
||||||
| FormFieldReadonly<typeof component> = {
|
|
||||||
component,
|
component,
|
||||||
label: formatI18nField(option.ask),
|
label: formatI18nField(option.ask),
|
||||||
props,
|
rules: option.readonly ? undefined : rules,
|
||||||
readonly: option.readonly,
|
|
||||||
rules,
|
|
||||||
visible,
|
visible,
|
||||||
description: formatI18nField(option.help),
|
description: formatI18nField(option.help),
|
||||||
}
|
}
|
||||||
|
|
||||||
// We don't care about component props in case of readonly
|
// 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)) {
|
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
|
// trim
|
||||||
// autocomplete
|
// autocomplete
|
||||||
|
|
||||||
if (option.type === 'password') {
|
if (option.type === 'password') {
|
||||||
field.description ??= t('good_practices_about_admin_password')
|
field.description ??= t('good_practices_about_admin_password')
|
||||||
rules.passwordLenght = validators.minLength(8)
|
rules.passwordLenght = validators.minLength(8)
|
||||||
props.placeholder = '••••••••••••'
|
cProps.placeholder = '••••••••••••'
|
||||||
} else if (isIn(['number', 'range'], option)) {
|
} else if (isIn(['number', 'range'], option)) {
|
||||||
rules.numValue = validators.integer
|
rules.numValue = validators.integer
|
||||||
props.step = option.step
|
cProps.step = option.step
|
||||||
|
|
||||||
if (option.min !== undefined) {
|
if (option.min !== undefined) {
|
||||||
rules.minValue = validators.minValue(option.min)
|
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)) {
|
} 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]) => ({
|
? Object.entries(option.choices).map(([k, v]) => ({
|
||||||
text: v,
|
text: v,
|
||||||
value: k,
|
value: k,
|
||||||
|
@ -182,23 +183,23 @@ function formatOption(option: AnyOption, form: Ref<Obj>): AnyFormField {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (isIn(['tags', 'tags-select'], option)) {
|
} else if (isIn(['tags', 'tags-select'], option)) {
|
||||||
// props.limit = option.limit // FIXME limit is not defined in core?
|
// cProps.limit = option.limit // FIXME limit is not defined in core?
|
||||||
props.placeholder = option.placeholder
|
cProps.placeholder = option.placeholder
|
||||||
props.tagIcon = option.icon
|
cProps.tagIcon = option.icon
|
||||||
|
|
||||||
if ('tags-select' === option.type) {
|
if ('tags-select' === option.type) {
|
||||||
props.options = option.choices
|
cProps.options = option.choices
|
||||||
props.auto = true
|
cProps.auto = true
|
||||||
props.itemsName = ''
|
cProps.itemsName = ''
|
||||||
props.label = option.placeholder
|
cProps.label = option.placeholder
|
||||||
}
|
}
|
||||||
} else if ('boolean' === option.type) {
|
} else if ('boolean' === option.type) {
|
||||||
// FIXME
|
// FIXME
|
||||||
// props.choices = option.choices
|
// cProps.choices = option.choices
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('file' === option.type) {
|
if ('file' === option.type) {
|
||||||
props.accept = option.accept
|
cProps.accept = option.accept
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('boolean' !== option.type && option.optional === false) {
|
if ('boolean' !== option.type && option.optional === false) {
|
||||||
|
@ -233,6 +234,7 @@ export function formatOptions<MV extends Obj>(
|
||||||
fields: FormFieldDict<MV>
|
fields: FormFieldDict<MV>
|
||||||
form: Ref<MV>
|
form: Ref<MV>
|
||||||
} {
|
} {
|
||||||
|
// FIXME handle optional for app install ? or is already handled in core bookworm?
|
||||||
const form = ref(
|
const form = ref(
|
||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
options
|
options
|
||||||
|
@ -373,7 +375,6 @@ function useEvaluation(expression: string, form: MaybeRefOrGetter<Obj>) {
|
||||||
|
|
||||||
return computed(() => {
|
return computed(() => {
|
||||||
const { exp, ctx } = buildContext(toValue(form))
|
const { exp, ctx } = buildContext(toValue(form))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return !!evaluate(ctx, exp)
|
return !!evaluate(ctx, exp)
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
|
// eslint-disable-next-line vue/prefer-import-from-vue
|
||||||
|
import { isFunction } from '@vue/shared'
|
||||||
import type {
|
import type {
|
||||||
BaseValidation,
|
BaseValidation,
|
||||||
ServerErrors,
|
ServerErrors,
|
||||||
Validation,
|
Validation,
|
||||||
ValidationArgs,
|
ValidationArgs,
|
||||||
ValidationRuleCollection,
|
|
||||||
} from '@vuelidate/core'
|
} from '@vuelidate/core'
|
||||||
import useVuelidate from '@vuelidate/core'
|
import useVuelidate from '@vuelidate/core'
|
||||||
import { computedWithControl } from '@vueuse/core'
|
import { watchImmediate } from '@vueuse/core'
|
||||||
import type {
|
import type {
|
||||||
ComputedRef,
|
ComputedRef,
|
||||||
InjectionKey,
|
InjectionKey,
|
||||||
|
@ -14,11 +15,11 @@ import type {
|
||||||
Ref,
|
Ref,
|
||||||
WritableComputedRef,
|
WritableComputedRef,
|
||||||
} from 'vue'
|
} 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 { APIBadRequestError, type APIError } from '@/api/errors'
|
||||||
import type { Obj } from '@/types/commons'
|
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<
|
export const clearServerErrorsSymbol = Symbol() as InjectionKey<
|
||||||
(key?: string) => void
|
(key?: string) => void
|
||||||
|
@ -57,25 +58,32 @@ export type FormValidation<MV extends Obj> = Validation<
|
||||||
export function useForm<
|
export function useForm<
|
||||||
MV extends Obj,
|
MV extends Obj,
|
||||||
FFD extends FormFieldDict<MV> = FormFieldDict<MV>,
|
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 serverErrors = reactive<ServerErrors>({})
|
||||||
const validByDefault: ValidationRuleCollection = { true: () => true }
|
const validByDefault = { true: () => true as const }
|
||||||
const rules = computedWithControl(
|
// create a fake validation rule for global state to be able to add $externalResult errors to it
|
||||||
() => toValue(fields),
|
const rules = ref({ global: validByDefault, form: {} }) as Ref<{
|
||||||
() => {
|
global: { true: () => true }
|
||||||
const fs = toValue(fields)
|
form: ValidationArgs<MV>
|
||||||
const validations = Object.keys(form.value).map((key: keyof MV) => [
|
}>
|
||||||
key,
|
function updateRules(ffd: FFD) {
|
||||||
(fs[key] as FormField).rules ?? validByDefault,
|
const validations = Object.keys(form.value).map((key: keyof MV) => [
|
||||||
])
|
key,
|
||||||
const rules: ValidationArgs<MV> = Object.fromEntries(validations)
|
ffd[key].rules ?? validByDefault,
|
||||||
return {
|
])
|
||||||
// create a fake validation rule for global state to be able to add $externalResult errors to it
|
const formRules: ValidationArgs<MV> = Object.fromEntries(validations)
|
||||||
global: { true: () => true },
|
rules.value = { global: { true: () => true }, form: formRules }
|
||||||
form: rules,
|
}
|
||||||
}
|
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(
|
const v: Ref<FormValidation<MV>> = useVuelidate(
|
||||||
rules,
|
rules,
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { isObjectLiteral } from '@/helpers/commons'
|
||||||
import type { ArrInnerType, Cols, Obj, StateVariant } from '@/types/commons'
|
import type { ArrInnerType, Cols, Obj, StateVariant } from '@/types/commons'
|
||||||
|
|
||||||
type StateValidation = false | null
|
type StateValidation = false | null
|
||||||
|
type Choices = string[] | { text: string; value: string }[]
|
||||||
|
|
||||||
// DISPLAY
|
// DISPLAY
|
||||||
|
|
||||||
|
@ -20,6 +21,7 @@ type BaseDisplayItemProps = {
|
||||||
|
|
||||||
export type ButtonItemProps = BaseDisplayItemProps & {
|
export type ButtonItemProps = BaseDisplayItemProps & {
|
||||||
// FIXME compute enabled JSExpression
|
// FIXME compute enabled JSExpression
|
||||||
|
id: string
|
||||||
enabled?: boolean | ComputedRef<boolean>
|
enabled?: boolean | ComputedRef<boolean>
|
||||||
icon?: string
|
icon?: string
|
||||||
type?: StateVariant
|
type?: StateVariant
|
||||||
|
@ -41,6 +43,7 @@ type BaseWritableItemProps = {
|
||||||
name?: string
|
name?: string
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
touchKey?: string
|
touchKey?: string
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BaseItemComputedProps<MV extends any = any> = {
|
export type BaseItemComputedProps<MV extends any = any> = {
|
||||||
|
@ -51,7 +54,7 @@ export type BaseItemComputedProps<MV extends any = any> = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AdressItemProps = BaseWritableItemProps & {
|
export type AdressItemProps = BaseWritableItemProps & {
|
||||||
choices: string[]
|
choices: Choices
|
||||||
type?: 'domain' | 'email'
|
type?: 'domain' | 'email'
|
||||||
}
|
}
|
||||||
export type AdressModelValue = {
|
export type AdressModelValue = {
|
||||||
|
@ -64,7 +67,7 @@ export type CheckboxItemProps = BaseWritableItemProps & {
|
||||||
label?: string
|
label?: string
|
||||||
labels?: { true: string; false: string }
|
labels?: { true: string; false: string }
|
||||||
// FIXME unused?
|
// FIXME unused?
|
||||||
// choices: string[]
|
// choices: Choices
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FileItemProps = BaseWritableItemProps & {
|
export type FileItemProps = BaseWritableItemProps & {
|
||||||
|
@ -89,7 +92,7 @@ export type InputItemProps = BaseWritableItemProps & {
|
||||||
| 'current-password'
|
| 'current-password'
|
||||||
| 'url'
|
| 'url'
|
||||||
// pattern?: object
|
// pattern?: object
|
||||||
// choices?: string[] FIXME rm ?
|
// choices?: Choices FIXME rm ?
|
||||||
step?: number
|
step?: number
|
||||||
trim?: boolean
|
trim?: boolean
|
||||||
type?:
|
type?:
|
||||||
|
@ -107,7 +110,7 @@ export type InputItemProps = BaseWritableItemProps & {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SelectItemProps = BaseWritableItemProps & {
|
export type SelectItemProps = BaseWritableItemProps & {
|
||||||
choices: string[] | { text: string; value: string }[]
|
choices: Choices
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TagsItemProps = BaseWritableItemProps & {
|
export type TagsItemProps = BaseWritableItemProps & {
|
||||||
|
@ -205,7 +208,7 @@ export function isNonWritableComponent(
|
||||||
return isDisplayComponent(field) || !!field.readonly
|
return isDisplayComponent(field) || !!field.readonly
|
||||||
}
|
}
|
||||||
|
|
||||||
type ItemComponentToItemProps = {
|
export type ItemComponentToItemProps = {
|
||||||
// DISPLAY
|
// DISPLAY
|
||||||
ButtonItem: ButtonItemProps
|
ButtonItem: ButtonItemProps
|
||||||
DisplayTextItem: DisplayTextItemProps
|
DisplayTextItem: DisplayTextItemProps
|
||||||
|
@ -234,11 +237,11 @@ type BaseFormFieldComputedProps<MV extends any = any> = {
|
||||||
}
|
}
|
||||||
|
|
||||||
type BaseFormField<C extends AnyItemComponents> = {
|
type BaseFormField<C extends AnyItemComponents> = {
|
||||||
component: C
|
component?: C
|
||||||
|
cProps?: ItemComponentToItemProps[C]
|
||||||
hr?: boolean
|
hr?: boolean
|
||||||
id?: string
|
id?: string
|
||||||
label?: string
|
label?: string
|
||||||
props?: ItemComponentToItemProps[C]
|
|
||||||
readonly?: boolean
|
readonly?: boolean
|
||||||
visible?: boolean | ComputedRef<boolean>
|
visible?: boolean | ComputedRef<boolean>
|
||||||
}
|
}
|
||||||
|
@ -249,14 +252,14 @@ export type FormField<
|
||||||
> = BaseFormField<C> & {
|
> = BaseFormField<C> & {
|
||||||
append?: string
|
append?: string
|
||||||
asInputGroup?: boolean
|
asInputGroup?: boolean
|
||||||
|
cProps?: ItemComponentToItemProps[C]
|
||||||
description?: string
|
description?: string
|
||||||
descriptionVariant?: StateVariant
|
descriptionVariant?: StateVariant
|
||||||
labelFor?: string
|
labelFor?: string
|
||||||
link?:
|
link?:
|
||||||
| { text: string; name: RouteLocationRaw }
|
| { text: string; name: RouteLocationRaw }
|
||||||
| { text: string; href: string }
|
| { text: string; href: string }
|
||||||
props: ItemComponentToItemProps[C]
|
rules?: FormFieldRules<MV> | ComputedRef<FormFieldRules<MV>>
|
||||||
rules?: FormFieldRules<MV>
|
|
||||||
prepend?: string
|
prepend?: string
|
||||||
readonly?: false
|
readonly?: false
|
||||||
}
|
}
|
||||||
|
@ -267,16 +270,18 @@ export type FormFieldReadonly<
|
||||||
label: string
|
label: string
|
||||||
cols?: Cols
|
cols?: Cols
|
||||||
readonly: true
|
readonly: true
|
||||||
|
rules: undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FormFieldDisplay<
|
export type FormFieldDisplay<
|
||||||
C extends AnyDisplayComponents = AnyDisplayComponents,
|
C extends AnyDisplayComponents = AnyDisplayComponents,
|
||||||
> = {
|
> = {
|
||||||
component: C
|
component?: C
|
||||||
props: ItemComponentToItemProps[C]
|
cProps?: ItemComponentToItemProps[C]
|
||||||
visible?: boolean | ComputedRef<boolean>
|
visible?: boolean | ComputedRef<boolean>
|
||||||
hr?: boolean
|
hr?: boolean
|
||||||
readonly?: true
|
readonly?: true
|
||||||
|
rules: undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FormFieldProps<
|
export type FormFieldProps<
|
||||||
|
@ -305,12 +310,14 @@ export type FormFieldDict<T extends Obj = Obj> = {
|
||||||
|
|
||||||
// Type to check if object satisfies specified Field and Item
|
// Type to check if object satisfies specified Field and Item
|
||||||
export type FieldProps<
|
export type FieldProps<
|
||||||
C extends AnyItemComponents = 'InputItem',
|
C extends AnyItemComponents = AnyItemComponents,
|
||||||
MV extends any = never,
|
MV extends any = never,
|
||||||
> = C extends AnyWritableComponents
|
> = C extends AnyWritableComponents
|
||||||
? FormField<C, MV> | FormFieldReadonly<C>
|
?
|
||||||
|
| (FormField<C, MV> & { component: C })
|
||||||
|
| (FormFieldReadonly<C> & { component: C })
|
||||||
: C extends AnyDisplayComponents
|
: C extends AnyDisplayComponents
|
||||||
? FormFieldDisplay<C>
|
? FormFieldDisplay<C> & { component: C }
|
||||||
: never
|
: never
|
||||||
|
|
||||||
export function isFileModelValue(value: any): value is FileModelValue {
|
export function isFileModelValue(value: any): value is FileModelValue {
|
||||||
|
|
Loading…
Reference in a new issue