Manager: Add Input Validation to Settings page
Prevent fields from being empty, when it's known that Flamenco Manager will not start up if they are. The icons for the variable add/delete are enhanced with colors and icons +/- or with a trashcan SVG. Error/warning messages appear under inputs when values are invalid on blur. `FormInputDropdownSelect` is also created here, decoupling the validation from Dropdown Select. `DropdownSelect`'s props now have type definitions. CSS selectors are more specified, and renamed to be more fitting. ### What 'required' means for each input - For text, `required` means the field cannot be empty. - For numbers, `required` means the field cannot be empty, and having a `min` and/or `max` means the number must be equal to or above/below the min/max. - For dropdowns, `required` means the selection cannot be empty, and `strict` means that an option not included in the list passed to the `options` prop cannot be selected. - For the new variable input, empty strings, duplicate variable names, and variable names that contain `{` or `}` are invalid. ### Required Settings To keep the application running and remaining on the same page, these fields **must** be non-empty strings: - `database` and `shared_storage_path` (both which can be invalid so long as they are non-empty) - `listen` (which MUST be a valid value AND non-empty) When `shared_storage_path` is empty, the application will automatically jump to the Setup Assistant which after completing will create a new `flamenco-manager.yaml` and restart the application. If `database` is empty and `listen` is not a proper port, the application will fail to start, leading the user to a dead end and forcing them to manually configure `flamenco-manager.yaml` to get it running again. To prevent the backend from throwing a `Bad Request` error, numerical and time duration inputs **must not** be null or empty: - `database_check_period`, `task_timeout`, `worker_timeout`, `blocklist_threshold`, `task_fail_after_softfail_count` Pull Request: https://projects.blender.org/studio/flamenco/pulls/104409
This commit is contained in:
parent
9603f3b711
commit
7ddce9ac22
@ -6,20 +6,73 @@ select {
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: ['modelValue', 'id', 'disabled', 'options'],
|
data() {
|
||||||
|
return {
|
||||||
|
errorMsg: '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
id: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
// options is a k,v map where
|
||||||
|
// k is the value to be saved in modelValue and
|
||||||
|
// v is the label to be rendered to the user
|
||||||
|
options: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
required: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
// Input validation to ensure the value matches one of the options
|
||||||
|
strict: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
emits: ['update:modelValue'],
|
emits: ['update:modelValue'],
|
||||||
|
methods: {
|
||||||
|
onChange(event) {
|
||||||
|
// Update the value from the parent component
|
||||||
|
this.$emit('update:modelValue', event.target.value);
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<select
|
<select :required="required" :id="id" :value="modelValue" @change="onChange" :disabled="disabled">
|
||||||
class="time"
|
<!-- The default to show and select if modelValue is a non-option and either an empty string, null, or undefined -->
|
||||||
:id="id"
|
<option
|
||||||
:value="modelValue"
|
:value="''"
|
||||||
@change="$emit('update:modelValue', $event.target.value)"
|
:selected="
|
||||||
:disabled="disabled">
|
!(modelValue in options) &&
|
||||||
<!-- This option only shows if its current value does not match any of the preset options. -->
|
(modelValue === '' || modelValue === null || modelValue === undefined)
|
||||||
<option v-if="!(modelValue in options)" :value="modelValue" selected>
|
">
|
||||||
|
{{ 'Select an option' }}
|
||||||
|
</option>
|
||||||
|
<!-- Show the non-option value if it is not an empty string, null, or undefined; disable it if strict is enabled -->
|
||||||
|
<option
|
||||||
|
v-if="
|
||||||
|
!(modelValue in options) &&
|
||||||
|
modelValue !== '' &&
|
||||||
|
modelValue !== null &&
|
||||||
|
modelValue !== undefined
|
||||||
|
"
|
||||||
|
:disabled="!(modelValue in options) && strict"
|
||||||
|
:value="modelValue"
|
||||||
|
:selected="!(modelValue in options) && !strict">
|
||||||
{{ modelValue }}
|
{{ modelValue }}
|
||||||
</option>
|
</option>
|
||||||
<template :key="o" v-for="o in Object.keys(options)">
|
<template :key="o" v-for="o in Object.keys(options)">
|
||||||
|
109
web/app/src/components/settings/FormInputDropdownSelect.vue
Normal file
109
web/app/src/components/settings/FormInputDropdownSelect.vue
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
<template>
|
||||||
|
<div class="form-col">
|
||||||
|
<label v-if="label" :for="id">{{ label }}</label>
|
||||||
|
<DropdownSelect
|
||||||
|
:required="required"
|
||||||
|
:strict="strict"
|
||||||
|
:disabled="disabled"
|
||||||
|
:options="options"
|
||||||
|
v-model="model"
|
||||||
|
@change="onChange"
|
||||||
|
:id="id" />
|
||||||
|
<span :class="{ hidden: !errorMsg, error: errorMsg }">{{ errorMsg }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import DropdownSelect from '@/components/settings/DropdownSelect.vue';
|
||||||
|
export default {
|
||||||
|
name: 'FormInputDropdownSelect',
|
||||||
|
components: {
|
||||||
|
DropdownSelect,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
id: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
// options is a k,v map where
|
||||||
|
// k is the value to be saved in modelValue and
|
||||||
|
// v is the label to be rendered to the user
|
||||||
|
options: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
required: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
// Input validation to ensure the value matches one of the options
|
||||||
|
strict: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
errorMsg: '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
model: {
|
||||||
|
get() {
|
||||||
|
return this.modelValue;
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
this.$emit('update:modelValue', value);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
modelValue() {
|
||||||
|
// If the value gets populated after component creation, check for strictness again
|
||||||
|
this.enforceStrict();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
// Check for strictness upon component creation
|
||||||
|
this.enforceStrict();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
enforceStrict() {
|
||||||
|
// If strict is enabled and the current selection is not in the provided options, print an error message.
|
||||||
|
if (
|
||||||
|
this.strict &&
|
||||||
|
!(this.modelValue in this.options) &&
|
||||||
|
this.modelValue !== '' &&
|
||||||
|
this.modelValue !== null &&
|
||||||
|
this.modelValue !== undefined
|
||||||
|
) {
|
||||||
|
this.errorMsg = 'Invalid option.';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onChange(event) {
|
||||||
|
// If required is enabled, and the value is empty, print the error message
|
||||||
|
if (event.target.value === '' && this.required) {
|
||||||
|
this.errorMsg = 'Selection required.';
|
||||||
|
} else {
|
||||||
|
this.errorMsg = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the value from the parent component
|
||||||
|
this.$emit('update:modelValue', event.target.value);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="form-row">
|
<div class="form-col">
|
||||||
<label :for="id">{{ label }}</label>
|
<label :for="id">{{ label }}</label>
|
||||||
<input
|
<input
|
||||||
:required="required"
|
:required="required"
|
||||||
@ -73,7 +73,7 @@ export default {
|
|||||||
// Supports .lazy
|
// Supports .lazy
|
||||||
// Can add validation here
|
// Can add validation here
|
||||||
if (event.target.value === '' && this.required) {
|
if (event.target.value === '' && this.required) {
|
||||||
this.errorMsg = 'Field required.';
|
this.errorMsg = 'This field is required.';
|
||||||
} else {
|
} else {
|
||||||
this.errorMsg = '';
|
this.errorMsg = '';
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="form-row">
|
<div class="form-col">
|
||||||
<label class="form-switch-row">
|
<label class="form-switch-row">
|
||||||
<span>{{ label }}</span>
|
<span>{{ label }}</span>
|
||||||
<span class="switch">
|
<span class="switch">
|
||||||
@ -7,7 +7,7 @@
|
|||||||
<span class="slider round"></span>
|
<span class="slider round"></span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<p>
|
<p class="text-color-hint">
|
||||||
{{ description }}
|
{{ description }}
|
||||||
<template v-if="moreInfoText">
|
<template v-if="moreInfoText">
|
||||||
{{ moreInfoText }}
|
{{ moreInfoText }}
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
<div
|
<div
|
||||||
:class="{
|
:class="{
|
||||||
hidden: hidden,
|
hidden: hidden,
|
||||||
'form-row': !hidden,
|
'form-col': !hidden,
|
||||||
}">
|
}">
|
||||||
<label v-if="label" :for="id">{{ label }}</label>
|
<label v-if="label" :for="id">{{ label }}</label>
|
||||||
<input
|
<input
|
||||||
@ -67,7 +67,7 @@ export default {
|
|||||||
// Supports .lazy
|
// Supports .lazy
|
||||||
// Can add validation here
|
// Can add validation here
|
||||||
if (event.target.value === '' && this.required) {
|
if (event.target.value === '' && this.required) {
|
||||||
this.errorMsg = 'Field required.';
|
this.errorMsg = 'This field is required.';
|
||||||
} else {
|
} else {
|
||||||
this.errorMsg = '';
|
this.errorMsg = '';
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import NotificationBar from '@/components/footer/NotificationBar.vue';
|
import NotificationBar from '@/components/footer/NotificationBar.vue';
|
||||||
import UpdateListener from '@/components/UpdateListener.vue';
|
import UpdateListener from '@/components/UpdateListener.vue';
|
||||||
import DropdownSelect from '@/components/settings/DropdownSelect.vue';
|
import FormInputDropdownSelect from '@/components/settings/FormInputDropdownSelect.vue';
|
||||||
import FormInputSwitchCheckbox from '@/components/settings/FormInputSwitchCheckbox.vue';
|
import FormInputSwitchCheckbox from '@/components/settings/FormInputSwitchCheckbox.vue';
|
||||||
import FormInputText from '@/components/settings/FormInputText.vue';
|
import FormInputText from '@/components/settings/FormInputText.vue';
|
||||||
import FormInputNumber from '@/components/settings/FormInputNumber.vue';
|
import FormInputNumber from '@/components/settings/FormInputNumber.vue';
|
||||||
@ -91,16 +91,19 @@ const initialFormValues = {
|
|||||||
type: inputTypes.string,
|
type: inputTypes.string,
|
||||||
label: 'Database',
|
label: 'Database',
|
||||||
value: null,
|
value: null,
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
database_check_period: {
|
database_check_period: {
|
||||||
type: inputTypes.timeDuration,
|
type: inputTypes.timeDuration,
|
||||||
label: 'Database Check Period',
|
label: 'Database Check Period',
|
||||||
value: null,
|
value: null,
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
listen: {
|
listen: {
|
||||||
type: inputTypes.string,
|
type: inputTypes.string,
|
||||||
label: 'Listening IP and Port Number',
|
label: 'Listening IP and Port Number',
|
||||||
value: null,
|
value: null,
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
autodiscoverable: {
|
autodiscoverable: {
|
||||||
type: inputTypes.boolean,
|
type: inputTypes.boolean,
|
||||||
@ -120,6 +123,7 @@ const initialFormValues = {
|
|||||||
type: inputTypes.string,
|
type: inputTypes.string,
|
||||||
label: 'Shared Storage Path',
|
label: 'Shared Storage Path',
|
||||||
value: null,
|
value: null,
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
shaman: {
|
shaman: {
|
||||||
enabled: {
|
enabled: {
|
||||||
@ -136,8 +140,8 @@ const initialFormValues = {
|
|||||||
moreInfoLinkLabel: `Shaman Storage System`,
|
moreInfoLinkLabel: `Shaman Storage System`,
|
||||||
},
|
},
|
||||||
garbageCollect: {
|
garbageCollect: {
|
||||||
period: { type: inputTypes.timeDuration, label: 'Period', value: null },
|
period: { type: inputTypes.timeDuration, label: 'Period', value: null, required: true },
|
||||||
maxAge: { type: inputTypes.timeDuration, label: 'Max Age', value: null },
|
maxAge: { type: inputTypes.timeDuration, label: 'Max Age', value: null, required: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -146,21 +150,25 @@ const initialFormValues = {
|
|||||||
type: inputTypes.timeDuration,
|
type: inputTypes.timeDuration,
|
||||||
label: 'Task Timeout',
|
label: 'Task Timeout',
|
||||||
value: null,
|
value: null,
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
worker_timeout: {
|
worker_timeout: {
|
||||||
type: inputTypes.timeDuration,
|
type: inputTypes.timeDuration,
|
||||||
label: 'Worker Timeout',
|
label: 'Worker Timeout',
|
||||||
value: null,
|
value: null,
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
blocklist_threshold: {
|
blocklist_threshold: {
|
||||||
type: inputTypes.number,
|
type: inputTypes.number,
|
||||||
label: 'Blocklist Threshold',
|
label: 'Blocklist Threshold',
|
||||||
value: null,
|
value: null,
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
task_fail_after_softfail_count: {
|
task_fail_after_softfail_count: {
|
||||||
type: inputTypes.number,
|
type: inputTypes.number,
|
||||||
label: 'Task Fail after Soft Fail Count',
|
label: 'Task Fail after Soft Fail Count',
|
||||||
value: null,
|
value: null,
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
// MQTT
|
// MQTT
|
||||||
@ -198,16 +206,18 @@ export default {
|
|||||||
components: {
|
components: {
|
||||||
NotificationBar,
|
NotificationBar,
|
||||||
UpdateListener,
|
UpdateListener,
|
||||||
DropdownSelect,
|
|
||||||
FormInputText,
|
FormInputText,
|
||||||
FormInputNumber,
|
FormInputNumber,
|
||||||
FormInputSwitchCheckbox,
|
FormInputSwitchCheckbox,
|
||||||
|
FormInputDropdownSelect,
|
||||||
},
|
},
|
||||||
data: () => ({
|
data: () => ({
|
||||||
// Make a deep copy so it can be compared to the original for isDirty check to work
|
// Make a deep copy so it can be compared to the original for isDirty check to work
|
||||||
config: JSON.parse(JSON.stringify(initialFormValues)),
|
config: JSON.parse(JSON.stringify(initialFormValues)),
|
||||||
originalConfig: JSON.parse(JSON.stringify(initialFormValues)),
|
originalConfig: JSON.parse(JSON.stringify(initialFormValues)),
|
||||||
newVariableName: '',
|
newVariableName: '',
|
||||||
|
newVariableErrorMessage: '',
|
||||||
|
newVariableTouched: false,
|
||||||
metaAPI: new MetaApi(getAPIClient()),
|
metaAPI: new MetaApi(getAPIClient()),
|
||||||
|
|
||||||
// Static data
|
// Static data
|
||||||
@ -226,18 +236,36 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
addVariableOnInput() {
|
||||||
|
this.newVariableTouched = true;
|
||||||
|
},
|
||||||
canAddVariable() {
|
canAddVariable() {
|
||||||
|
// Don't show an error message if the field is blank e.g. after a user adds a variable name
|
||||||
|
// but still prevent variable addition
|
||||||
|
if (this.newVariableName === '') {
|
||||||
|
this.newVariableErrorMessage = '';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Duplicate variable name
|
// Duplicate variable name
|
||||||
if (this.newVariableName in this.config.variables) {
|
if (this.newVariableName in this.config.variables) {
|
||||||
// TODO: add error message here
|
this.newVariableErrorMessage = 'Duplicate variable name found.';
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whitespace only
|
// Whitespace only
|
||||||
if (!this.newVariableName.trim()) {
|
if (!this.newVariableName.trim()) {
|
||||||
// TODO: add error message here
|
this.newVariableErrorMessage = 'Must have at least one non-whitespace character.';
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Curly brace detection
|
||||||
|
if (this.newVariableName.match(/[{}]/)) {
|
||||||
|
this.newVariableErrorMessage =
|
||||||
|
'Variable name cannot contain any of the following characters: {}';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.newVariableErrorMessage = '';
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
handleAddVariable() {
|
handleAddVariable() {
|
||||||
@ -444,25 +472,31 @@ export default {
|
|||||||
<a :href="'#' + category.id">{{ category.label }}</a>
|
<a :href="'#' + category.id">{{ category.label }}</a>
|
||||||
</div>
|
</div>
|
||||||
<!-- TODO: Add a "Reset to Previous Value button" -->
|
<!-- TODO: Add a "Reset to Previous Value button" -->
|
||||||
<button type="submit" form="config-form" class="save-button" :disabled="!canSave()">
|
<button
|
||||||
|
type="submit"
|
||||||
|
form="config-form"
|
||||||
|
class="action-button margin-left-auto"
|
||||||
|
:disabled="!canSave()">
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
<aside class="side-container">
|
<aside class="side-container">
|
||||||
<div class="dialog">
|
<div class="dialog">
|
||||||
<div class="dialog-content">
|
<div class="flex-col gap-col-spacer">
|
||||||
<h2>Settings</h2>
|
<div class="flex-col">
|
||||||
<p>
|
<h2>Settings</h2>
|
||||||
This editor allows you to configure the settings for the Flamenco Server. These changes
|
<p class="text-color-hint">
|
||||||
will directly edit the
|
This editor allows you to configure the settings for the Flamenco Server. These
|
||||||
<span class="file-name"> flamenco-manager.yaml </span>
|
changes will directly edit the
|
||||||
file. For more information, see
|
<span class="file-name"> flamenco-manager.yaml </span>
|
||||||
<a class="link" href="https://flamenco.blender.org/usage/manager-configuration/">
|
file. For more information, see
|
||||||
Manager Configuration
|
<a class="link" href="https://flamenco.blender.org/usage/manager-configuration/">
|
||||||
</a>
|
Manager Configuration</a
|
||||||
</p>
|
>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<!-- TODO: add attribute descriptions when onFocus -->
|
||||||
</div>
|
</div>
|
||||||
<!-- TODO: add attribute descriptions when onFocus -->
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<form id="config-form" class="form-container" @submit.prevent="saveConfig">
|
<form id="config-form" class="form-container" @submit.prevent="saveConfig">
|
||||||
@ -471,9 +505,10 @@ export default {
|
|||||||
<h2 :id="category.id">{{ category.label }}</h2>
|
<h2 :id="category.id">{{ category.label }}</h2>
|
||||||
<!-- Variables -->
|
<!-- Variables -->
|
||||||
<template v-if="category.id === 'variables'">
|
<template v-if="category.id === 'variables'">
|
||||||
<div class="form-row">
|
<div class="form-col">
|
||||||
<div class="form-variable-row">
|
<div class="form-row gap-col-spacer">
|
||||||
<input
|
<input
|
||||||
|
@input="addVariableOnInput"
|
||||||
@keydown.enter.prevent="canAddVariable() ? handleAddVariable() : null"
|
@keydown.enter.prevent="canAddVariable() ? handleAddVariable() : null"
|
||||||
placeholder="variableName"
|
placeholder="variableName"
|
||||||
type="text"
|
type="text"
|
||||||
@ -487,6 +522,15 @@ export default {
|
|||||||
Add Variable
|
Add Variable
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
hidden: !newVariableErrorMessage || !newVariableTouched,
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
>{{ newVariableErrorMessage }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<section
|
<section
|
||||||
class="form-variable-section"
|
class="form-variable-section"
|
||||||
@ -496,37 +540,51 @@ export default {
|
|||||||
<h3>
|
<h3>
|
||||||
<pre>{{ variableName }}</pre>
|
<pre>{{ variableName }}</pre>
|
||||||
</h3>
|
</h3>
|
||||||
<button type="button" @click="handleDeleteVariable(variableName)">Delete</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="delete-button"
|
||||||
|
@click="handleDeleteVariable(variableName)">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25">
|
||||||
|
<g id="trash">
|
||||||
|
<path
|
||||||
|
class="trash"
|
||||||
|
d="M20.5 4h-3.64l-.69-2.06a1.37 1.37 0 0 0-1.3-.94h-4.74a1.37 1.37 0 0 0-1.3.94L8.14 4H4.5a.5.5 0 0 0 0 1h.34l1 17.59A1.45 1.45 0 0 0 7.2 24h10.6a1.45 1.45 0 0 0 1.41-1.41L20.16 5h.34a.5.5 0 0 0 0-1zM9.77 2.26a.38.38 0 0 1 .36-.26h4.74a.38.38 0 0 1 .36.26L15.81 4H9.19zm8.44 20.27a.45.45 0 0 1-.41.47H7.2a.45.45 0 0 1-.41-.47L5.84 5h13.32z" />
|
||||||
|
<path
|
||||||
|
class="trash"
|
||||||
|
d="M9.5 10a.5.5 0 0 0-.5.5v7a.5.5 0 0 0 1 0v-7a.5.5 0 0 0-.5-.5zM12.5 9a.5.5 0 0 0-.5.5v9a.5.5 0 0 0 1 0v-9a.5.5 0 0 0-.5-.5zM15.5 10a.5.5 0 0 0-.5.5v7a.5.5 0 0 0 1 0v-7a.5.5 0 0 0-.5-.5z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-variable-row" v-for="(entry, index) in variable.values" :key="index">
|
<div class="form-variable-row" v-for="(entry, index) in variable.values" :key="index">
|
||||||
<FormInputText
|
<FormInputText
|
||||||
required
|
|
||||||
:id="variableName + '[' + index + ']' + '.value'"
|
:id="variableName + '[' + index + ']' + '.value'"
|
||||||
v-model:value="entry.value.value"
|
v-model:value="entry.value.value"
|
||||||
:label="index === 0 ? entry.value.label : ''" />
|
:label="index === 0 ? entry.value.label : ''" />
|
||||||
<div class="form-row-secondary">
|
<FormInputDropdownSelect
|
||||||
<label v-if="index === 0" :for="variableName + index + '.platform'">{{
|
required
|
||||||
entry.platform.label
|
:label="index === 0 ? entry.platform.label : ''"
|
||||||
}}</label>
|
:options="platformOptions"
|
||||||
<DropdownSelect
|
v-model="entry.platform.value"
|
||||||
:options="platformOptions"
|
:id="variableName + index + '.platform'" />
|
||||||
v-model="entry.platform.value"
|
<FormInputDropdownSelect
|
||||||
:id="variableName + index + '.platform'" />
|
required
|
||||||
</div>
|
strict
|
||||||
<div class="form-row-secondary">
|
:label="index === 0 ? entry.audience.label : ''"
|
||||||
<label v-if="index === 0" :for="variableName + index + '.audience'">{{
|
:options="audienceOptions"
|
||||||
entry.audience.label
|
v-model="entry.audience.value"
|
||||||
}}</label>
|
:id="variableName + index + '.audience'" />
|
||||||
<DropdownSelect
|
<button
|
||||||
:options="audienceOptions"
|
type="button"
|
||||||
v-model="entry.audience.value"
|
class="delete-button with-error-message"
|
||||||
:id="variableName + index + '.audience'" />
|
:class="['delete-button', { 'margin-top': index === 0 }]"
|
||||||
</div>
|
@click="handleDeleteVariableConfig(variableName, index)">
|
||||||
<button type="button" @click="handleDeleteVariableConfig(variableName, index)">
|
-
|
||||||
Delete
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" @click="handleAddVariableConfig(variableName)">Add</button>
|
<button type="button" class="add-button" @click="handleAddVariableConfig(variableName)">
|
||||||
|
+
|
||||||
|
</button>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
<!-- Render all other sections dynamically -->
|
<!-- Render all other sections dynamically -->
|
||||||
@ -550,20 +608,18 @@ export default {
|
|||||||
<template v-else-if="key === 'garbageCollect'">
|
<template v-else-if="key === 'garbageCollect'">
|
||||||
<span>Garbage Collection Settings</span>
|
<span>Garbage Collection Settings</span>
|
||||||
<template
|
<template
|
||||||
v-for="(garbageCollectSetting, key) in shamanSetting"
|
v-for="(garbageCollectSetting, garbageCollectKey) in shamanSetting"
|
||||||
:key="'garbageCollect' + key">
|
:key="'garbageCollect' + garbageCollectKey">
|
||||||
<div
|
<template v-if="garbageCollectSetting.type === inputTypes.timeDuration">
|
||||||
class="form-row"
|
<FormInputDropdownSelect
|
||||||
v-if="garbageCollectSetting.type === inputTypes.timeDuration">
|
strict
|
||||||
<label :for="'shaman.garbageCollect.' + key">{{
|
:required="config.shaman.garbageCollect[garbageCollectKey].required"
|
||||||
garbageCollectSetting.label
|
:label="garbageCollectSetting.label"
|
||||||
}}</label>
|
|
||||||
<DropdownSelect
|
|
||||||
:disabled="!config.shaman.enabled.value"
|
:disabled="!config.shaman.enabled.value"
|
||||||
:options="timeDurationOptions"
|
:options="timeDurationOptions"
|
||||||
v-model="garbageCollectSetting.value"
|
v-model="garbageCollectSetting.value"
|
||||||
:id="'shaman.garbageCollect.' + key" />
|
:id="'shaman.garbageCollect.' + garbageCollectKey" />
|
||||||
</div>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
@ -587,6 +643,7 @@ export default {
|
|||||||
:key="clientKey">
|
:key="clientKey">
|
||||||
<template v-if="clientSetting.type === inputTypes.string">
|
<template v-if="clientSetting.type === inputTypes.string">
|
||||||
<FormInputText
|
<FormInputText
|
||||||
|
:required="config.mqtt.client[clientKey].required"
|
||||||
:disabled="!config.mqtt.enabled.value"
|
:disabled="!config.mqtt.enabled.value"
|
||||||
:id="'mqtt.client.' + clientKey"
|
:id="'mqtt.client.' + clientKey"
|
||||||
v-model:value="clientSetting.value"
|
v-model:value="clientSetting.value"
|
||||||
@ -598,6 +655,7 @@ export default {
|
|||||||
<!-- Render all other input types dynamically -->
|
<!-- Render all other input types dynamically -->
|
||||||
<template v-else-if="config[key].type === inputTypes.string">
|
<template v-else-if="config[key].type === inputTypes.string">
|
||||||
<FormInputText
|
<FormInputText
|
||||||
|
:required="config[key].required"
|
||||||
:id="key"
|
:id="key"
|
||||||
v-model:value="config[key].value"
|
v-model:value="config[key].value"
|
||||||
:label="config[key].label" />
|
:label="config[key].label" />
|
||||||
@ -610,20 +668,20 @@ export default {
|
|||||||
</template>
|
</template>
|
||||||
<template v-if="config[key].type === inputTypes.number">
|
<template v-if="config[key].type === inputTypes.number">
|
||||||
<FormInputNumber
|
<FormInputNumber
|
||||||
|
:required="config[key].required"
|
||||||
:label="config[key].label"
|
:label="config[key].label"
|
||||||
:min="0"
|
:min="0"
|
||||||
v-model:value="config[key].value"
|
v-model:value="config[key].value"
|
||||||
:id="key" />
|
:id="key" />
|
||||||
</template>
|
</template>
|
||||||
<div v-else-if="config[key].type === inputTypes.timeDuration" class="form-row">
|
<template v-else-if="config[key].type === inputTypes.timeDuration">
|
||||||
<label :for="key">
|
<FormInputDropdownSelect
|
||||||
{{ config[key].label }}
|
:required="config[key].required"
|
||||||
</label>
|
:label="config[key].label"
|
||||||
<DropdownSelect
|
|
||||||
:options="timeDurationOptions"
|
:options="timeDurationOptions"
|
||||||
v-model="config[key].value"
|
v-model="config[key].value"
|
||||||
:id="key" />
|
:id="key" />
|
||||||
</div>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
@ -645,8 +703,9 @@ export default {
|
|||||||
.yaml-view-container {
|
.yaml-view-container {
|
||||||
--nav-height: 35px;
|
--nav-height: 35px;
|
||||||
--button-height: 35px;
|
--button-height: 35px;
|
||||||
|
--delete-button-width: 35px;
|
||||||
|
|
||||||
--min-form-area-width: 525px;
|
--min-form-area-width: 600px;
|
||||||
--max-form-area-width: 1fr;
|
--max-form-area-width: 1fr;
|
||||||
--min-side-area-width: 250px;
|
--min-side-area-width: 250px;
|
||||||
--max-side-area-width: 425px;
|
--max-side-area-width: 425px;
|
||||||
@ -659,6 +718,22 @@ export default {
|
|||||||
--column-item-spacer: 25px;
|
--column-item-spacer: 25px;
|
||||||
--section-spacer: 25px;
|
--section-spacer: 25px;
|
||||||
--container-padding: 25px;
|
--container-padding: 25px;
|
||||||
|
--text-spacer: 8px;
|
||||||
|
|
||||||
|
grid-column-start: col-1;
|
||||||
|
grid-column-end: col-3;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-gap: var(--grid-gap);
|
||||||
|
grid-template-areas:
|
||||||
|
'header header'
|
||||||
|
'side main'
|
||||||
|
'footer footer';
|
||||||
|
grid-template-columns: minmax(var(--min-side-area-width), var(--max-side-area-width)) minmax(
|
||||||
|
var(--min-form-area-width),
|
||||||
|
var(--max-form-area-width)
|
||||||
|
);
|
||||||
|
grid-template-rows: var(--nav-height) 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hidden {
|
.hidden {
|
||||||
@ -689,27 +764,76 @@ export default {
|
|||||||
#variables:target {
|
#variables:target {
|
||||||
color: var(--color-accent-text);
|
color: var(--color-accent-text);
|
||||||
}
|
}
|
||||||
.save-button {
|
|
||||||
|
.error {
|
||||||
|
color: var(--color-status-failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.delete-button {
|
||||||
|
border: var(--color-danger) 1px solid;
|
||||||
|
color: var(--color-danger);
|
||||||
|
background-color: var(--color-background-column);
|
||||||
|
width: var(--delete-button-width);
|
||||||
|
height: var(--delete-button-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.delete-button .trash {
|
||||||
|
fill: var(--color-danger);
|
||||||
|
width: 25px;
|
||||||
|
height: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.delete-button.margin-top {
|
||||||
|
/* This is calculated by subtracting the button height from the form row height,
|
||||||
|
aligning it properly with the inputs */
|
||||||
|
margin-top: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.add-button {
|
||||||
|
border: var(--color-success) 1px solid;
|
||||||
|
color: var(--color-success);
|
||||||
|
background-color: var(--color-background-column);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.delete-button:hover,
|
||||||
|
button.delete-button:hover .trash,
|
||||||
|
button.add-button:hover {
|
||||||
|
fill: var(--color-accent);
|
||||||
|
color: var(--color-accent);
|
||||||
|
border: 1px solid var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.margin-left-auto {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.margin-top-auto {
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.action-button {
|
||||||
background-color: var(--color-accent-background);
|
background-color: var(--color-accent-background);
|
||||||
color: var(--color-accent-text);
|
color: var(--color-accent-text);
|
||||||
padding: 5px 64px;
|
padding: 5px 64px;
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
margin-left: auto;
|
|
||||||
border: var(--border-width) solid var(--color-accent);
|
border: var(--border-width) solid var(--color-accent);
|
||||||
}
|
}
|
||||||
.save-button:hover {
|
button.action-button:hover {
|
||||||
background-color: var(--color-accent);
|
background-color: var(--color-accent);
|
||||||
}
|
}
|
||||||
.save-button:active {
|
button.action-button:active {
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
background-color: var(--color-accent-background);
|
background-color: var(--color-accent-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: var(--color-text-hint);
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
white-space: pre-line;
|
white-space: pre-line;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
.text-color-hint {
|
||||||
|
color: var(--color-text-hint);
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
@ -729,22 +853,7 @@ button {
|
|||||||
background-color: var(--color-background-column);
|
background-color: var(--color-background-column);
|
||||||
padding: 2px 10px;
|
padding: 2px 10px;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
border-radius: var(--border-radius);
|
||||||
.yaml-view-container {
|
|
||||||
grid-column-start: col-1;
|
|
||||||
grid-column-end: col-3;
|
|
||||||
|
|
||||||
display: grid;
|
|
||||||
grid-gap: var(--grid-gap);
|
|
||||||
grid-template-areas:
|
|
||||||
'header header'
|
|
||||||
'side main'
|
|
||||||
'footer footer';
|
|
||||||
grid-template-columns: minmax(var(--min-side-area-width), var(--max-side-area-width)) minmax(
|
|
||||||
var(--min-form-area-width),
|
|
||||||
var(--max-form-area-width)
|
|
||||||
);
|
|
||||||
grid-template-rows: var(--nav-height) 1fr;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-container {
|
.side-container {
|
||||||
@ -758,8 +867,19 @@ button {
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
top: 70px;
|
top: 70px;
|
||||||
padding: var(--side-padding);
|
padding: var(--side-padding);
|
||||||
flex: 1;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
.flex-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.gap-text-spacer {
|
||||||
|
gap: var(--text-spacer);
|
||||||
|
}
|
||||||
|
.gap-col-spacer {
|
||||||
|
gap: var(--column-item-spacer);
|
||||||
|
}
|
||||||
|
|
||||||
.form-container {
|
.form-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -790,11 +910,16 @@ h3 {
|
|||||||
margin-bottom: 50px;
|
margin-bottom: 50px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-row {
|
.form-col {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: var(--text-spacer);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -807,14 +932,25 @@ h3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.form-variable-row {
|
.form-variable-row {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: row;
|
grid-template-columns: 1fr minmax(0, max-content) minmax(0, max-content) var(
|
||||||
align-items: end;
|
--delete-button-width
|
||||||
|
);
|
||||||
|
grid-template-areas: 'value platform audience button';
|
||||||
|
align-items: start;
|
||||||
|
justify-items: center;
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
gap: var(--row-item-spacer);
|
column-gap: var(--row-item-spacer);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-variable-col {
|
||||||
|
display: flex;
|
||||||
|
align-items: start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--text-spacer);
|
||||||
|
}
|
||||||
|
|
||||||
.form-variable-header {
|
.form-variable-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@ -828,13 +964,6 @@ h3 {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-row-secondary {
|
|
||||||
display: flex;
|
|
||||||
align-items: start;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
input {
|
||||||
height: var(--input-height);
|
height: var(--input-height);
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user