Merge pull request #366 from YunoHost/config-panel

New config panel with file and other nice formitem
This commit is contained in:
Alexandre Aubin 2021-09-13 02:39:57 +02:00 committed by GitHub
commit da87972ca4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 2779 additions and 2166 deletions

4188
app/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -18,10 +18,12 @@
"firacode": "^5.2.0", "firacode": "^5.2.0",
"fontsource-firago": "^3.1.5", "fontsource-firago": "^3.1.5",
"fork-awesome": "^1.1.7", "fork-awesome": "^1.1.7",
"simple-evaluate": "^1.4.3",
"vue": "^2.6.12", "vue": "^2.6.12",
"vue-i18n": "^8.24.1", "vue-i18n": "^8.24.1",
"vue-router": "^3.5.1", "vue-router": "^3.5.1",
"vuelidate": "^0.7.6", "vuelidate": "^0.7.6",
"vue-showdown": "^2.4.1",
"vuex": "^3.6.2" "vuex": "^3.6.2"
}, },
"devDependencies": { "devDependencies": {

View file

@ -49,6 +49,7 @@ class APIBadRequestError extends APIError {
super(method, response, errorData) super(method, response, errorData)
this.name = 'APIBadRequestError' this.name = 'APIBadRequestError'
this.key = errorData.error_key this.key = errorData.error_key
this.data = errorData
} }
} }

View file

