1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-09 15:55:23 +02:00

feat(UX): introduce new env variables UI (#4175)

* feat(app): introduce new env vars ui

feat(app): introduce new env vars ui

feat(UX): WIP new env variables UI

feat(UX): update button and placeholder

feat(UX): mention .env file in message

feat(UX): allow add/remove value & load correctly

feat(UX): restrict filesize to 1MB

feat(UX): vertical align error message

feat(UX): fill UI from file & when switching modes

feat(UX): strip un-needed newline character

feat(UX): introduce component to other views

feat(UX): fix title alignment

feat(UX): only populate editor on mode switch when key exists

feat(UX): prevent trimming of whitespace on values

feat(UX): change editor to async

feat(UX): add message describing use

feat(UX): Refactor variable text to editorText

refactor(app): rename env vars controller

refactor(app): move env var explanation to parent

refactor(app): order env var panels

refactor(app): move simple env vars mode to component

refactor(app): parse env vars

refactor(app): move styles to css

refactor(app): rename functions

refactor(container): parse env vars

refactor(env-vars): move utils to helper module

refactor(env-vars): use util function for parse dot env file

fix(env-vars): ignore comments

refactor(services): use env vars utils

refactor(env-vars): rename files

refactor(env-panel): use utils

style(stack): revert EnvContent to Env

style(service): revert EnvContent to Env

style(container): revert EnvContent to Env

refactor(env-vars): support default value

refactor(service): use new env var component

refactor(env-var): use one way data flow

refactor(containers): remove unused function

* fix(env-vars): prevent using non .env files

* refactor(env-vars): move env vars items to a component

* feat(app): fixed env vars form validation in Stack

* feat(services): disable env form submit if invalid

* fix(app): show key pairs correctly

* fix(env-var): use the same validation as with kubernetes

* fix(env-vars): parse env var

Co-authored-by: Chaim Lev-Ari <chiptus@gmail.com>
Co-authored-by: Felix Han <felix.han@portainer.io>
This commit is contained in:
itsconquest 2021-06-14 18:59:07 +12:00 committed by GitHub
parent 6e9f472723
commit a5e8cf62d2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 506 additions and 265 deletions

View file

@ -0,0 +1,34 @@
import { parseDotEnvFile, convertToArrayOfStrings } from '@/portainer/helpers/env-vars';
export default class EnvironmentVariablesPanelController {
/* @ngInject */
constructor() {
this.mode = 'simple';
this.editorText = '';
this.switchEnvMode = this.switchEnvMode.bind(this);
this.editorUpdate = this.editorUpdate.bind(this);
this.handleSimpleChange = this.handleSimpleChange.bind(this);
}
switchEnvMode() {
if (this.mode === 'simple') {
const editorText = convertToArrayOfStrings(this.ngModel).join('\n');
this.editorText = editorText;
this.mode = 'advanced';
} else {
this.mode = 'simple';
}
}
handleSimpleChange(value) {
this.onChange(value);
}
editorUpdate(cm) {
this.editorText = cm.getValue();
this.onChange(parseDotEnvFile(this.editorText));
}
}

View file

@ -0,0 +1,11 @@
.environment-variables-panel {
margin-top: 15px;
}
.environment-variables-panel--explanation {
margin-bottom: 5px;
}
.environment-variables-panel--advanced > * + * {
margin-top: 5px;
}

View file

@ -0,0 +1,30 @@
<ng-form class="form-horizontal environment-variables-panel" name="$ctrl.envVarsForm">
<div class="form-group">
<div class="col-sm-12 form-section-title" style="margin-top: 10px; margin-left: 15px; width: 98%;">
Environment variables
</div>
<div class="col-sm-12 environment-variables-panel--explanation">
{{::$ctrl.explanation}}
</div>
<environment-variables-simple-mode
ng-if="$ctrl.mode == 'simple'"
ng-model="$ctrl.ngModel"
on-change="($ctrl.handleSimpleChange)"
on-switch-mode-click="($ctrl.switchEnvMode)"
></environment-variables-simple-mode>
<div ng-if="$ctrl.mode == 'advanced'" class="environment-variables-panel--advanced">
<div class="col-sm-12">
<a class="small interactive" ng-click="$ctrl.switchEnvMode()"> <i class="fa fa-list-ol space-right" aria-hidden="true"></i> Simple mode </a>
</div>
<div class="col-sm-12 small text-muted">
<i class="fa fa-info-circle blue-icon space-right" aria-hidden="true"></i>
Switch to simple mode to define variables line by line, or load from .env file
</div>
<div class="form-group" style="margin-left: 1px;">
<code-editor identifier="environment-variables-editor" placeholder="e.g. key=value" value="$ctrl.editorText" yml="false" on-change="($ctrl.editorUpdate)"></code-editor>
</div>
</div>
</div>
</ng-form>

View file

@ -0,0 +1,41 @@
import { KEY_REGEX, VALUE_REGEX } from '@/portainer/helpers/env-vars';
class EnvironmentVariablesSimpleModeItemController {
/* @ngInject */
constructor() {
this.KEY_REGEX = KEY_REGEX;
this.VALUE_REGEX = VALUE_REGEX;
}
onChangeName(name) {
const fieldIsInvalid = typeof name === 'undefined';
if (fieldIsInvalid) {
return;
}
this.onChange(this.index, { ...this.variable, name });
}
onChangeValue(value) {
const fieldIsInvalid = typeof value === 'undefined';
if (fieldIsInvalid) {
return;
}
this.onChange(this.index, { ...this.variable, value });
}
hasValue() {
return typeof this.variable.value !== 'undefined';
}
removeValue() {
this.onChange(this.index, { name: this.variable.name });
}
$onInit() {
this.formName = `variableForm${this.index}`;
}
}
export default EnvironmentVariablesSimpleModeItemController;

View file

@ -0,0 +1,63 @@
<ng-form class="env-item" name="$ctrl.{{ $ctrl.formName }}">
<div class="col-sm-5">
<div class="input-group input-group-sm env-item-key w-full">
<span class="input-group-addon">name</span>
<input
type="text"
name="name"
class="form-control"
placeholder="e.g. FOO"
ng-model="$ctrl.variable.name"
ng-disabled="$ctrl.variable.added"
ng-pattern="$ctrl.KEY_REGEX"
ng-change="$ctrl.onChangeName($ctrl.variable.name)"
required
/>
</div>
<div class="form-group" style="margin-top: 5px;" ng-show="$ctrl[$ctrl.formName].name.$invalid">
<div class="col-sm-12 small text-warning">
<div ng-messages="$ctrl[$ctrl.formName].name.$error">
<p ng-message="required"> <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Name is required. </p>
<p ng-message="pattern">
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
This field must consist alphanumeric characters, '-' or '_', start with an alphabetic character, and end with an alphanumeric character (e.g. 'my-var', or 'MY_VAR123').
</p>
</div>
</div>
</div>
</div>
<div ng-if="$ctrl.hasValue()" class="col-sm-6">
<div class="w-full env-item-value">
<div class="input-group input-group-sm">
<span class="input-group-addon">value</span>
<input
type="text"
class="form-control"
ng-model="$ctrl.variable.value"
placeholder="e.g. bar"
ng-trim="false"
ng-pattern="$ctrl.VALUE_REGEX"
name="value"
ng-change="$ctrl.onChangeValue($ctrl.variable.value)"
/>
</div>
<button class="btn btn-sm btn-primary" type="button" ng-click="$ctrl.removeValue()"> <i class="fa fa-minus" aria-hidden="true"></i> Remove value </button>
</div>
<div class="form-group" ng-show="$ctrl[$ctrl.formName].value.$invalid">
<div class="col-sm-12 small text-warning">
<div ng-messages="$ctrl[$ctrl.formName].value.$error">
<p ng-message="required"> <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Value is required. </p>
<p ng-message="pattern"> <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Value is invalid. </p>
</div>
</div>
</div>
</div>
<div>
<button class="btn btn-sm btn-primary" type="button" ng-if="!$ctrl.hasValue()" ng-click="$ctrl.onChangeValue('')">
<i class="fa fa-plus" aria-hidden="true"></i> Add value
</button>
<button class="btn btn-sm btn-danger" type="button" ng-click="$ctrl.onRemove($ctrl.index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</ng-form>

View file

@ -0,0 +1,17 @@
import angular from 'angular';
import controller from './environment-variables-simple-mode-item.controller.js';
export const environmentVariablesSimpleModeItem = {
templateUrl: './environment-variables-simple-mode-item.html',
controller,
bindings: {
variable: '<',
index: '<',
onChange: '<',
onRemove: '<',
},
};
angular.module('portainer.app').component('environmentVariablesSimpleModeItem', environmentVariablesSimpleModeItem);

View file

@ -0,0 +1,43 @@
import { parseDotEnvFile } from '@/portainer/helpers/env-vars';
export default class EnvironmentVariablesSimpleModeController {
/* @ngInject */
constructor($async) {
this.$async = $async;
this.onChangeVariable = this.onChangeVariable.bind(this);
this.remove = this.remove.bind(this);
}
add() {
this.onChange([...this.ngModel, { name: '', value: '' }]);
}
remove(index) {
this.onChange(this.ngModel.filter((_, i) => i !== index));
}
addFromFile(file) {
return this.$async(async () => {
if (!file) {
return;
}
const text = await this.getTextFromFile(file);
const parsed = parseDotEnvFile(text);
this.onChange(this.ngModel.concat(parsed));
});
}
getTextFromFile(file) {
return new Promise((resolve, reject) => {
const temporaryFileReader = new FileReader();
temporaryFileReader.readAsText(file);
temporaryFileReader.onload = (event) => resolve(event.target.result);
temporaryFileReader.onerror = (error) => reject(error);
});
}
onChangeVariable(index, variable) {
this.onChange(this.ngModel.map((v, i) => (i !== index ? v : variable)));
}
}

View file

@ -0,0 +1,33 @@
.advanced-actions > * + * {
margin-top: 5px;
}
.environment-variables-simple-mode--actions {
display: flex;
align-items: center;
margin-left: 10px;
margin-top: 10px;
}
.env-items-list {
margin-top: 10px;
}
.env-items-list > * + * {
margin-top: 2px;
}
.env-items-list .env-item {
display: flex;
}
.env-item .env-item-key {
}
.env-item .env-item-value {
display: flex;
}
.env-item .env-item-value .input-group {
flex: 1;
}

View file

@ -0,0 +1,36 @@
<div class="environment-variables-simple-mode">
<div class="col-sm-12">
<a class="small interactive" ng-click="$ctrl.onSwitchModeClick()"> <i class="fa fa-list-ol space-right" aria-hidden="true"></i> Advanced mode </a>
</div>
<div class="col-sm-12 small text-muted">
<i class="fa fa-info-circle blue-icon space-right" aria-hidden="true"></i>
Switch to advanced mode to copy & paste multiple variables
</div>
<div class="col-sm-12 environment-variables-simple-mode--actions">
<button type="button" class="btn btn-sm btn-default" ng-click="$ctrl.add()"> <i class="fa fa-plus-circle" aria-hidden="true"></i> Add an environment variable </button>
<button
type="button"
class="btn btn-sm btn-default"
ngf-select="$ctrl.addFromFile($file)"
ngf-accept="'.env'"
ngf-pattern="'.env'"
ngf-max-size="1MB"
ngf-model-invalid="errorFile"
>
<i class="fa fa-file-upload" aria-hidden="true"></i> Load variables from .env file
</button>
<span class="space-left" ng-if="errorFile.$error == 'maxSize'">
<i class="fa fa-times red-icon space-right" aria-hidden="true"></i>
File too large! Try uploading a file smaller than 1MB
</span>
</div>
<div class="col-sm-12 form-inline env-items-list">
<environment-variables-simple-mode-item
ng-repeat="variable in $ctrl.ngModel"
variable="variable"
index="$index"
on-change="($ctrl.onChangeVariable)"
on-remove="($ctrl.remove)"
></environment-variables-simple-mode-item>
</div>
</div>

View file

@ -0,0 +1,15 @@
import angular from 'angular';
import './environment-variables-simple-mode.css';
import controller from './environment-variables-simple-mode.controller';
angular.module('portainer.app').component('environmentVariablesSimpleMode', {
templateUrl: './environment-variables-simple-mode.html',
controller,
bindings: {
ngModel: '<',
onSwitchModeClick: '<',
onChange: '<',
},
});

View file

@ -0,0 +1,15 @@
import angular from 'angular';
import './environment-variables-panel.css';
import controller from './environment-variables-panel.controller.js';
angular.module('portainer.app').component('environmentVariablesPanel', {
templateUrl: './environment-variables-panel.html',
controller,
bindings: {
ngModel: '<',
explanation: '@',
onChange: '<',
},
});

View file

@ -79,12 +79,20 @@ angular
.filter('key', function () {
'use strict';
return function (pair, separator) {
if (!pair.includes(separator)) {
return pair;
}
return pair.slice(0, pair.indexOf(separator));
};
})
.filter('value', function () {
'use strict';
return function (pair, separator) {
if (!pair.includes(separator)) {
return '';
}
return pair.slice(pair.indexOf(separator) + 1);
};
})

View file

@ -0,0 +1,61 @@
import _ from 'lodash-es';
export const KEY_REGEX = /[a-zA-Z]([-_a-zA-Z0-9]*[a-zA-Z0-9])?/.source;
export const VALUE_REGEX = /(.*)?/.source;
const KEY_VALUE_REGEX = new RegExp(`^(${KEY_REGEX})\\s*=(${VALUE_REGEX})$`);
const NEWLINES_REGEX = /\n|\r|\r\n/;
/**
* @param {string} src the source of the .env file
*
* @returns {[{name: string, value: string}]} array of {name, value}
*/
export function parseDotEnvFile(src) {
return parseArrayOfStrings(
_.compact(src.split(NEWLINES_REGEX))
.map((v) => v.trim())
.filter((v) => !v.startsWith('#'))
);
}
/**
* parses an array of name=value to array of {name, value}
*
* @param {[string]} array array of strings in format name=value
*
* @returns {[{name: string, value: string}]} array of {name, value}
*/
export function parseArrayOfStrings(array) {
if (!array) {
return [];
}
return _.compact(
array.map((variableString) => {
if (!variableString.includes('=')) {
return { name: variableString };
}
const parsedKeyValArr = variableString.trim().match(KEY_VALUE_REGEX);
if (parsedKeyValArr != null && parsedKeyValArr.length > 4) {
return { name: parsedKeyValArr[1], value: parsedKeyValArr[3] || '' };
}
})
);
}
/**
* converts an array of {name, value} to array of `name=value`, name is always defined
*
* @param {[{name, value}]} array array of {name, value}
*
* @returns {[string]} array of `name=value`
*/
export function convertToArrayOfStrings(array) {
if (!array) {
return [];
}
return array.filter((variable) => variable.name).map(({ name, value }) => (value || value === '' ? `${name}=${value}` : name));
}

View file

@ -53,14 +53,6 @@ angular
}
};
$scope.addEnvironmentVariable = function () {
$scope.formValues.Env.push({ name: '', value: '' });
};
$scope.removeEnvironmentVariable = function (index) {
$scope.formValues.Env.splice(index, 1);
};
function validateForm(accessControlData, isAdmin) {
$scope.state.formValidationError = '';
var error = '';
@ -123,6 +115,11 @@ angular
}
}
$scope.handleEnvVarChange = handleEnvVarChange;
function handleEnvVarChange(value) {
$scope.formValues.Env = value;
}
$scope.deployStack = function () {
var name = $scope.formValues.Name;
var method = $scope.state.Method;

View file

@ -7,7 +7,7 @@
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<form class="form-horizontal">
<form class="form-horizontal" name="createStackForm">
<!-- name-input -->
<div class="form-group">
<label for="stack_name" class="col-sm-1 control-label text-left">Name</label>
@ -261,36 +261,8 @@
</div>
<!-- !custom-template -->
<!-- environment-variables -->
<div>
<div class="col-sm-12 form-section-title">
Environment
</div>
<div class="form-group">
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">Environment variables</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addEnvironmentVariable()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add environment variable
</span>
</div>
<!-- environment-variable-input-list -->
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="variable in formValues.Env" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">name</span>
<input type="text" class="form-control" ng-model="variable.name" placeholder="e.g. FOO" />
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="variable.value" placeholder="e.g. bar" />
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removeEnvironmentVariable($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- !environment-variable-input-list -->
</div>
</div>
<environment-variables-panel ng-model="formValues.Env" explanation="These values will be used as substitutions in the stack file" on-change="(handleEnvVarChange)">
</environment-variables-panel>
<!-- !environment-variables -->
<por-access-control-form form-data="formValues.AccessControlData"></por-access-control-form>
<!-- actions -->
@ -303,6 +275,7 @@
type="button"
class="btn btn-primary btn-sm"
ng-disabled="state.actionInProgress
|| !createStackForm.$valid
|| (state.Method === 'editor' && (!formValues.StackFileContent || state.editorYamlValidationError))
|| (state.Method === 'upload' && (!formValues.StackFile || state.uploadYamlValidationError))
|| (state.Method === 'template' && (!formValues.StackFileContent || !selectedTemplate || state.editorYamlValidationError))

View file

@ -126,7 +126,7 @@
<!-- tab-file -->
<uib-tab index="1" select="showEditor()" ng-if="!external">
<uib-tab-heading> <i class="fa fa-pencil-alt space-right" aria-hidden="true"></i> Editor </uib-tab-heading>
<form class="form-horizontal" ng-if="state.showEditorTab" style="margin-top: 10px;">
<form class="form-horizontal" ng-if="state.showEditorTab" style="margin-top: 10px;" name="stackUpdateForm">
<div class="form-group">
<span class="col-sm-12 text-muted small" style="margin-bottom: 7px;" ng-if="stackType == 2 && composeSyntaxMaxVersion == 2">
This stack will be deployed using the equivalent of <code>docker-compose</code>. Only Compose file format version <b>2</b> is supported at the moment.
@ -152,34 +152,11 @@
</div>
<!-- environment-variables -->
<div ng-if="stack && stack.Type === 1">
<div class="col-sm-12 form-section-title">
Environment
</div>
<div class="form-group">
<div class="col-sm-12" style="margin-top: 5px;" authorization="PortainerStackUpdate">
<label class="control-label text-left">Environment variables</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addEnvironmentVariable()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add environment variable
</span>
</div>
<!-- environment-variable-input-list -->
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="variable in stack.Env" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">name</span>
<input type="text" class="form-control" ng-model="variable.name" placeholder="e.g. FOO" />
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="variable.value" placeholder="e.g. bar" />
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removeEnvironmentVariable($index)" authorization="PortainerStackUpdate">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- !environment-variable-input-list -->
</div>
<environment-variables-panel
ng-model="formValues.Env"
explanation="These values will be used as substitutions in the stack file"
on-change="(handleEnvVarChange)"
></environment-variables-panel>
</div>
<!-- !environment-variables -->
<!-- options -->
@ -207,7 +184,7 @@
<button
type="button"
class="btn btn-sm btn-primary"
ng-disabled="state.actionInProgress || stack.Status === 2 || !stackFileContent || orphaned"
ng-disabled="state.actionInProgress || !stackUpdateForm.$valid || stack.Status === 2 || !stackFileContent || orphaned"
ng-click="deployStack()"
button-spinner="state.actionInProgress"
>

View file

@ -61,6 +61,7 @@ angular.module('portainer.app').controller('StackController', [
Prune: false,
Endpoint: null,
AccessControlData: new AccessControlFormData(),
Env: [],
};
$window.onbeforeunload = () => {
@ -69,9 +70,14 @@ angular.module('portainer.app').controller('StackController', [
}
};
$scope.handleEnvVarChange = handleEnvVarChange;
function handleEnvVarChange(value) {
$scope.formValues.Env = value;
}
$scope.duplicateStack = function duplicateStack(name, endpointId) {
var stack = $scope.stack;
var env = FormHelper.removeInvalidEnvVars(stack.Env);
var env = FormHelper.removeInvalidEnvVars($scope.formValues.Env);
EndpointProvider.setEndpointID(endpointId);
return StackService.duplicateStack(name, $scope.stackFileContent, env, endpointId, stack.Type).then(onDuplicationSuccess).catch(notifyOnError);
@ -195,7 +201,7 @@ angular.module('portainer.app').controller('StackController', [
$scope.deployStack = function () {
var stackFile = $scope.stackFileContent;
var env = FormHelper.removeInvalidEnvVars($scope.stack.Env);
var env = FormHelper.removeInvalidEnvVars($scope.formValues.Env);
var prune = $scope.formValues.Prune;
var stack = $scope.stack;
@ -223,14 +229,6 @@ angular.module('portainer.app').controller('StackController', [
});
};
$scope.addEnvironmentVariable = function () {
$scope.stack.Env.push({ name: '', value: '' });
};
$scope.removeEnvironmentVariable = function (index) {
$scope.stack.Env.splice(index, 1);
};
$scope.editorUpdate = function (cm) {
if ($scope.stackFileContent.replace(/(\r\n|\n|\r)/gm, '') !== cm.getValue().replace(/(\r\n|\n|\r)/gm, '')) {
$scope.state.isEditorDirty = true;
@ -301,6 +299,8 @@ angular.module('portainer.app').controller('StackController', [
$scope.stack = stack;
$scope.containerNames = ContainerHelper.getContainerNames(data.containers);
$scope.formValues.Env = $scope.stack.Env;
let resourcesPromise = Promise.resolve({});
if (stack.Status === 1) {
resourcesPromise = stack.Type === 1 ? retrieveSwarmStackResources(stack.Name, agentProxy) : retrieveComposeStackResources(stack.Name);