mirror of
https://github.com/YunoHost/yunohost-admin.git
synced 2024-09-03 20:06:15 +02:00
[enh] Adapt the new config panel mechanism to vuejs webadmin
This commit is contained in:
parent
30e4bb6322
commit
36a4a21d57
13 changed files with 362 additions and 112 deletions
|
@ -22,7 +22,9 @@
|
|||
"vue-i18n": "^8.24.1",
|
||||
"vue-router": "^3.5.1",
|
||||
"vuelidate": "^0.7.6",
|
||||
"vuex": "^3.6.2"
|
||||
"vuex": "^3.6.2",
|
||||
"simple-evaluate": "^1.4.3",
|
||||
"vue-showdown": "^2.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vue/cli-plugin-babel": "~4.4.0",
|
||||
|
|
|
@ -26,11 +26,11 @@
|
|||
|
||||
<template #description>
|
||||
<!-- Render description -->
|
||||
<template v-if="description || example || link">
|
||||
<template v-if="description || link">
|
||||
<div class="d-flex">
|
||||
<span v-if="example">{{ $t('form_input_example', { example }) }}</span>
|
||||
|
||||
<b-link v-if="link" :to="link" class="ml-auto">
|
||||
<b-link v-if="link" :to="link" :href="link.href"
|
||||
class="ml-auto"
|
||||
>
|
||||
{{ link.text }}
|
||||
</b-link>
|
||||
</div>
|
||||
|
@ -57,7 +57,6 @@ export default {
|
|||
id: { type: String, default: null },
|
||||
description: { type: String, default: null },
|
||||
descriptionVariant: { type: String, default: null },
|
||||
example: { type: String, default: null },
|
||||
link: { type: Object, default: null },
|
||||
// Rendered field component props
|
||||
component: { type: String, default: 'InputItem' },
|
||||
|
|
45
app/src/components/globals/formItems/FileItem.vue
Normal file
45
app/src/components/globals/formItems/FileItem.vue
Normal file
|
@ -0,0 +1,45 @@
|
|||
<template>
|
||||
<b-button-group class="w-100">
|
||||
<b-button @click="clearFiles" variant="danger" v-if="!required && file">
|
||||
<icon iname="trash" />
|
||||
</b-button>
|
||||
<b-form-file
|
||||
v-model="file"
|
||||
ref="file-input"
|
||||
:id="id"
|
||||
:required="required"
|
||||
v-on="$listeners"
|
||||
:placeholder="placeholder"
|
||||
:accept="accept"
|
||||
@blur="$parent.$emit('touch', name)"
|
||||
/>
|
||||
</b-button-group>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'FileItem',
|
||||
|
||||
data () {
|
||||
return {
|
||||
file: null
|
||||
}
|
||||
},
|
||||
|
||||
props: {
|
||||
id: { type: String, default: null },
|
||||
value: { type: [File, null], default: null },
|
||||
placeholder: { type: String, default: 'Choose a file or drop it here...' },
|
||||
accept: { type: String, default: null },
|
||||
required: { type: Boolean, default: false },
|
||||
name: { type: String, default: null }
|
||||
},
|
||||
|
||||
|
||||
methods: {
|
||||
clearFiles () {
|
||||
this.$refs['file-input'].reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -7,6 +7,8 @@
|
|||
:type="type"
|
||||
:state="state"
|
||||
:required="required"
|
||||
:min="min"
|
||||
:max="max"
|
||||
@blur="$parent.$emit('touch', name)"
|
||||
/>
|
||||
</template>
|
||||
|
@ -22,6 +24,8 @@ export default {
|
|||
type: { type: String, default: 'text' },
|
||||
required: { type: Boolean, default: false },
|
||||
state: { type: Boolean, default: null },
|
||||
min: { type: Number, default: null },
|
||||
max: { type: Number, default: null },
|
||||
name: { type: String, default: null }
|
||||
}
|
||||
}
|
||||
|
|
15
app/src/components/globals/formItems/MarkdownItem.vue
Normal file
15
app/src/components/globals/formItems/MarkdownItem.vue
Normal file
|
@ -0,0 +1,15 @@
|
|||
<template>
|
||||
<vue-showdown :markdown="label" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'MarkdownItem',
|
||||
|
||||
props: {
|
||||
id: { type: String, default: null },
|
||||
label: { type: String, default: null }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
18
app/src/components/globals/formItems/ReadOnlyAlertItem.vue
Normal file
18
app/src/components/globals/formItems/ReadOnlyAlertItem.vue
Normal file
|
@ -0,0 +1,18 @@
|
|||
<template>
|
||||
<b-alert :variant="type" show>
|
||||
<icon :iname="type" />
|
||||
{{ label }}
|
||||
</b-alert>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ReadOnlyAlertItem',
|
||||
|
||||
props: {
|
||||
id: { type: String, default: null },
|
||||
label: { type: String, default: null },
|
||||
type: { type: String, default: null }
|
||||
}
|
||||
}
|
||||
</script>
|
32
app/src/components/globals/formItems/TagsItem.vue
Normal file
32
app/src/components/globals/formItems/TagsItem.vue
Normal file
|
@ -0,0 +1,32 @@
|
|||
<template>
|
||||
<b-form-tags
|
||||
v-model="tags"
|
||||
:id="id"
|
||||
:placeholder="placeholder"
|
||||
:required="required"
|
||||
:state="state"
|
||||
v-on="$listeners"
|
||||
@blur="$parent.$emit('touch', name)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'TagsItem',
|
||||
|
||||
data () {
|
||||
return {
|
||||
tags: null
|
||||
}
|
||||
},
|
||||
props: {
|
||||
value: { type: Array, default: null },
|
||||
id: { type: String, default: null },
|
||||
placeholder: { type: String, default: null },
|
||||
required: { type: Boolean, default: false },
|
||||
state: { type: Boolean, default: null },
|
||||
name: { type: String, default: null }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
29
app/src/components/globals/formItems/TextAreaItem.vue
Normal file
29
app/src/components/globals/formItems/TextAreaItem.vue
Normal file
|
@ -0,0 +1,29 @@
|
|||
<template>
|
||||
<b-form-textarea
|
||||
v-model="value"
|
||||
:id="id"
|
||||
:placeholder="placeholder"
|
||||
:required="required"
|
||||
:state="state"
|
||||
rows="3"
|
||||
max-rows="6"
|
||||
v-on="$listeners"
|
||||
@blur="$parent.$emit('touch', name)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'TextAreaItem',
|
||||
|
||||
props: {
|
||||
value: { type: String, default: null },
|
||||
id: { type: String, default: null },
|
||||
placeholder: { type: String, default: null },
|
||||
type: { type: String, default: 'text' },
|
||||
required: { type: Boolean, default: false },
|
||||
state: { type: Boolean, default: null },
|
||||
name: { type: String, default: null }
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -58,44 +58,59 @@ export function adressToFormValue (address) {
|
|||
export function formatYunoHostArgument (arg) {
|
||||
let value = null
|
||||
const validation = {}
|
||||
arg.ask = formatI18nField(arg.ask)
|
||||
const field = {
|
||||
component: undefined,
|
||||
label: formatI18nField(arg.ask),
|
||||
label: arg.ask,
|
||||
props: {}
|
||||
}
|
||||
|
||||
if (arg.type === 'boolean') {
|
||||
field.id = arg.name
|
||||
} else {
|
||||
field.props.id = arg.name
|
||||
const defaultProps = ['id:name', 'placeholder:example']
|
||||
const components = [
|
||||
{
|
||||
types: [undefined, 'string'],
|
||||
name: 'InputItem',
|
||||
props: defaultProps
|
||||
},
|
||||
{
|
||||
types: ['email', 'url', 'date', 'time', 'color'],
|
||||
name: 'InputItem',
|
||||
props: defaultProps.concat(['type'])
|
||||
},
|
||||
{
|
||||
types: ['password'],
|
||||
name: 'InputItem',
|
||||
props: defaultProps.concat(['type']),
|
||||
callback: function () {
|
||||
if (!arg.help) {
|
||||
arg.help = 'good_practices_about_admin_password'
|
||||
}
|
||||
|
||||
// Some apps has an argument type `string` as type but expect a select since it has `choices`
|
||||
if (arg.choices !== undefined) {
|
||||
field.component = 'SelectItem'
|
||||
field.props.choices = arg.choices
|
||||
// Input
|
||||
} else if ([undefined, 'string', 'number', 'password', 'email'].includes(arg.type)) {
|
||||
field.component = 'InputItem'
|
||||
if (![undefined, 'string'].includes(arg.type)) {
|
||||
field.props.type = arg.type
|
||||
if (arg.type === 'password') {
|
||||
field.description = i18n.t('good_practices_about_admin_password')
|
||||
field.placeholder = '••••••••'
|
||||
arg.example = '••••••••'
|
||||
validation.passwordLenght = validators.minLength(8)
|
||||
}
|
||||
},
|
||||
{
|
||||
types: ['number', 'range'],
|
||||
name: 'InputItem',
|
||||
props: defaultProps.concat(['type', 'min', 'max']),
|
||||
callback: function () {
|
||||
if (!isNaN(parseInt(arg.min))) {
|
||||
validation.minValue = validators.minValue(parseInt(arg.min))
|
||||
}
|
||||
// Checkbox
|
||||
} else if (arg.type === 'boolean') {
|
||||
field.component = 'CheckboxItem'
|
||||
if (typeof arg.default === 'number') {
|
||||
value = arg.default === 1
|
||||
} else {
|
||||
value = arg.default || false
|
||||
if (!isNaN(parseInt(arg.max))) {
|
||||
validation.maxValue = validators.maxValue(parseInt(arg.max))
|
||||
}
|
||||
// Special (store related)
|
||||
} else if (['user', 'domain'].includes(arg.type)) {
|
||||
field.component = 'SelectItem'
|
||||
}
|
||||
},
|
||||
{
|
||||
types: ['select'],
|
||||
name: 'SelectItem',
|
||||
props: ['id:name', 'choices']
|
||||
},
|
||||
{
|
||||
types: ['select', 'user', 'domain'],
|
||||
name: 'SelectItem',
|
||||
props: ['id:name', 'choices'],
|
||||
callback: function () {
|
||||
field.link = { name: arg.type + '-list', text: i18n.t(`manage_${arg.type}s`) }
|
||||
field.props.choices = store.getters[arg.type + 'sAsChoices']
|
||||
if (arg.type === 'domain') {
|
||||
|
@ -103,31 +118,91 @@ export function formatYunoHostArgument (arg) {
|
|||
} else {
|
||||
value = field.props.choices.length ? field.props.choices[0].value : null
|
||||
}
|
||||
|
||||
// Unknown from the specs, try to display it as an input[text]
|
||||
// FIXME throw an error instead ?
|
||||
} else {
|
||||
field.component = 'InputItem'
|
||||
}
|
||||
},
|
||||
{
|
||||
types: ['file'],
|
||||
name: 'FileItem',
|
||||
props: defaultProps.concat(['accept'])
|
||||
},
|
||||
{
|
||||
types: ['text'],
|
||||
name: 'TextAreaItem',
|
||||
props: defaultProps
|
||||
},
|
||||
{
|
||||
types: ['tags'],
|
||||
name: 'TagsItem',
|
||||
props: defaultProps
|
||||
},
|
||||
{
|
||||
types: ['boolean'],
|
||||
name: 'CheckboxItem',
|
||||
props: ['id:name', 'choices'],
|
||||
callback: function () {
|
||||
if (typeof arg.default === 'number') {
|
||||
value = arg.default === 1
|
||||
} else {
|
||||
value = arg.default || false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
types: ['succes', 'info', 'warning', 'error'],
|
||||
name: 'ReadOnlyAlertItem',
|
||||
props: ['type', 'label:ask'],
|
||||
readonly: true
|
||||
},
|
||||
{
|
||||
types: ['markdown', 'display_text'],
|
||||
name: 'MarkdownItem',
|
||||
props: ['label:ask'],
|
||||
readonly: true
|
||||
}
|
||||
]
|
||||
|
||||
// Default type management if no one is filled
|
||||
if (arg.type === undefined) {
|
||||
arg.type = (arg.choices === undefined) ? 'string' : 'select'
|
||||
}
|
||||
// Search the component bind to the type
|
||||
const component = components.find(element => element.types.includes(arg.type))
|
||||
field.component = component.name
|
||||
// Callback use for specific behaviour
|
||||
if (component.callback) component.callback()
|
||||
// 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) {
|
||||
field.props[propName] = arg[argName]
|
||||
}
|
||||
}
|
||||
// We don't want to display a label html item as this kind or field contains
|
||||
// already the text to display
|
||||
if (component.readonly) delete field.label
|
||||
// Required (no need for checkbox its value can't be null)
|
||||
if (field.component !== 'CheckboxItem' && arg.optional !== true) {
|
||||
else if (field.component !== 'CheckboxItem' && arg.optional !== true) {
|
||||
validation.required = validators.required
|
||||
}
|
||||
// Default value if still `null`
|
||||
if (value === null && arg.default) {
|
||||
value = arg.default
|
||||
}
|
||||
|
||||
// Help message
|
||||
if (arg.help) {
|
||||
field.description = formatI18nField(arg.help)
|
||||
}
|
||||
// Example
|
||||
if (arg.example) {
|
||||
field.example = arg.example
|
||||
if (field.component === 'InputItem') {
|
||||
field.props.placeholder = field.example
|
||||
|
||||
// Help message
|
||||
if (arg.helpLink) {
|
||||
field.link = { href: arg.helpLink.href, text: i18n.t(arg.helpLink.text) }
|
||||
}
|
||||
|
||||
if (arg.visibleif) {
|
||||
field.visibleif = arg.visibleif
|
||||
}
|
||||
|
||||
return {
|
||||
|
@ -148,7 +223,6 @@ export function formatYunoHostArgument (arg) {
|
|||
* @return {Object} an object containing all parsed values to be used in vue views.
|
||||
*/
|
||||
export function formatYunoHostArguments (args, name = null) {
|
||||
let disclaimer = null
|
||||
const form = {}
|
||||
const fields = {}
|
||||
const validations = {}
|
||||
|
@ -163,20 +237,27 @@ export function formatYunoHostArguments (args, name = null) {
|
|||
}
|
||||
|
||||
for (const arg of args) {
|
||||
if (arg.type === 'display_text') {
|
||||
disclaimer = formatI18nField(arg.ask)
|
||||
} else {
|
||||
const { value, field, validation } = formatYunoHostArgument(arg)
|
||||
fields[arg.name] = field
|
||||
form[arg.name] = value
|
||||
if (validation) validations[arg.name] = validation
|
||||
}
|
||||
|
||||
return { form, fields, validations }
|
||||
}
|
||||
|
||||
return { form, fields, validations, disclaimer }
|
||||
export function pFileReader (file, output, key) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fr = new FileReader()
|
||||
fr.onerror = reject
|
||||
fr.onload = () => {
|
||||
output[key] = fr.result.replace(/data:[^;]*;base64,/, '')
|
||||
output[key + '[name]'] = file.name
|
||||
resolve()
|
||||
}
|
||||
fr.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Format helper for a form value.
|
||||
* Convert Boolean to (1|0) and concatenate adresses.
|
||||
|
@ -204,14 +285,15 @@ export function formatFormDataValue (value) {
|
|||
* @param {Boolean} [extraParams.removeEmpty=true] - Removes "empty" values from the object.
|
||||
* @return {Object} the parsed data to be sent to the server, with extracted values if specified.
|
||||
*/
|
||||
export function formatFormData (
|
||||
export async function formatFormData (
|
||||
formData,
|
||||
{ extract = null, flatten = false, removeEmpty = true } = {}
|
||||
{ extract = null, flatten = false, removeEmpty = true, promise = false } = {}
|
||||
) {
|
||||
const output = {
|
||||
data: {},
|
||||
extracted: {}
|
||||
}
|
||||
const promises = []
|
||||
for (const key in formData) {
|
||||
const type = extract && extract.includes(key) ? 'extracted' : 'data'
|
||||
const value = Array.isArray(formData[key])
|
||||
|
@ -220,6 +302,8 @@ export function formatFormData (
|
|||
|
||||
if (removeEmpty && isEmptyValue(value)) {
|
||||
continue
|
||||
} else if (value instanceof File) {
|
||||
promises.push(pFileReader(value, output[type], key))
|
||||
} else if (flatten && isObjectLiteral(value)) {
|
||||
flattenObjectLiteral(value, output[type])
|
||||
} else {
|
||||
|
@ -227,5 +311,13 @@ export function formatFormData (
|
|||
}
|
||||
}
|
||||
const { data, extracted } = output
|
||||
if (promises.length > 0 || promise) {
|
||||
return new Promise((resolve, reject) => {
|
||||
Promise.all(promises).then((value) => {
|
||||
resolve(data)
|
||||
})
|
||||
})
|
||||
} else {
|
||||
return extract ? { data, ...extracted } : data
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import Vue from 'vue'
|
||||
import App from './App.vue'
|
||||
import BootstrapVue from 'bootstrap-vue'
|
||||
// import VueShowdown from 'vue-showdown'
|
||||
|
||||
import i18n from './i18n'
|
||||
import router from './router'
|
||||
|
@ -11,7 +12,6 @@ import { registerGlobalErrorHandlers } from './api'
|
|||
|
||||
Vue.config.productionTip = false
|
||||
|
||||
|
||||
// Styles are imported in `src/App.vue` <style>
|
||||
Vue.use(BootstrapVue, {
|
||||
BSkeleton: { animation: 'none' },
|
||||
|
@ -24,6 +24,11 @@ Vue.use(BootstrapVue, {
|
|||
}
|
||||
})
|
||||
|
||||
// Vue.use(VueShowdown, {
|
||||
// options: {
|
||||
// emoji: true
|
||||
// }
|
||||
// })
|
||||
|
||||
// Ugly wrapper for `$bvModal.msgBoxConfirm` to set default i18n button titles
|
||||
// FIXME find or wait for a better way
|
||||
|
@ -46,13 +51,14 @@ requireComponent.keys().forEach((fileName) => {
|
|||
Vue.component(component.name, component)
|
||||
})
|
||||
|
||||
|
||||
registerGlobalErrorHandlers()
|
||||
|
||||
|
||||
new Vue({
|
||||
const app = new Vue({
|
||||
i18n,
|
||||
router,
|
||||
store,
|
||||
render: h => h(App)
|
||||
}).$mount('#app')
|
||||
})
|
||||
|
||||
app.$mount('#app')
|
||||
|
|
|
@ -15,14 +15,6 @@
|
|||
:validation="$v.actions[i]" :id="action.id + '-form'" :server-error="action.serverError"
|
||||
@submit.prevent="performAction(action)" :submit-text="$t('perform')"
|
||||
>
|
||||
<template #disclaimer>
|
||||
<div
|
||||
v-if="action.formDisclaimer"
|
||||
class="alert alert-info" v-html="action.formDisclaimer"
|
||||
/>
|
||||
<b-card-text v-if="action.description" v-html="action.description" />
|
||||
</template>
|
||||
|
||||
<form-field
|
||||
v-for="(field, fname) in action.fields" :key="fname" label-cols="0"
|
||||
v-bind="field" v-model="action.form[fname]" :validation="$v.actions[i][fname]"
|
||||
|
@ -85,11 +77,10 @@ export default {
|
|||
const action = { name, id, serverError: '' }
|
||||
if (description) action.description = formatI18nField(description)
|
||||
if (arguments_ && arguments_.length) {
|
||||
const { form, fields, validations, disclaimer } = formatYunoHostArguments(arguments_)
|
||||
const { form, fields, validations } = formatYunoHostArguments(arguments_)
|
||||
action.form = form
|
||||
action.fields = fields
|
||||
if (validations) action.validations = validations
|
||||
if (disclaimer) action.formDisclaimer = disclaimer
|
||||
}
|
||||
return action
|
||||
})
|
||||
|
|
|
@ -1,13 +1,9 @@
|
|||
<template>
|
||||
<view-base :queries="queries" @queries-response="onQueriesResponse" skeleton="card-form-skeleton">
|
||||
<template v-if="panels" #default>
|
||||
<b-alert variant="warning" class="mb-4">
|
||||
<icon iname="exclamation-triangle" /> {{ $t('experimental_warning') }}
|
||||
</b-alert>
|
||||
|
||||
<card-form
|
||||
v-for="{ name, id: id_, sections, help, serverError } in panels" :key="id_"
|
||||
:title="name" icon="wrench" title-tag="h4"
|
||||
:title="name" icon="wrench" title-tag="h2"
|
||||
:validation="$v.forms[id_]" :id="id_ + '-form'" :server-error="serverError"
|
||||
collapsable
|
||||
@submit.prevent="applyConfig(id_)"
|
||||
|
@ -17,12 +13,15 @@
|
|||
</template>
|
||||
|
||||
<div v-for="section in sections" :key="section.id" class="mb-5">
|
||||
<b-card-title>{{ section.name }} <small v-if="section.help">{{ section.help }}</small></b-card-title>
|
||||
|
||||
<form-field
|
||||
v-for="(field, fname) in section.fields" :key="fname" label-cols="0"
|
||||
v-bind="field" v-model="forms[id_][fname]" :validation="$v.forms[id_][fname]"
|
||||
<b-card-title v-if="section.name" title-tag="h3">
|
||||
{{ section.name }} <small v-if="section.help">{{ section.help }}</small>
|
||||
</b-card-title>
|
||||
<template v-for="(field, fname) in section.fields">
|
||||
<form-field :key="fname" v-model="forms[id_][fname]"
|
||||
:validation="$v.forms[id_][fname]"
|
||||
v-if="isVisible(field.visibleif)" v-bind="field"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</card-form>
|
||||
</template>
|
||||
|
@ -36,6 +35,7 @@
|
|||
|
||||
<script>
|
||||
import { validationMixin } from 'vuelidate'
|
||||
import evaluate from 'simple-evaluate'
|
||||
|
||||
// FIXME needs test and rework
|
||||
import api, { objectToParams } from '@/api'
|
||||
|
@ -69,6 +69,17 @@ export default {
|
|||
},
|
||||
|
||||
methods: {
|
||||
isVisible (expression) {
|
||||
if (!expression) return true
|
||||
const context = {}
|
||||
for (const args of Object.values(this.forms)) {
|
||||
for (const fname in args) {
|
||||
const shortname = fname.split('_').slice(4).join('_').toLowerCase()
|
||||
context[shortname] = args[fname]
|
||||
}
|
||||
}
|
||||
return evaluate(context, expression)
|
||||
},
|
||||
onQueriesResponse (data) {
|
||||
if (!data.config_panel || data.config_panel.length === 0) {
|
||||
this.panels = null
|
||||
|
@ -100,7 +111,8 @@ export default {
|
|||
},
|
||||
|
||||
applyConfig (id_) {
|
||||
const args = objectToParams(formatFormData(this.forms[id_]))
|
||||
formatFormData(this.forms[id_], { promise: true }).then((formatedData) => {
|
||||
const args = objectToParams(formatedData)
|
||||
|
||||
api.put(
|
||||
`apps/${this.id}/config`, { args }, { key: 'apps.update_config', name: this.id }
|
||||
|
@ -113,7 +125,17 @@ export default {
|
|||
const panel = this.panels.find(({ id }) => id_ === id)
|
||||
this.$set(panel, 'serverError', err.message)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
h3.card-title {
|
||||
margin-bottom: 1em;
|
||||
border-bottom: solid 1px #aaa;
|
||||
}
|
||||
.form-control::placeholder, .form-file-text {
|
||||
color: #6d7780;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -23,10 +23,6 @@
|
|||
:validation="$v" :server-error="serverError"
|
||||
@submit.prevent="performInstall"
|
||||
>
|
||||
<template v-if="formDisclaimer" #disclaimer>
|
||||
<div class="alert alert-info" v-html="formDisclaimer" />
|
||||
</template>
|
||||
|
||||
<form-field
|
||||
v-for="(field, fname) in fields" :key="fname" label-cols="0"
|
||||
v-bind="field" v-model="form[fname]" :validation="$v.form[fname]"
|
||||
|
@ -94,12 +90,11 @@ export default {
|
|||
manifest.multi_instance = this.$i18n.t(manifest.multi_instance ? 'yes' : 'no')
|
||||
this.infos = Object.fromEntries(infosKeys.map(key => [key, manifest[key]]))
|
||||
|
||||
const { form, fields, validations, disclaimer } = formatYunoHostArguments(
|
||||
const { form, fields, validations } = formatYunoHostArguments(
|
||||
manifest.arguments.install,
|
||||
manifest.name
|
||||
)
|
||||
|
||||
this.formDisclaimer = disclaimer
|
||||
this.fields = fields
|
||||
this.form = form
|
||||
this.validations = { form: validations }
|
||||
|
|
Loading…
Reference in a new issue