@ -11,8 +11,8 @@
<slot name="server-error"> <slot name="server-error">
<b-alert <b-alert
variant="danger" class="my-3" variant="danger" class="my-3" icon="ban"
:show="serverError !== ''" v-html="serverError" :show="errorFeedback !== ''" v-html="errorFeedback"
/> />
</slot> </slot>
</b-form> </b-form>
@ -46,7 +46,13 @@ export default {
computed: { computed: {
disabled () { disabled () {
return this.validation ? this.validation.$invalid : false return false // this.validation ? this.validation.$invalid : false
},
errorFeedback () {
if (this.serverError) return this.serverError
else if (this.validation && this.validation.$anyError) {
return this.$i18n.t('form_errors.invalid_form')
} else return ''
} }
}, },

View file

@ -26,17 +26,16 @@
<template #description> <template #description>
<!-- Render description --> <!-- Render description -->
<template v-if="description || example || link"> <template v-if="description || link">
<div class="d-flex"> <div class="d-flex">
<span v-if="example">{{ $t('form_input_example', { example }) }}</span> <b-link v-if="link" :to="link" :href="link.href"
class="ml-auto"
<b-link v-if="link" :to="link" class="ml-auto"> >
{{ link.text }} {{ link.text }}
</b-link> </b-link>
</div> </div>
<div <vue-showdown :markdown="description" flavor="github" v-if="description"
v-if="description" v-html="description"
:class="{ ['alert p-1 px-2 alert-' + descriptionVariant]: descriptionVariant }" :class="{ ['alert p-1 px-2 alert-' + descriptionVariant]: descriptionVariant }"
/> />
</template> </template>
@ -57,7 +56,6 @@ export default {
id: { type: String, default: null }, id: { type: String, default: null },
description: { type: String, default: null }, description: { type: String, default: null },
descriptionVariant: { type: String, default: null }, descriptionVariant: { type: String, default: null },
example: { type: String, default: null },
link: { type: Object, default: null }, link: { type: Object, default: null },
// Rendered field component props // Rendered field component props
component: { type: String, default: 'InputItem' }, component: { type: String, default: 'InputItem' },

View file

@ -1,15 +1,23 @@
<template> <template>
<b-button-group class="w-100">
<b-button @click="clearFiles" variant="danger" v-if="!this.required && this.value !== null && !this.value._removed">
<icon iname="trash" />
</b-button>
<b-form-file <b-form-file
:id="id"
v-model="file" v-model="file"
ref="input-file"
:id="id"
v-on="$listeners" v-on="$listeners"
:placeholder="placeholder" :required="required"
:placeholder="_placeholder"
:accept="accept"
:drop-placeholder="dropPlaceholder" :drop-placeholder="dropPlaceholder"
:state="state" :state="state"
:required="required"
:browse-text="$t('words.browse')" :browse-text="$t('words.browse')"
@blur="$parent.$emit('touch', name)"
@focusout.native="$parent.$emit('touch', name)" @focusout.native="$parent.$emit('touch', name)"
/> />
</b-button-group>
</template> </template>
<script> <script>
@ -17,17 +25,41 @@ export default {
name: 'FileItem', name: 'FileItem',
data () { data () {
return { file: null } return {
file: this.value
}
}, },
props: { props: {
id: { type: String, default: null }, id: { type: String, default: null },
placeholder: { type: String, default: null }, value: { type: [File, null], default: null },
placeholder: { type: String, default: 'Choose a file or drop it here...' },
dropPlaceholder: { type: String, default: null }, dropPlaceholder: { type: String, default: null },
required: { type: Boolean, default: false }, accept: { type: String, default: null },
state: { type: Boolean, default: null }, state: { type: Boolean, default: null },
name: { type: String, default: null }, required: { type: Boolean, default: false },
accept: { type: String, default: null } name: { type: String, default: null }
},
computed: {
_placeholder: function () {
return (this.value === null) ? this.placeholder : this.value.name
}
},
methods: {
clearFiles () {
const f = new File([''], this.placeholder)
f._removed = true
if (this.value && this.value.currentfile) {
this.$refs['input-file'].reset()
this.$emit('input', f)
} else {
this.$refs['input-file'].setFiles([f])
this.file = f
this.$emit('input', f)
}
}
} }
} }
</script> </script>

View file

@ -7,14 +7,25 @@
:type="type" :type="type"
:state="state" :state="state"
:required="required" :required="required"
:min="min"
:max="max"
:step="step"
:trim="trim"
:autocomplete="autocomplete_"
@blur="$parent.$emit('touch', name)" @blur="$parent.$emit('touch', name)"
/> />
</template> </template>
<script> <script>
export default { export default {
name: 'InputItem', name: 'InputItem',
data () {
return {
autocomplete_: (this.autocomplete) ? this.autocomplete : (this.type === 'password') ? 'new-password' : null
}
},
props: { props: {
value: { type: [String, Number], default: null }, value: { type: [String, Number], default: null },
id: { type: String, default: null }, id: { type: String, default: null },
@ -22,6 +33,12 @@ export default {
type: { type: String, default: 'text' }, type: { type: String, default: 'text' },
required: { type: Boolean, default: false }, required: { type: Boolean, default: false },
state: { type: Boolean, default: null }, state: { type: Boolean, default: null },
min: { type: Number, default: null },
max: { type: Number, default: null },
step: { type: Number, default: null },
trim: { type: Boolean, default: true },
autocomplete: { type: String, default: null },
pattern: { type: Object, default: null },
name: { type: String, default: null } name: { type: String, default: null }
} }
} }

View file

@ -0,0 +1,15 @@
<template>
<vue-showdown :markdown="label" flavor="github" />
</template>
<script>
export default {
name: 'MarkdownItem',
props: {
id: { type: String, default: null },
label: { type: String, default: null }
}
}
</script>

View file

@ -0,0 +1,42 @@
<template>
<b-alert :variant="type" show>
<icon :iname="icon_" />
<vue-showdown :markdown="label" flavor="github"
tag="span" class="markdown"
/>
</b-alert>
</template>
<script>
export default {
name: 'ReadOnlyAlertItem',
data () {
const icons = {
success: 'thumbs-up',
info: 'info',
warning: 'warning',
danger: 'ban'
}
return {
icon_: (this.icon) ? this.icon : icons[this.type]
}
},
props: {
id: { type: String, default: null },
label: { type: String, default: null },
type: { type: String, default: null },
icon: { type: String, default: null }
}
}
</script>
<style lang="scss">
.icon + span.markdown > *:first-child {
display: inline-block;
}
.alert p:last-child {
margin-bottom: 0;
}
</style>

View file

@ -0,0 +1,36 @@
<template>
<b-form-tags
v-model="tags"
:id="id"
:placeholder="placeholder"
:required="required"
separator=" ,;"
:limit="limit"
remove-on-delete
:state="state"
v-on="$listeners"
@blur="$parent.$emit('touch', name)"
/>
</template>
<script>
export default {
name: 'TagsItem',
data () {
return {
tags: this.value
}
},
props: {
value: { type: Array, default: null },
id: { type: String, default: null },
placeholder: { type: String, default: null },
limit: { type: Number, default: null },
required: { type: Boolean, default: false },
state: { type: Boolean, default: null },
name: { type: String, default: null }
}
}
</script>

View file

@ -67,12 +67,15 @@
<script> <script>
export default { export default {
name: 'TagsSelectize', name: 'TagsSelectizeItem',
props: { props: {
value: { type: Array, required: true }, value: { type: Array, required: true },
options: { type: Array, required: true }, options: { type: Array, required: true },
id: { type: String, required: true }, id: { type: String, required: true },
placeholder: { type: String, default: null },
limit: { type: Number, default: null },
name: { type: String, default: null },
itemsName: { type: String, required: true }, itemsName: { type: String, required: true },
disabledItems: { type: Array, default: () => ([]) }, disabledItems: { type: Array, default: () => ([]) },
// By default `addTag` and `removeTag` have to be executed manually by listening to 'tag-update'. // By default `addTag` and `removeTag` have to be executed manually by listening to 'tag-update'.

View file

@ -0,0 +1,28 @@
<template>
<b-form-textarea
v-model="value"
:id="id"
:placeholder="placeholder"
:required="required"
:state="state"
rows="4"
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>

View file

@ -67,7 +67,6 @@ const unique = items => item => helpers.withParams(
item => items ? !helpers.req(item) || !items.includes(item) : true item => items ? !helpers.req(item) || !items.includes(item) : true
)(item) )(item)
export { export {
alphalownum_, alphalownum_,
domain, domain,

View file

@ -56,85 +56,201 @@ export function adressToFormValue (address) {
* @return {Object} an formated argument containing formItem props, validation and base value. * @return {Object} an formated argument containing formItem props, validation and base value.
*/ */
export function formatYunoHostArgument (arg) { export function formatYunoHostArgument (arg) {
let value = null let value = (arg.value !== undefined) ? arg.value : (arg.current_value !== undefined) ? arg.current_value : null
const validation = {} const validation = {}
const error = { message: null }
arg.ask = formatI18nField(arg.ask)
const field = { const field = {
component: undefined, component: undefined,
label: formatI18nField(arg.ask), label: arg.ask,
props: {} props: {}
} }
const defaultProps = ['id:name', 'placeholder:example']
if (arg.type === 'boolean') { const components = [
field.id = arg.name {
} else { types: [undefined, 'string', 'path'],
field.props.id = arg.name name: 'InputItem',
props: defaultProps.concat(['autocomplete', 'trim'])
},
{
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 = 'good_practices_about_admin_password'
} }
arg.example = '••••••••••••'
// 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 = '••••••••'
validation.passwordLenght = validators.minLength(8) validation.passwordLenght = validators.minLength(8)
} }
},
{
types: ['number', 'range'],
name: 'InputItem',
props: defaultProps.concat(['type', 'min', 'max', 'step']),
callback: function () {
if (!isNaN(parseInt(arg.min))) {
validation.minValue = validators.minValue(parseInt(arg.min))
} }
// Checkbox if (!isNaN(parseInt(arg.max))) {
} else if (arg.type === 'boolean') { validation.maxValue = validators.maxValue(parseInt(arg.max))
field.component = 'CheckboxItem'
if (typeof arg.default === 'number') {
value = arg.default === 1
} else {
value = arg.default || false
} }
// Special (store related) validation.numValue = validators.helpers.regex('Please provide an integer', new RegExp('^-?[0-9]+$'))
} else if (['user', 'domain'].includes(arg.type)) { }
field.component = 'SelectItem' },
{
types: ['select'],
name: 'SelectItem',
props: ['id:name', 'choices']
},
{
types: ['user', 'domain'],
name: 'SelectItem',
props: ['id:name', 'choices'],
callback: function () {
field.link = { name: arg.type + '-list', text: i18n.t(`manage_${arg.type}s`) } field.link = { name: arg.type + '-list', text: i18n.t(`manage_${arg.type}s`) }
field.props.choices = store.getters[arg.type + 'sAsChoices'] field.props.choices = store.getters[arg.type + 'sAsChoices']
if (value) {
return
}
if (arg.type === 'domain') { if (arg.type === 'domain') {
value = store.getters.mainDomain value = store.getters.mainDomain
} else { } else {
value = field.props.choices.length ? field.props.choices[0].value : null 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']),
callback: function () {
if (value) {
value = new File([''], value)
value.currentfile = true
}
}
},
{
types: ['text'],
name: 'TextAreaItem',
props: defaultProps
},
{
types: ['tags'],
name: 'TagsItem',
props: defaultProps.concat(['limit', 'placeholder', 'options:choices', 'tagIcon:icon']),
callback: function () {
if (arg.choices) {
this.name = 'TagsSelectizeItem'
field.props.auto = true
field.props.itemsName = ''
field.props.label = arg.placeholder
}
if (typeof value === 'string') {
value = value.split(',')
} else if (!value) {
value = []
}
}
},
{
types: ['boolean'],
name: 'CheckboxItem',
props: ['id:name', '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'],
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))
if (component === undefined) throw new TypeError('Unknown type: ' + arg.type)
// Callback use for specific behaviour
if (component.callback) component.callback()
field.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) {
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) // 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 validation.required = validators.required
} }
if (arg.pattern) {
// validation.pattern = validators.helpers.withMessage(arg.pattern.error,
validation.pattern = validators.helpers.regex(arg.pattern.error, new RegExp(arg.pattern.regexp))
}
validation.remote = validators.helpers.withParams(error, (v) => {
const result = !error.message
error.message = null
return result
})
// field.props['title'] = field.pattern.error
// Default value if still `null` // Default value if still `null`
if (value === null && arg.current_value) {
value = arg.current_value
}
if (value === null && arg.default) { if (value === null && arg.default) {
value = arg.default value = arg.default
} }
// Help message // Help message
if (arg.help) { if (arg.help) {
field.description = formatI18nField(arg.help) field.description = formatI18nField(arg.help)
} }
// Example
if (arg.example) { // Help message
field.example = arg.example if (arg.helpLink) {
if (field.component === 'InputItem') { field.link = { href: arg.helpLink.href, text: i18n.t(arg.helpLink.text) }
field.props.placeholder = field.example
} }
if (arg.visible) {
field.visible = arg.visible
} }
return { return {
value, value,
field, field,
// Return null instead of empty object if there's no validation // Return null instead of empty object if there's no validation
validation: Object.keys(validation).length === 0 ? null : validation validation: Object.keys(validation).length === 0 ? null : validation,
error
} }
} }
@ -148,10 +264,10 @@ export function formatYunoHostArgument (arg) {
* @return {Object} an object containing all parsed values to be used in vue views. * @return {Object} an object containing all parsed values to be used in vue views.
*/ */
export function formatYunoHostArguments (args, name = null) { export function formatYunoHostArguments (args, name = null) {
let disclaimer = null
const form = {} const form = {}
const fields = {} const fields = {}
const validations = {} const validations = {}
const errors = {}
// FIXME yunohost should add the label field by default // FIXME yunohost should add the label field by default
if (name) { if (name) {
@ -163,20 +279,35 @@ export function formatYunoHostArguments (args, name = null) {
} }
for (const arg of args) { for (const arg of args) {
if (arg.type === 'display_text') { const { value, field, validation, error } = formatYunoHostArgument(arg)
disclaimer = formatI18nField(arg.ask)
} else {
const { value, field, validation } = formatYunoHostArgument(arg)
fields[arg.name] = field fields[arg.name] = field
form[arg.name] = value form[arg.name] = value
if (validation) validations[arg.name] = validation if (validation) validations[arg.name] = validation
} errors[arg.name] = error
} }
return { form, fields, validations, disclaimer } return { form, fields, validations, errors }
} }
export function pFileReader (file, output, key, base64 = true) {
return new Promise((resolve, reject) => {
const fr = new FileReader()
fr.onerror = reject
fr.onload = () => {
output[key] = fr.result
if (base64) {
output[key] = fr.result.replace(/data:[^;]*;base64,/, '')
}
output[key + '[name]'] = file.name
resolve()
}
if (base64) {
fr.readAsDataURL(file)
} else {
fr.readAsText(file)
}
})
}
/** /**
* Format helper for a form value. * Format helper for a form value.
* Convert Boolean to (1|0) and concatenate adresses. * Convert Boolean to (1|0) and concatenate adresses.
@ -206,12 +337,13 @@ export function formatFormDataValue (value) {
*/ */
export function formatFormData ( export function formatFormData (
formData, formData,
{ extract = null, flatten = false, removeEmpty = true } = {} { extract = null, flatten = false, removeEmpty = true, removeNull = false, promise = false } = {}
) { ) {
const output = { const output = {
data: {}, data: {},
extracted: {} extracted: {}
} }
const promises = []
for (const key in formData) { for (const key in formData) {
const type = extract && extract.includes(key) ? 'extracted' : 'data' const type = extract && extract.includes(key) ? 'extracted' : 'data'
const value = Array.isArray(formData[key]) const value = Array.isArray(formData[key])
@ -220,6 +352,16 @@ export function formatFormData (
if (removeEmpty && isEmptyValue(value)) { if (removeEmpty && isEmptyValue(value)) {
continue continue
} else if (removeNull && (value === null || value === undefined)) {
continue
} else if (value instanceof File) {
if (value.currentfile) {
continue
} else if (value._removed) {
output[type][key] = ''
continue
}
promises.push(pFileReader(value, output[type], key))
} else if (flatten && isObjectLiteral(value)) { } else if (flatten && isObjectLiteral(value)) {
flattenObjectLiteral(value, output[type]) flattenObjectLiteral(value, output[type])
} else { } else {
@ -227,5 +369,13 @@ export function formatFormData (
} }
} }
const { data, extracted } = output 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 return extract ? { data, ...extracted } : data
} }
}

View file

@ -176,11 +176,15 @@
"githubLink": "URL must be a valid GitHub link to a repository", "githubLink": "URL must be a valid GitHub link to a repository",
"name": "Names may not includes special characters except <code> ,.'-</code>", "name": "Names may not includes special characters except <code> ,.'-</code>",
"minValue": "Value must be a number equal or greater than {min}.", "minValue": "Value must be a number equal or greater than {min}.",
"maxValue": "Value must be a number equal or lesser than {max}.",
"notInUsers": "The user '{value}' already exists.", "notInUsers": "The user '{value}' already exists.",
"number": "Value must be a number.", "number": "Value must be a number.",
"passwordLenght": "Password must be at least 8 characters long.", "passwordLenght": "Password must be at least 8 characters long.",
"passwordMatch": "Passwords don't match.", "passwordMatch": "Passwords don't match.",
"required": "Field is required." "required": "Field is required.",
"remote": "{message}",
"pattern": "{type}",
"invalid_form": "The form contains some errors."
}, },
"form_input_example": "Example: {example}", "form_input_example": "Example: {example}",
"from_to": "from {0} to {1}", "from_to": "from {0} to {1}",

View file

@ -1,6 +1,7 @@
import Vue from 'vue' import Vue from 'vue'
import App from './App.vue' import App from './App.vue'
import BootstrapVue from 'bootstrap-vue' import BootstrapVue from 'bootstrap-vue'
import VueShowdown from 'vue-showdown'
import i18n from './i18n' import i18n from './i18n'
import router from './router' import router from './router'
@ -11,7 +12,6 @@ import { registerGlobalErrorHandlers } from './api'
Vue.config.productionTip = false Vue.config.productionTip = false
// Styles are imported in `src/App.vue` <style> // Styles are imported in `src/App.vue` <style>
Vue.use(BootstrapVue, { Vue.use(BootstrapVue, {
BSkeleton: { animation: 'none' }, 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 // Ugly wrapper for `$bvModal.msgBoxConfirm` to set default i18n button titles
// FIXME find or wait for a better way // FIXME find or wait for a better way
@ -46,13 +51,14 @@ requireComponent.keys().forEach((fileName) => {
Vue.component(component.name, component) Vue.component(component.name, component)
}) })
registerGlobalErrorHandlers() registerGlobalErrorHandlers()
new Vue({ const app = new Vue({
i18n, i18n,
router, router,
store, store,
render: h => h(App) render: h => h(App)
}).$mount('#app') })
app.$mount('#app')

View file

@ -15,14 +15,6 @@
:validation="$v.actions[i]" :id="action.id + '-form'" :server-error="action.serverError" :validation="$v.actions[i]" :id="action.id + '-form'" :server-error="action.serverError"
@submit.prevent="performAction(action)" :submit-text="$t('perform')" @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 <form-field
v-for="(field, fname) in action.fields" :key="fname" label-cols="0" 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]" v-bind="field" v-model="action.form[fname]" :validation="$v.actions[i][fname]"
@ -85,11 +77,10 @@ export default {
const action = { name, id, serverError: '' } const action = { name, id, serverError: '' }
if (description) action.description = formatI18nField(description) if (description) action.description = formatI18nField(description)
if (arguments_ && arguments_.length) { if (arguments_ && arguments_.length) {
const { form, fields, validations, disclaimer } = formatYunoHostArguments(arguments_) const { form, fields, validations } = formatYunoHostArguments(arguments_)
action.form = form action.form = form
action.fields = fields action.fields = fields
if (validations) action.validations = validations if (validations) action.validations = validations
if (disclaimer) action.formDisclaimer = disclaimer
} }
return action return action
}) })

View file

@ -1,30 +1,40 @@
<template> <template>
<view-base :queries="queries" @queries-response="onQueriesResponse" skeleton="card-form-skeleton"> <view-base :queries="queries" @queries-response="onQueriesResponse" skeleton="card-form-skeleton">
<template v-if="panels" #default> <template v-if="panels" #default>
<b-alert variant="warning" class="mb-4"> <b-tabs pills card vertical>
<icon iname="exclamation-triangle" /> {{ $t('experimental_warning') }} <b-tab v-for="{ name, id: id_, sections, help, serverError } in panels"
</b-alert> :key="id_"
:title="name"
>
<template #title>
<icon iname="wrench" /> {{ name }}
</template>
<card-form <card-form
v-for="{ name, id: id_, sections, help, serverError } in panels" :key="id_" :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" :validation="$v.forms[id_]" :id="id_ + '-form'" :server-error="serverError"
collapsable
@submit.prevent="applyConfig(id_)" @submit.prevent="applyConfig(id_)"
> >
<template v-if="help" #disclaimer> <template v-if="help" #disclaimer>
<div class="alert alert-info" v-html="help" /> <div class="alert alert-info" v-html="help" />
</template> </template>
<div v-for="section in sections" :key="section.id" class="mb-5"> <template v-for="section in sections">
<b-card-title>{{ section.name }} <small v-if="section.help">{{ section.help }}</small></b-card-title> <div :key="section.id" class="mb-5" v-if="isVisible(section.visible)">
<b-card-title v-if="section.name" title-tag="h3">
<form-field {{ section.name }} <small v-if="section.help">{{ section.help }}</small>
v-for="(field, fname) in section.fields" :key="fname" label-cols="0" </b-card-title>
v-bind="field" v-model="forms[id_][fname]" :validation="$v.forms[id_][fname]" <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.visible)" v-bind="field"
/> />
</template>
</div> </div>
</template>
</card-form> </card-form>
</b-tab>
</b-tabs>
</template> </template>
<!-- if no config panel --> <!-- if no config panel -->
@ -36,10 +46,11 @@
<script> <script>
import { validationMixin } from 'vuelidate' import { validationMixin } from 'vuelidate'
import evaluate from 'simple-evaluate'
// FIXME needs test and rework // FIXME needs test and rework
import api, { objectToParams } from '@/api' import api, { objectToParams } from '@/api'
import { formatI18nField, formatYunoHostArguments, formatFormData } from '@/helpers/yunohostArguments' import { formatI18nField, formatYunoHostArguments, formatFormData, pFileReader } from '@/helpers/yunohostArguments'
export default { export default {
name: 'AppConfigPanel', name: 'AppConfigPanel',
@ -53,13 +64,14 @@ export default {
data () { data () {
return { return {
queries: [ queries: [
['GET', `apps/${this.id}/config-panel`], ['GET', `apps/${this.id}/config-panel?full`],
['GET', { uri: 'domains' }], ['GET', { uri: 'domains' }],
['GET', { uri: 'domains/main', storeKey: 'main_domain' }], ['GET', { uri: 'domains/main', storeKey: 'main_domain' }],
['GET', { uri: 'users' }] ['GET', { uri: 'users' }]
], ],
panels: undefined, panels: undefined,
forms: undefined, forms: undefined,
errors: undefined,
validations: null validations: null
} }
}, },
@ -69,27 +81,71 @@ export default {
}, },
methods: { methods: {
isVisible (expression) {
if (!expression) return true
const context = {}
const promises = []
for (const args of Object.values(this.forms)) {
for (const shortname in args) {
if (args[shortname] instanceof File) {
if (expression.includes(shortname)) {
promises.push(pFileReader(args[shortname], context, shortname, false))
}
} else {
context[shortname] = args[shortname]
}
}
}
// Allow to use match(var,regexp) function
const matchRe = new RegExp('match\\(\\s*(\\w+)\\s*,\\s*"([^"]+)"\\s*\\)', 'g')
let i = 0
return new Promise((resolve, reject) => {
i = 2
resolve(false)
Promise.all(promises).then((value) => {
for (const matched of expression.matchAll(matchRe)) {
i++
const varName = matched[1] + '__re' + i.toString()
context[varName] = new RegExp(matched[2]).test(context[matched[1]])
expression = expression.replace(matched[0], varName)
}
try {
resolve(evaluate(context, expression))
} catch (error) {
resolve(false)
}
})
})
},
onQueriesResponse (data) { onQueriesResponse (data) {
if (!data.config_panel || data.config_panel.length === 0) { if (!data.panels || data.panels.length === 0) {
this.panels = null this.panels = null
return return
} }
const forms = {} const forms = {}
const validations_ = {} const validations_ = {}
const errors_ = {}
const panels_ = [] const panels_ = []
for (const { id, name, help, sections } of data.config_panel.panel) { for (const { id, name, help, sections } of data.panels) {
const panel_ = { id, name, sections: [] } const panel_ = { id, sections: [] }
if (name) panel_.name = formatI18nField(name)
if (help) panel_.help = formatI18nField(help) if (help) panel_.help = formatI18nField(help)
forms[id] = {} forms[id] = {}
validations_[id] = {} validations_[id] = {}
for (const { name, help, options } of sections) { errors_[id] = {}
const section_ = { name } for (const { id_, name, help, visible, options } of sections) {
const section_ = { id: id_, visible }
if (help) section_.help = formatI18nField(help) if (help) section_.help = formatI18nField(help)
const { form, fields, validations } = formatYunoHostArguments(options) if (name) section_.name = formatI18nField(name)
const { form, fields, validations, errors } = formatYunoHostArguments(options)
Object.assign(forms[id], form) Object.assign(forms[id], form)
Object.assign(validations_[id], validations) Object.assign(validations_[id], validations)
panel_.sections.push({ name, fields }) Object.assign(errors_[id], errors)
section_.fields = fields
panel_.sections.push(section_)
} }
panels_.push(panel_) panels_.push(panel_)
} }
@ -97,23 +153,34 @@ export default {
this.forms = forms this.forms = forms
this.validations = { forms: validations_ } this.validations = { forms: validations_ }
this.panels = panels_ this.panels = panels_
this.errors = errors_
}, },
applyConfig (id_) { applyConfig (id_) {
const args = objectToParams(formatFormData(this.forms[id_])) formatFormData(this.forms[id_], { promise: true, removeEmpty: false, removeNull: true }).then((formatedData) => {
const args = objectToParams(formatedData)
api.put( api.put(
`apps/${this.id}/config`, { args }, { key: 'apps.update_config', name: this.id } `apps/${this.id}/config`, { key: id_, args }, { key: 'apps.update_config', name: this.id }
).then(response => { ).then(response => {
// FIXME what should be done ?
/* eslint-disable-next-line */
console.log('SUCCESS', response)
}).catch(err => { }).catch(err => {
if (err.name !== 'APIBadRequestError') throw err if (err.name !== 'APIBadRequestError') throw err
const panel = this.panels.find(({ id }) => id_ === id) const panel = this.panels.find(({ id }) => id_ === id)
this.$set(panel, 'serverError', err.message) if (err.data.name) {
this.errors[id_][err.data.name].message = err.message
} else this.$set(panel, 'serverError', err.message)
})
}) })
} }
} }
} }
</script> </script>
<style>
h3.card-title {
margin-bottom: 1em;
border-bottom: solid 1px #aaa;
}
.form-control::placeholder, .form-file-text {
color: #6d7780;
}
</style>

View file

@ -23,10 +23,6 @@
:validation="$v" :server-error="serverError" :validation="$v" :server-error="serverError"
@submit.prevent="performInstall" @submit.prevent="performInstall"
> >
<template v-if="formDisclaimer" #disclaimer>
<div class="alert alert-info" v-html="formDisclaimer" />
</template>
<form-field <form-field
v-for="(field, fname) in fields" :key="fname" label-cols="0" v-for="(field, fname) in fields" :key="fname" label-cols="0"
v-bind="field" v-model="form[fname]" :validation="$v.form[fname]" v-bind="field" v-model="form[fname]" :validation="$v.form[fname]"
@ -75,6 +71,7 @@ export default {
form: undefined, form: undefined,
fields: undefined, fields: undefined,
validations: null, validations: null,
errors: undefined,
serverError: '' serverError: ''
} }
}, },
@ -94,15 +91,15 @@ export default {
manifest.multi_instance = this.$i18n.t(manifest.multi_instance ? 'yes' : 'no') manifest.multi_instance = this.$i18n.t(manifest.multi_instance ? 'yes' : 'no')
this.infos = Object.fromEntries(infosKeys.map(key => [key, manifest[key]])) this.infos = Object.fromEntries(infosKeys.map(key => [key, manifest[key]]))
const { form, fields, validations, disclaimer } = formatYunoHostArguments( const { form, fields, validations, errors } = formatYunoHostArguments(
manifest.arguments.install, manifest.arguments.install,
manifest.name manifest.name
) )
this.formDisclaimer = disclaimer
this.fields = fields this.fields = fields
this.form = form this.form = form
this.validations = { form: validations } this.validations = { form: validations }
this.errors = errors
}, },
async performInstall () { async performInstall () {
@ -120,7 +117,9 @@ export default {
this.$router.push({ name: 'app-list' }) this.$router.push({ name: 'app-list' })
}).catch(err => { }).catch(err => {
if (err.name !== 'APIBadRequestError') throw err if (err.name !== 'APIBadRequestError') throw err
this.serverError = err.message if (err.data.name) {
this.errors[err.data.name].message = err.message
} else this.serverError = err.message
}) })
} }
} }

