mirror of
https://github.com/YunoHost/yunohost-admin.git
synced 2024-09-03 20:06:15 +02:00
refactor: rework config panels and options formating
This commit is contained in:
parent
dbd753c8e9
commit
83e09d6ae9
6 changed files with 500 additions and 392 deletions
372
app/src/composables/configPanels.ts
Normal file
372
app/src/composables/configPanels.ts
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
|
@ -67,394 +67,6 @@ export function adressToFormValue(address) {
|
||||||
return { localPart, separator, domain }
|
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
|
* 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
|
* Object `{ key: Promise }` if `key` is supplied. When parsing a form, all those
|
||||||
|
|
|
@ -32,3 +32,10 @@ export type ArrInnerType<T> = T extends (infer ElementType)[]
|
||||||
? ElementType
|
? ElementType
|
||||||
: never
|
: never
|
||||||
export type KeyOfStr<T extends Obj> = Extract<keyof T, string>
|
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
|
||||||
|
}
|
||||||
|
|
80
app/src/types/configPanels.ts
Normal file
80
app/src/types/configPanels.ts
Normal 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[]
|
||||||
|
}
|
|
@ -8,7 +8,7 @@ type Pattern = {
|
||||||
|
|
||||||
// CONFIG PANELS
|
// CONFIG PANELS
|
||||||
|
|
||||||
export type CoreConfigPanels<MV extends Obj<Obj>> = {
|
export type CoreConfigPanels<MV extends Obj<Obj> = Obj<Obj>> = {
|
||||||
panels: CoreConfigPanel<MV>[]
|
panels: CoreConfigPanel<MV>[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -17,7 +17,7 @@ export type CoreConfigPanel<MV extends Obj> = {
|
||||||
icon?: string
|
icon?: string
|
||||||
id: KeyOfStr<MV>
|
id: KeyOfStr<MV>
|
||||||
name?: Translation
|
name?: Translation
|
||||||
sections: CoreConfigSection[]
|
sections?: CoreConfigSection[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CoreConfigSection = {
|
export type CoreConfigSection = {
|
||||||
|
|
|
@ -5,6 +5,7 @@ import type {
|
||||||
} from '@vuelidate/core'
|
} from '@vuelidate/core'
|
||||||
import type { RouteLocationRaw } from 'vue-router'
|
import type { RouteLocationRaw } from 'vue-router'
|
||||||
|
|
||||||
|
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
|
||||||
|
@ -129,6 +130,21 @@ export type TextAreaItemProps = BaseWritableItemProps & {
|
||||||
// type?: string // FIXME unused?
|
// type?: string // FIXME unused?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AnyDisplayItemProps =
|
||||||
|
| ButtonItemProps
|
||||||
|
| DisplayTextItemProps
|
||||||
|
| MarkdownItemProps
|
||||||
|
| ReadOnlyAlertItemProps
|
||||||
|
export type AnyWritableItemProps =
|
||||||
|
| AdressItemProps
|
||||||
|
| CheckboxItemProps
|
||||||
|
| FileItemProps
|
||||||
|
| InputItemProps
|
||||||
|
| SelectItemProps
|
||||||
|
| TagsItemProps
|
||||||
|
| TagsSelectizeItemProps
|
||||||
|
| TextAreaItemProps
|
||||||
|
|
||||||
// FIELDS
|
// FIELDS
|
||||||
|
|
||||||
const ANY_WRITABLE_COMPONENTS = [
|
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 = {
|
type ItemComponentToItemProps = {
|
||||||
// DISPLAY
|
// DISPLAY
|
||||||
ButtonItem: ButtonItemProps
|
ButtonItem: ButtonItemProps
|
||||||
|
@ -235,7 +261,7 @@ export type FormField<
|
||||||
readonly?: false
|
readonly?: false
|
||||||
}
|
}
|
||||||
|
|
||||||
type FormFieldReadonly<
|
export type FormFieldReadonly<
|
||||||
C extends AnyWritableComponents = AnyWritableComponents,
|
C extends AnyWritableComponents = AnyWritableComponents,
|
||||||
> = BaseFormField<C> & {
|
> = BaseFormField<C> & {
|
||||||
label: string
|
label: string
|
||||||
|
@ -243,7 +269,9 @@ type FormFieldReadonly<
|
||||||
readonly: true
|
readonly: true
|
||||||
}
|
}
|
||||||
|
|
||||||
type FormFieldDisplay<C extends AnyDisplayComponents = AnyDisplayComponents> = {
|
export type FormFieldDisplay<
|
||||||
|
C extends AnyDisplayComponents = AnyDisplayComponents,
|
||||||
|
> = {
|
||||||
component: C
|
component: C
|
||||||
props: ItemComponentToItemProps[C]
|
props: ItemComponentToItemProps[C]
|
||||||
visible?: boolean
|
visible?: boolean
|
||||||
|
@ -275,6 +303,7 @@ export type FormFieldDict<T extends Obj = Obj> = {
|
||||||
| FormFieldDisplay<AnyDisplayComponents>
|
| FormFieldDisplay<AnyDisplayComponents>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Type to check if object satisfies specified Field and Item
|
||||||
export type FieldProps<
|
export type FieldProps<
|
||||||
C extends AnyItemComponents = 'InputItem',
|
C extends AnyItemComponents = 'InputItem',
|
||||||
MV extends any = never,
|
MV extends any = never,
|
||||||
|
@ -283,3 +312,11 @@ export type FieldProps<
|
||||||
: C extends AnyDisplayComponents
|
: C extends AnyDisplayComponents
|
||||||
? FormFieldDisplay<C>
|
? FormFieldDisplay<C>
|
||||||
: never
|
: 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
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue