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:
Vivian Leung 2025-09-01 14:24:56 +02:00 committed by Sybren A. Stüvel
parent 9603f3b711
commit 7ddce9ac22
6 changed files with 403 additions and 112 deletions

View File

@ -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="''"
:selected="
!(modelValue in options) &&
(modelValue === '' || modelValue === null || modelValue === undefined)
">
{{ '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" :value="modelValue"
@change="$emit('update:modelValue', $event.target.value)" :selected="!(modelValue in options) && !strict">
:disabled="disabled">
<!-- This option only shows if its current value does not match any of the preset options. -->
<option v-if="!(modelValue in options)" :value="modelValue" selected>
{{ modelValue }} {{ modelValue }}
</option> </option>
<template :key="o" v-for="o in Object.keys(options)"> <template :key="o" v-for="o in Object.keys(options)">

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

View File

@ -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 = '';
} }

View File

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

View File

@ -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 = '';
} }

View File

@ -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,26 +472,32 @@ 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">
<div class="flex-col">
<h2>Settings</h2> <h2>Settings</h2>
<p> <p class="text-color-hint">
This editor allows you to configure the settings for the Flamenco Server. These changes This editor allows you to configure the settings for the Flamenco Server. These
will directly edit the changes will directly edit the
<span class="file-name"> flamenco-manager.yaml </span> <span class="file-name"> flamenco-manager.yaml </span>
file. For more information, see file. For more information, see
<a class="link" href="https://flamenco.blender.org/usage/manager-configuration/"> <a class="link" href="https://flamenco.blender.org/usage/manager-configuration/">
Manager Configuration Manager Configuration</a
</a> >
</p> </p>
</div> </div>
<!-- TODO: add attribute descriptions when onFocus --> <!-- TODO: add attribute descriptions when onFocus -->
</div> </div>
</div>
</aside> </aside>
<form id="config-form" class="form-container" @submit.prevent="saveConfig"> <form id="config-form" class="form-container" @submit.prevent="saveConfig">
<h1 id="flamenco-manager-setup">Flamenco Manager Setup</h1> <h1 id="flamenco-manager-setup">Flamenco Manager Setup</h1>
@ -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>
<DropdownSelect
:options="platformOptions" :options="platformOptions"
v-model="entry.platform.value" v-model="entry.platform.value"
:id="variableName + index + '.platform'" /> :id="variableName + index + '.platform'" />
</div> <FormInputDropdownSelect
<div class="form-row-secondary"> required
<label v-if="index === 0" :for="variableName + index + '.audience'">{{ strict
entry.audience.label :label="index === 0 ? entry.audience.label : ''"
}}</label>
<DropdownSelect
:options="audienceOptions" :options="audienceOptions"
v-model="entry.audience.value" v-model="entry.audience.value"
:id="variableName + index + '.audience'" /> :id="variableName + index + '.audience'" />
</div> <button
<button type="button" @click="handleDeleteVariableConfig(variableName, index)"> type="button"
Delete class="delete-button with-error-message"
:class="['delete-button', { 'margin-top': index === 0 }]"
@click="handleDeleteVariableConfig(variableName, index)">
-
</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);
} }