diff --git a/app/src/composables/configPanels.ts b/app/src/composables/configPanels.ts new file mode 100644 index 00000000..f303f82b --- /dev/null +++ b/app/src/composables/configPanels.ts @@ -0,0 +1,372 @@ +import evaluate from 'simple-evaluate' +import { computed, ref, toValue, type MaybeRefOrGetter, type Ref } from 'vue' +import { useI18n } from 'vue-i18n' + +import { asUnreffed, isObjectLiteral } from '@/helpers/commons' +import * as validators from '@/helpers/validators' +import { formatI18nField } from '@/helpers/yunohostArguments' +import type { MergeUnion, Obj } from '@/types/commons' +import type { + AnyFormField, + ConfigPanel, + ConfigPanels, +} from '@/types/configPanels' +import { OPTION_COMPONENT_RESOLVER, isIn } from '@/types/configPanels' +import type { + AnyOption, + AnyWritableOption, + CoreConfigPanel, + CoreConfigPanels, + JSExpression, +} from '@/types/core/options' +import { + ANY_DISPLAY_OPTION_TYPE, + ANY_INPUT_OPTION_TYPE, + ANY_WRITABLE_OPTION_TYPE, +} from '@/types/core/options' +import type { + AnyDisplayItemProps, + AnyWritableItemProps, + FormField, + FormFieldDict, + FormFieldDisplay, + FormFieldReadonly, +} from '@/types/form' +import { + isAdressModelValue, + isFileModelValue, + isNonWritableComponent, +} from '@/types/form' + +function formatOptionValue(option: AnyWritableOption) { + let value = option.value ?? null + + if ('tags' === option.type) { + // FIXME format in core? + if (typeof value === 'string') { + value = value.split(',') + } else if (!value) { + value = [] + } + } else if ('boolean' === option.type) { + // FIXME format in core? + if (value !== null) { + value = ['1', 'yes', 'y', 'true'].includes(String(value).toLowerCase()) + } else if (option.default !== null && option.default !== undefined) { + value = ['1', 'yes', 'y', 'true'].includes( + String(option.default).toLowerCase(), + ) + } + } else if ('file' === option.type) { + value = { + // in case of already defined file, we receive only the file path (not the actual file) + file: value ? new File([''], value) : null, + content: '', + current: !!value, + removed: false, + } + } + + if (value === null && option.default !== undefined) { + value = option.default + } + + return value +} + +/** + * Format app install and config panel Option into a Field that can be consumed + * by form field components. + * + * @param option - a core Option written by a packager + * @param form - a ref containing all related form values for expressions's evaluations + * @return Formated `FormField | FormFieldReadonly | FormFieldDisplay` props with form items props. + */ +function formatOption(option: AnyOption, form: Ref): AnyFormField { + const visible = useExpression(option.visible, form) + + 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 + // but this is not type safe. + const props = { + label: formatI18nField(option.ask), + id: option.id, + } as MergeUnion + const field: FormFieldDisplay = { + component, + visible, + props, + } + + if (isIn(['button', 'alert'], option)) { + props.type = option.style + props.icon = option.icon + if (option.type === 'button') { + props.enabled = useExpression(option.enabled, form) + } + } + + return field + } else if (isIn(ANY_WRITABLE_OPTION_TYPE, option)) { + if ('tags' === option.type && option.choices) { + // TODO: update in core directly? + option.type = 'tags-select' + } + + const component = OPTION_COMPONENT_RESOLVER[option.type] + // TODO: could be improved, for simplicity props can be be any writable item props + // but this is not type safe. + const props = { + id: option.id, + placeholder: option.example, + } as MergeUnion + const rules: FormField['rules'] = {} + const field: + | FormField + | FormFieldReadonly = { + component, + label: formatI18nField(option.ask), + props, + readonly: option.readonly, + rules, + visible, + description: formatI18nField(option.help), + } + + // We don't care about component props in case of readonly + if (field.readonly) return field + + const { t } = useI18n() + + if (isIn(ANY_INPUT_OPTION_TYPE, option)) { + props.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 = '••••••••••••' + } else if (isIn(['number', 'range'], option)) { + rules.numValue = validators.integer + props.step = option.step + + if (option.min !== undefined) { + rules.minValue = validators.minValue(option.min) + } + if (option.max !== undefined) { + rules.maxValue = validators.maxValue(option.max) + } + } + } else if (isIn(['select', 'user', 'domain', 'app', 'group'], option)) { + props.choices = isObjectLiteral(option.choices) + ? Object.entries(option.choices).map(([k, v]) => ({ + text: v, + value: k, + })) + : option.choices // FIXME rename choices to options? + if (option.type !== 'select') { + field.link = { + name: option.type + '-list', + text: t(`manage_${option.type}s`), + } + } + } 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 + + if ('tags-select' === option.type) { + props.options = option.choices + props.auto = true + props.itemsName = '' + props.label = option.placeholder + } + } else if ('boolean' === option.type) { + // FIXME + // props.choices = option.choices + } + + if ('file' === option.type) { + props.accept = option.accept + } + + if ('boolean' !== option.type && option.optional === false) { + rules.required = validators.required + } + + if (isIn(['string', 'text', 'path', 'url'], option) && option.pattern) { + rules.pattern = validators.helpers.withMessage( + formatI18nField(option.pattern.error), + validators.helpers.regex(new RegExp(option.pattern.regexp)), + ) + } + + return field + } else { + throw new TypeError( + 'Unknown Option type: ' + (option as { type: unknown }).type, + ) + } +} + +/** + * Format app install and config panel's options into a form and fields that + * can be used to populate `useForm` composable and CardForm component. + * + * @param options - a core Option array written by a packager + * @return An object with form and fields + */ +function formatOptions( + options: AnyOption[], +): { + fields: FormFieldDict + form: Ref +} { + const form = ref( + Object.fromEntries( + options + .filter((option) => isIn(ANY_WRITABLE_OPTION_TYPE, option)) + .map((option) => { + return [option.id, formatOptionValue(option as AnyWritableOption)] + }), + ), + ) as Ref + + return { + form, + fields: Object.fromEntries( + options.map((option) => [option.id, formatOption(option, form)]), + ) as FormFieldDict, + } +} + +function formatConfigPanel>( + panel: CoreConfigPanel, +): { + form: Ref + panel: ConfigPanel +} { + const options = panel.sections.flatMap((section) => section.options) + const { form, fields } = formatOptions(options) + let hasApplyButton = false + + const sections = panel.sections.map((section) => { + const sectionFieldsIds = section.options.map( + (option) => option.id, + ) as ConfigPanel['sections'][number]['fields'] + + if ( + !section.is_action_section && + sectionFieldsIds.some((id) => !isNonWritableComponent(fields[id])) + ) { + hasApplyButton = true + } + + return { + help: formatI18nField(section.help), + fields: sectionFieldsIds, + id: section.id, + isActionSection: section.is_action_section, + name: formatI18nField(section.name), + visible: useExpression(section.visible, form), + } + }) + + return { + form, + panel: { + fields, + help: formatI18nField(panel.help), + hasApplyButton, + id: panel.id, + name: formatI18nField(panel.name), + sections, + }, + } +} + +export function formatConfigPanels< + NestedMV extends Obj, + MV extends Obj, +>(config: CoreConfigPanels): ConfigPanels { + return config.panels.reduce( + (cps, panel_) => { + const { form, panel } = formatConfigPanel(panel_) + cps.forms[panel.id] = form + cps.panels.push(panel) + return cps + }, + { + forms: {} as Record>, + panels: [], + routes: config.panels.map((panel) => ({ + to: { params: { tabId: panel.id } }, + text: formatI18nField(panel.name), + icon: panel.icon || 'wrench', + })), + } as ConfigPanels, + ) +} + +function useExpression( + expression: JSExpression | undefined, + form: Ref, +): boolean { + if (typeof expression === 'boolean') return expression + if (typeof expression === 'string') { + // FIXME normalize expression in core? ('', 'false', 'true') and rm next 2 lines + if (!expression || expression === 'true') return true + if (expression === 'false') return false + // FIXME remove asUnreffed and manage ref type? + return asUnreffed(useEvaluation(expression, form)) + } + return true +} + +/** + * Evaluate config panel string expression that can contain regular expressions. + * Expressions are evaluated with the config panel's form as context. + * + * @param expression - A string expression to evaluate as a boolean + * @param form - An object to serve as evaluation context + * @return A computed boolean + */ +function useEvaluation(expression: string, form: MaybeRefOrGetter) { + function buildContext(f: Obj) { + // FIXME deepClone? + const ctx: Obj = { ...f } + let exp = expression + + for (const key in ctx) { + if (isFileModelValue(ctx[key])) { + ctx[key] = ctx[key].content + } + if (isAdressModelValue(ctx[key])) { + ctx[key] = ctx[key].value().join('') + } + } + + // Allow to use match(var,regexp) function + const matchRe = /match(\s*(\w+)\s*,\s*"([^"]+)"\s*)/g + for (const matched of expression.matchAll(matchRe)) { + const [fullMatch, varMatch, regExpMatch] = matched + const varName = varMatch + '__re' + matched.index + ctx[varName] = new RegExp(regExpMatch, 'm').test(ctx[varMatch]) + exp = expression.replace(fullMatch, varName) + } + + return { exp, ctx } + } + + return computed(() => { + const { exp, ctx } = buildContext(toValue(form)) + + try { + return !!evaluate(ctx, exp) + } catch { + return false + } + }) +} diff --git a/app/src/helpers/yunohostArguments.ts b/app/src/helpers/yunohostArguments.ts index 1fa1e304..27673809 100644 --- a/app/src/helpers/yunohostArguments.ts +++ b/app/src/helpers/yunohostArguments.ts @@ -67,394 +67,6 @@ export function adressToFormValue(address) { return { localPart, separator, domain } } -/** - * Evaluate config panel string expression that can contain regular expressions. - * Expression are evaluated with the config panel form as context. - * - * @param {String} expression - A String to evaluate. - * @param {Object} forms - A nested form used in config panels. - * @return {Boolean} - expression evaluation result. - */ -export function evaluateExpression(expression, form, nested = true) { - if (!expression) return true - if (expression === '"false"') return false - - const context = nested - ? Object.values(form).reduce((merged, next) => ({ ...merged, ...next })) - : form - - for (const key in context) { - if (isObjectLiteral(context[key]) && 'file' in context[key]) { - context[key] = context[key].content - } - } - - // Allow to use match(var,regexp) function - const matchRe = /match(\s*(\w+)\s*,\s*"([^"]+)"\s*)/g - for (const matched of expression.matchAll(matchRe)) { - const [fullMatch, varMatch, regExpMatch] = matched - const varName = varMatch + '__re' + matched.index - context[varName] = new RegExp(regExpMatch, 'm').test(context[varMatch]) - expression = expression.replace(fullMatch, varName) - } - - try { - return !!evaluate(context, expression) - } catch { - return false - } -} - -// Adds a property to an Object that will dynamically returns a expression evaluation result. -function addEvaluationGetter(prop, obj, expr, ctx, nested) { - Object.defineProperty(obj, prop, { - get: () => evaluateExpression(expr, ctx, nested), - }) -} - -/** - * Format app install, actions and config panel argument into a data structure that - * will be automaticly transformed into a component on screen. - * - * @param {Object} arg - a yunohost arg options written by a packager. - * @return {Object} an formated argument containing formItem props, validation and base value. - */ -export function formatYunoHostArgument(arg) { - let value = arg.value !== undefined ? arg.value : null - let validation = {} - arg.ask = formatI18nField(arg.ask) - const field = { - is: arg.readonly ? 'FormFieldReadonly' : 'FormField', - visible: arg.visible, - props: { - label: arg.ask, - component: undefined, - props: {}, - }, - } - - const defaultProps = ['id', 'placeholder:example'] - const components = [ - { - types: ['string', 'path'], - name: 'InputItem', - props: defaultProps.concat(['autocomplete', 'trim', 'choices']), - }, - { - types: ['email', 'url', 'date', 'time', 'color'], - name: 'InputItem', - props: defaultProps.concat(['type', 'trim']), - }, - { - types: ['password'], - name: 'InputItem', - props: defaultProps.concat(['type', 'autocomplete', 'trim']), - callback: function () { - if (!arg.help) { - arg.help = i18n.global.t('good_practices_about_admin_password') - } - arg.example = '••••••••••••' - validation.passwordLenght = validators.minLength(8) - }, - }, - { - types: ['number', 'range'], - name: 'InputItem', - props: defaultProps.concat(['type', 'min', 'max', 'step']), - callback: function () { - if (arg.min !== undefined) { - validation.minValue = validators.minValue(arg.min) - } - if (arg.max !== undefined) { - validation.maxValue = validators.maxValue(arg.max) - } - validation.numValue = validators.integer - }, - }, - { - types: ['select', 'user', 'domain', 'app', 'group'], - name: 'SelectItem', - props: ['id', 'choices'], - callback: function () { - if (arg.type !== 'select') { - field.props.link = { - name: arg.type + '-list', - text: i18n.global.t(`manage_${arg.type}s`), - } - } - }, - }, - { - types: ['file'], - name: 'FileItem', - props: defaultProps.concat(['accept']), - callback: function () { - value = { - // in case of already defined file, we receive only the file path (not the actual file) - file: value ? new File([''], value) : null, - content: '', - current: !!value, - removed: false, - } - }, - }, - { - types: ['text'], - name: 'TextAreaItem', - props: defaultProps, - }, - { - types: ['tags'], - name: 'TagsItem', - props: defaultProps.concat([ - 'limit', - 'placeholder', - 'options:choices', - 'tagIcon:icon', - ]), - callback: function () { - if (arg.choices && arg.choices.length) { - this.name = 'TagsSelectizeItem' - Object.assign(field.props.props, { - auto: true, - itemsName: '', - label: arg.placeholder, - }) - } - if (typeof value === 'string' && value) { - value = value.split(',') - } else if (!value) { - value = [] - } - }, - }, - { - types: ['boolean'], - name: 'CheckboxItem', - props: ['id', 'choices'], - callback: function () { - if (value !== null && value !== undefined) { - value = ['1', 'yes', 'y', 'true'].includes( - String(value).toLowerCase(), - ) - } else if (arg.default !== null && arg.default !== undefined) { - value = ['1', 'yes', 'y', 'true'].includes( - String(arg.default).toLowerCase(), - ) - } - }, - }, - { - types: ['alert'], - name: 'ReadOnlyAlertItem', - props: ['type:style', 'label:ask', 'icon'], - renderSelf: true, - }, - { - types: ['markdown'], - name: 'MarkdownItem', - props: ['label:ask'], - renderSelf: true, - }, - { - types: ['display_text'], - name: 'DisplayTextItem', - props: ['label:ask'], - renderSelf: true, - }, - { - types: ['button'], - name: 'ButtonItem', - props: ['type:style', 'label:ask', 'icon', 'enabled'], - renderSelf: true, - }, - ] - - // Default type management if no one is filled - if (arg.type !== 'tags' && arg.choices && arg.choices.length) { - arg.type = 'select' - } - if (arg.type === undefined) { - if (arg.choices && arg.choices.length) { - arg.type = 'select' - } else { - arg.type = 'string' - } - } - - // Search the component bind to the type - const component = components.find((element) => - element.types.includes(arg.type), - ) - if (component === undefined) throw new TypeError('Unknown type: ' + arg.type) - - // Callback use for specific behaviour - if (component.callback) component.callback() - field.props.component = component.name - // Affect properties to the field Item - for (let prop of component.props) { - prop = prop.split(':') - const propName = prop[0] - const argName = prop.slice(-1)[0] - - if (argName in arg) { - if (propName === 'choices') { - field.props.props[propName] = Object.entries(arg[argName]).map( - ([value, text]) => ({ value, text }), - ) - } else { - field.props.props[propName] = arg[argName] - } - } - } - - // Required (no need for checkbox its value can't be null) - if ( - !component.renderSelf && - arg.type !== 'boolean' && - arg.optional !== true - ) { - validation.required = validators.required - } - if (arg.pattern && arg.type !== 'tags') { - validation.pattern = validators.helpers.withMessage( - formatI18nField(arg.pattern.error), - validators.helpers.regex(new RegExp(arg.pattern.regexp)), - ) - } - - // Default value if still `null` - if (value === null && arg.default) { - value = arg.default - } - - // Help message - if (arg.help) { - field.props.description = formatI18nField(arg.help) - } - - // Help message - if (arg.helpLink) { - field.props.link = { - href: arg.helpLink.href, - text: i18n.global.t(arg.helpLink.text), - } - } - - if (component.renderSelf) { - field.is = field.props.component - field.props = field.props.props - } - - return { - value, - field, - // Return null instead of empty object if there's no validation - validation: Object.keys(validation).length === 0 ? null : validation, - } -} - -/** - * Format app install, actions and config panel manifest args into a form that can be used - * as v-model values, fields that can be passed to a FormField component and validations. - * - * @param {Array} args - a yunohost arg array written by a packager. - * @param {Object|null} forms - nested form used as the expression evualuations context. - * @return {Object} an object containing all parsed values to be used in vue views. - */ -export function formatYunoHostArguments(args, forms) { - const form = {} - const fields = {} - const validations = {} - - for (const arg of args) { - const { value, field, validation } = formatYunoHostArgument(arg) - fields[arg.id] = field - form[arg.id] = value - if (validation) validations[arg.id] = validation - - if ('visible' in arg && typeof arg.visible === 'string') { - addEvaluationGetter( - 'visible', - field, - arg.visible, - forms || form, - forms !== undefined, - ) - } - - if ('enabled' in arg && typeof arg.enabled === 'string') { - addEvaluationGetter( - 'enabled', - field.props, - arg.enabled, - forms || form, - forms !== undefined, - ) - } - } - - return { form, fields, validations } -} - -export function formatYunoHostConfigPanels(data) { - const result = { - panels: [], - forms: {}, - validations: {}, - } - - for (const { id: panelId, name, help, sections } of data.panels) { - const panel = { - id: panelId, - sections: [], - serverError: '', - hasApplyButton: false, - } - result.forms[panelId] = {} - result.validations[panelId] = {} - - if (name) panel.name = formatI18nField(name) - if (help) panel.help = formatI18nField(help) - - for (const _section of sections) { - const section = { - id: _section.id, - isActionSection: _section.is_action_section, - visible: _section.visible, - } - if (_section.help) section.help = formatI18nField(_section.help) - if (_section.name) section.name = formatI18nField(_section.name) - if (typeof _section.visible === 'string') { - addEvaluationGetter('visible', section, section.visible, result.forms) - } - - const { form, fields, validations } = formatYunoHostArguments( - _section.options, - result.forms, - ) - // Merge all sections forms to the panel to get a unique form - Object.assign(result.forms[panelId], form) - Object.assign(result.validations[panelId], validations) - section.fields = fields - panel.sections.push(section) - - if ( - !section.isActionSection && - Object.values(fields).some( - (field) => !NO_VALUE_FIELDS.includes(field.is), - ) - ) { - panel.hasApplyButton = true - } - } - - result.panels.push(panel) - } - - return result -} - /** * Parse a front-end value to its API equivalent. This function returns a Promise or an * Object `{ key: Promise }` if `key` is supplied. When parsing a form, all those diff --git a/app/src/types/commons.ts b/app/src/types/commons.ts index 18145a1b..e464d3b6 100644 --- a/app/src/types/commons.ts +++ b/app/src/types/commons.ts @@ -32,3 +32,10 @@ export type ArrInnerType = T extends (infer ElementType)[] ? ElementType : never export type KeyOfStr = Extract +export type MergeUnion> = { + [K in U extends unknown ? keyof U : never]: U extends unknown + ? K extends keyof U + ? U[K] + : never + : never +} diff --git a/app/src/types/configPanels.ts b/app/src/types/configPanels.ts new file mode 100644 index 00000000..aa9e6902 --- /dev/null +++ b/app/src/types/configPanels.ts @@ -0,0 +1,80 @@ +import type { Ref } from 'vue' + +import type { Obj, KeyOfStr, CustomRoute } from '@/types/commons' +import type { + FormField, + FormFieldReadonly, + FormFieldDisplay, + FormFieldDict, +} from '@/types/form' +import type { + AnyOptionType, + AnyOption, + OptionTypeToOptionResolver, +} from '@/types/core/options' + +export const OPTION_COMPONENT_RESOLVER = { + display_text: 'DisplayTextItem', + markdown: 'MarkdownItem', + alert: 'ReadOnlyAlertItem', + button: 'ButtonItem', + string: 'InputItem', + text: 'TextAreaItem', + password: 'InputItem', + color: 'InputItem', + number: 'InputItem', + range: 'InputItem', + boolean: 'CheckboxItem', + date: 'InputItem', + time: 'InputItem', + email: 'InputItem', + path: 'InputItem', + url: 'InputItem', + file: 'FileItem', + select: 'SelectItem', + tags: 'TagsItem', + 'tags-select': 'TagsSelectizeItem', + domain: 'SelectItem', + app: 'SelectItem', + user: 'SelectItem', + group: 'SelectItem', +} as const + +export function isIn( + coll: ReadonlyArray, + el: U, +): el is U & OptionTypeToOptionResolver[T] { + return coll.includes(el.type as T) +} + +// FIXME move to types/form.ts? +export type AnyFormField = FormField | FormFieldReadonly | FormFieldDisplay + +export type ConfigSection> = { + help: string + fields: KeyOfStr[] + id: string + isActionSection: boolean + name?: string + visible: boolean +} + +export type ConfigPanel< + NestedMV extends Obj, + MV extends Obj, + FFD extends FormFieldDict = FormFieldDict, +> = { + fields: FFD + help: string + hasApplyButton: boolean + id: KeyOfStr + icon?: string + name: string + sections?: ConfigSection[] +} + +export type ConfigPanels> = { + forms: Record> + panels: ConfigPanel[] + routes: CustomRoute[] +} diff --git a/app/src/types/core/options.ts b/app/src/types/core/options.ts index 78008b60..fc5ab4f6 100644 --- a/app/src/types/core/options.ts +++ b/app/src/types/core/options.ts @@ -8,7 +8,7 @@ type Pattern = { // CONFIG PANELS -export type CoreConfigPanels> = { +export type CoreConfigPanels = Obj> = { panels: CoreConfigPanel[] } @@ -17,7 +17,7 @@ export type CoreConfigPanel = { icon?: string id: KeyOfStr name?: Translation - sections: CoreConfigSection[] + sections?: CoreConfigSection[] } export type CoreConfigSection = { diff --git a/app/src/types/form.ts b/app/src/types/form.ts index 28d94a5e..50dba32b 100644 --- a/app/src/types/form.ts +++ b/app/src/types/form.ts @@ -5,6 +5,7 @@ import type { } from '@vuelidate/core' import type { RouteLocationRaw } from 'vue-router' +import { isObjectLiteral } from '@/helpers/commons' import type { ArrInnerType, Cols, Obj, StateVariant } from '@/types/commons' type StateValidation = false | null @@ -129,6 +130,21 @@ export type TextAreaItemProps = BaseWritableItemProps & { // type?: string // FIXME unused? } +export type AnyDisplayItemProps = + | ButtonItemProps + | DisplayTextItemProps + | MarkdownItemProps + | ReadOnlyAlertItemProps +export type AnyWritableItemProps = + | AdressItemProps + | CheckboxItemProps + | FileItemProps + | InputItemProps + | SelectItemProps + | TagsItemProps + | TagsSelectizeItemProps + | TextAreaItemProps + // FIELDS const ANY_WRITABLE_COMPONENTS = [ @@ -178,6 +194,16 @@ export function isDisplayComponent( ) } +export function isNonWritableComponent( + field: + | FormField + | FormField + | FormFieldReadonly + | FormFieldDisplay, +): field is FormFieldDisplay | FormFieldReadonly { + return isDisplayComponent(field) || !!field.readonly +} + type ItemComponentToItemProps = { // DISPLAY ButtonItem: ButtonItemProps @@ -235,7 +261,7 @@ export type FormField< readonly?: false } -type FormFieldReadonly< +export type FormFieldReadonly< C extends AnyWritableComponents = AnyWritableComponents, > = BaseFormField & { label: string @@ -243,7 +269,9 @@ type FormFieldReadonly< readonly: true } -type FormFieldDisplay = { +export type FormFieldDisplay< + C extends AnyDisplayComponents = AnyDisplayComponents, +> = { component: C props: ItemComponentToItemProps[C] visible?: boolean @@ -275,6 +303,7 @@ export type FormFieldDict = { | FormFieldDisplay } +// Type to check if object satisfies specified Field and Item export type FieldProps< C extends AnyItemComponents = 'InputItem', MV extends any = never, @@ -283,3 +312,11 @@ export type FieldProps< : C extends AnyDisplayComponents ? FormFieldDisplay : never + +export function isFileModelValue(value: any): value is FileModelValue { + return isObjectLiteral(value) && 'file' in value +} + +export function isAdressModelValue(value: any): value is AdressModelValue { + return isObjectLiteral(value) && 'separator' in value +}