refactor: rework config panels and options formating

This commit is contained in:
axolotle 2024-07-22 17:44:50 +02:00
parent dbd753c8e9
commit 83e09d6ae9
6 changed files with 500 additions and 392 deletions

View file

@ -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<Obj>): 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<AnyDisplayItemProps>
const field: FormFieldDisplay<typeof component> = {
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<AnyWritableItemProps>
const rules: FormField['rules'] = {}
const field:
| FormField<typeof component>
| FormFieldReadonly<typeof component> = {
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<MV extends Obj>(
options: AnyOption[],
): {
fields: FormFieldDict<MV>
form: Ref<MV>
} {
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<MV>
return {
form,
fields: Object.fromEntries(
options.map((option) => [option.id, formatOption(option, form)]),
) as FormFieldDict<MV>,
}
}
function formatConfigPanel<NestedMV extends Obj, MV extends Obj<NestedMV>>(
panel: CoreConfigPanel<MV>,
): {
form: Ref<NestedMV>
panel: ConfigPanel<NestedMV, MV>
} {
const options = panel.sections.flatMap((section) => section.options)
const { form, fields } = formatOptions<NestedMV>(options)
let hasApplyButton = false
const sections = panel.sections.map((section) => {
const sectionFieldsIds = section.options.map(
(option) => option.id,
) as ConfigPanel<NestedMV, MV>['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<NestedMV>,
>(config: CoreConfigPanels<MV>): ConfigPanels<NestedMV, MV> {
return config.panels.reduce(
(cps, panel_) => {
const { form, panel } = formatConfigPanel<NestedMV, MV>(panel_)
cps.forms[panel.id] = form
cps.panels.push(panel)
return cps
},
{
forms: {} as Record<keyof MV, Ref<NestedMV>>,
panels: [],
routes: config.panels.map((panel) => ({
to: { params: { tabId: panel.id } },
text: formatI18nField(panel.name),
icon: panel.icon || 'wrench',
})),
} as ConfigPanels<NestedMV, MV>,
)
}
function useExpression(
expression: JSExpression | undefined,
form: Ref<Obj>,
): 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<Obj>) {
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
}
})
}

View file

@ -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

View file

@ -32,3 +32,10 @@ export type ArrInnerType<T> = T extends (infer ElementType)[]
? ElementType
: never
export type KeyOfStr<T extends Obj> = Extract<keyof T, string>
export type MergeUnion<U extends Record<string, unknown>> = {
[K in U extends unknown ? keyof U : never]: U extends unknown
? K extends keyof U
? U[K]
: never
: never
}

View file

@ -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<T extends AnyOptionType, U extends AnyOption>(
coll: ReadonlyArray<T>,
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<MV extends Obj, FFD extends FormFieldDict<MV>> = {
help: string
fields: KeyOfStr<FFD>[]
id: string
isActionSection: boolean
name?: string
visible: boolean
}
export type ConfigPanel<
NestedMV extends Obj,
MV extends Obj<NestedMV>,
FFD extends FormFieldDict<NestedMV> = FormFieldDict<NestedMV>,
> = {
fields: FFD
help: string
hasApplyButton: boolean
id: KeyOfStr<MV>
icon?: string
name: string
sections?: ConfigSection<NestedMV, FFD>[]
}
export type ConfigPanels<NestedMV extends Obj, MV extends Obj<NestedMV>> = {
forms: Record<keyof MV, Ref<NestedMV>>
panels: ConfigPanel<NestedMV, MV>[]
routes: CustomRoute[]
}

View file

@ -8,7 +8,7 @@ type Pattern = {
// CONFIG PANELS
export type CoreConfigPanels<MV extends Obj<Obj>> = {
export type CoreConfigPanels<MV extends Obj<Obj> = Obj<Obj>> = {
panels: CoreConfigPanel<MV>[]
}
@ -17,7 +17,7 @@ export type CoreConfigPanel<MV extends Obj> = {
icon?: string
id: KeyOfStr<MV>
name?: Translation
sections: CoreConfigSection[]
sections?: CoreConfigSection[]
}
export type CoreConfigSection = {

View file

@ -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<AnyWritableComponents>
| 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<C> & {
label: string
@ -243,7 +269,9 @@ type FormFieldReadonly<
readonly: true
}
type FormFieldDisplay<C extends AnyDisplayComponents = AnyDisplayComponents> = {
export type FormFieldDisplay<
C extends AnyDisplayComponents = AnyDisplayComponents,
> = {
component: C
props: ItemComponentToItemProps[C]
visible?: boolean
@ -275,6 +303,7 @@ export type FormFieldDict<T extends Obj = Obj> = {
| FormFieldDisplay<AnyDisplayComponents>
}
// 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<C>
: 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
}