View file

@ -44,7 +44,7 @@
</p> </p>
</template> </template>
<template v-else> <template v-else>
<tags-selectize <tags-selectize-item
v-model="group.members" :options="usersOptions" v-model="group.members" :options="usersOptions"
:id="groupName + '-users'" :label="$t('group_add_member')" :id="groupName + '-users'" :label="$t('group_add_member')"
tag-icon="user" items-name="users" tag-icon="user" items-name="users"
@ -60,7 +60,7 @@
<strong>{{ $t('permissions') }}</strong> <strong>{{ $t('permissions') }}</strong>
</b-col> </b-col>
<b-col> <b-col>
<tags-selectize <tags-selectize-item
v-model="group.permissions" :options="permissionsOptions" v-model="group.permissions" :options="permissionsOptions"
:id="groupName + '-perms'" :label="$t('group_add_permission')" :id="groupName + '-perms'" :label="$t('group_add_permission')"
tag-icon="key-modern" items-name="permissions" tag-icon="key-modern" items-name="permissions"
@ -83,7 +83,7 @@
</b-col> </b-col>
<b-col> <b-col>
<tags-selectize <tags-selectize-item
v-model="userGroups[userName].permissions" :options="permissionsOptions" v-model="userGroups[userName].permissions" :options="permissionsOptions"
:id="userName + '-perms'" :label="$t('group_add_permission')" :id="userName + '-perms'" :label="$t('group_add_permission')"
tag-icon="key-modern" items-name="permissions" tag-icon="key-modern" items-name="permissions"
@ -94,7 +94,7 @@
<hr :key="index"> <hr :key="index">
</template> </template>
<tags-selectize <tags-selectize-item
v-model="activeUserGroups" :options="usersOptions" v-model="activeUserGroups" :options="usersOptions"
id="user-groups" :label="$t('group_add_member')" id="user-groups" :label="$t('group_add_member')"
no-tags items-name="users" no-tags items-name="users"
@ -109,7 +109,7 @@ import Vue from 'vue'
import api from '@/api' import api from '@/api'
import { isEmptyValue } from '@/helpers/commons' import { isEmptyValue } from '@/helpers/commons'
import TagsSelectize from '@/components/TagsSelectize' import TagsSelectizeItem from '@/components/globals/formItems/TagsSelectizeItem'
// TODO add global search with type (search by: group, user, permission) // TODO add global search with type (search by: group, user, permission)
// TODO add vuex store update on inputs ? // TODO add vuex store update on inputs ?
@ -117,7 +117,7 @@ export default {
name: 'GroupList', name: 'GroupList',
components: { components: {
TagsSelectize TagsSelectizeItem
}, },
data () { data () {

View file

@ -205,12 +205,12 @@ export default {
label: this.$i18n.t('password'), label: this.$i18n.t('password'),
description: this.$i18n.t('good_practices_about_user_password'), description: this.$i18n.t('good_practices_about_user_password'),
descriptionVariant: 'warning', descriptionVariant: 'warning',
props: { id: 'change_password', type: 'password', placeholder: '••••••••', autocomplete: "new-password" } props: { id: 'change_password', type: 'password', placeholder: '••••••••', autocomplete: 'new-password' }
}, },
confirmation: { confirmation: {
label: this.$i18n.t('password_confirmation'), label: this.$i18n.t('password_confirmation'),
props: { id: 'confirmation', type: 'password', placeholder: '••••••••', autocomplete: "new-password" } props: { id: 'confirmation', type: 'password', placeholder: '••••••••', autocomplete: 'new-password' }
} }
} }
} }

