diff --git a/app/src/components/globals/CardForm.vue b/app/src/components/globals/CardForm.vue index 057feff6..2f91906d 100644 --- a/app/src/components/globals/CardForm.vue +++ b/app/src/components/globals/CardForm.vue @@ -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 as `component:${K}`]?: ( _: FFD[K]['component'] extends AnyWritableComponents - ? FFD[K]['props'] & BaseItemComputedProps + ? FFD[K]['cProps'] & BaseItemComputedProps : FFD[K]['component'] extends AnyDisplayComponents - ? FFD[K]['props'] + ? FFD[K]['cProps'] : never, ) => any } @@ -135,17 +136,17 @@ const Fields = createReusableTemplate<{ @@ -181,12 +182,13 @@ const Fields = createReusableTemplate<{ {{ section.name }} {{ section.help }} - + diff --git a/app/src/components/globals/FormField.vue b/app/src/components/globals/FormField.vue index cc2867a2..25ea0bc3 100644 --- a/app/src/components/globals/FormField.vue +++ b/app/src/components/globals/FormField.vue @@ -14,6 +14,7 @@ import type { BaseItemComputedProps, FormField, FormFieldProps, + ItemComponentToItemProps, } from '@/types/form' defineOptions({ @@ -24,6 +25,8 @@ defineOptions({ const props = withDefaults(defineProps>(), { 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['props'] & BaseItemComputedProps, + componentProps: FormField['cProps'] & BaseItemComputedProps, ) => 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<{ - + {{ $t('delete') }} - - {{ $t('user_emailaliases_add') }} + + {{ addBtnText ?? $t('add') }} + diff --git a/app/src/components/globals/formItems/ButtonItem.vue b/app/src/components/globals/formItems/ButtonItem.vue index 29efd32d..f104384a 100644 --- a/app/src/components/globals/formItems/ButtonItem.vue +++ b/app/src/components/globals/formItems/ButtonItem.vue @@ -4,14 +4,13 @@ import { computed, toValue } from 'vue' import type { ButtonItemProps } from '@/types/form' const props = withDefaults(defineProps(), { - id: undefined, enabled: true, icon: undefined, type: 'success', }) const emit = defineEmits<{ - action: [value: ButtonItemProps['id']] + action: [value: string] }>() const icon = computed(() => { diff --git a/app/src/composables/configPanels.ts b/app/src/composables/configPanels.ts index 67ce47c5..5b2f1854 100644 --- a/app/src/composables/configPanels.ts +++ b/app/src/composables/configPanels.ts @@ -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): 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 const field: FormFieldDisplay = { 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): 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 const rules: FormField['rules'] = {} - const field: - | FormField - | FormFieldReadonly = { + const field: FormField = { 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 + } 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): 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): 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( fields: FormFieldDict form: Ref } { + // 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) { return computed(() => { const { exp, ctx } = buildContext(toValue(form)) - try { return !!evaluate(ctx, exp) } catch { diff --git a/app/src/composables/form.ts b/app/src/composables/form.ts index 8f9bca4b..d5ee06d6 100644 --- a/app/src/composables/form.ts +++ b/app/src/composables/form.ts @@ -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 = Validation< export function useForm< MV extends Obj, FFD extends FormFieldDict = FormFieldDict, ->(form: Ref | WritableComputedRef, fields: MaybeRefOrGetter) { +>(form: Ref | WritableComputedRef, fields: FFD | (() => FFD)) { const serverErrors = reactive({}) - 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 = 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 + }> + function updateRules(ffd: FFD) { + const validations = Object.keys(form.value).map((key: keyof MV) => [ + key, + ffd[key].rules ?? validByDefault, + ]) + const formRules: ValidationArgs = 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> = useVuelidate( rules, diff --git a/app/src/types/form.ts b/app/src/types/form.ts index 82c63341..f28ad2d8 100644 --- a/app/src/types/form.ts +++ b/app/src/types/form.ts @@ -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 icon?: string type?: StateVariant @@ -41,6 +43,7 @@ type BaseWritableItemProps = { name?: string placeholder?: string touchKey?: string + disabled?: boolean } export type BaseItemComputedProps = { @@ -51,7 +54,7 @@ export type BaseItemComputedProps = { } 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 = { } type BaseFormField = { - component: C + component?: C + cProps?: ItemComponentToItemProps[C] hr?: boolean id?: string label?: string - props?: ItemComponentToItemProps[C] readonly?: boolean visible?: boolean | ComputedRef } @@ -249,14 +252,14 @@ export type FormField< > = BaseFormField & { 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 + rules?: FormFieldRules | ComputedRef> 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 hr?: boolean readonly?: true + rules: undefined } export type FormFieldProps< @@ -305,12 +310,14 @@ export type FormFieldDict = { // 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 | FormFieldReadonly + ? + | (FormField & { component: C }) + | (FormFieldReadonly & { component: C }) : C extends AnyDisplayComponents - ? FormFieldDisplay + ? FormFieldDisplay & { component: C } : never export function isFileModelValue(value: any): value is FileModelValue {