View file

@ -26,7 +26,6 @@
<icon iname="download" /> {{ $t('users_export') }} <icon iname="download" /> {{ $t('users_export') }}
</b-dropdown-item> </b-dropdown-item>
</b-dropdown> </b-dropdown>
</template> </template>
<b-list-group> <b-list-group>

View file

@ -1,4 +1,5 @@
const webpack = require('webpack') const webpack = require('webpack')
const fs = require('fs');
const dateFnsLocales = [ const dateFnsLocales = [
'ar', 'ar',
@ -58,17 +59,20 @@ module.exports = {
}, },
publicPath: '/yunohost/admin', publicPath: '/yunohost/admin',
devServer: { devServer: {
public: fs.readFileSync('/etc/yunohost/current_host', 'utf8'),
https: false, https: false,
disableHostCheck: true, disableHostCheck: true,
proxy: { proxy: {
'^/yunohost': { '^/yunohost': {
target: `http://${process.env.VUE_APP_IP}`, target: `http://${process.env.VUE_APP_IP}`,
ws: true, ws: true,
logLevel: 'debug' logLevel: 'info'
} }
}, },
watchOptions: { watchOptions: {
ignored: /node_modules/ ignored: /node_modules/,
} aggregateTimeout: 300,
poll: 1000
},
} }
} }