mirror of https://github.com/portainer/portainer
refactor(ui): migrate env var field to react [EE-4853] (#8451)
parent
6b5940e00e
commit
2d05103fed
|
@ -1,9 +1,9 @@
|
||||||
import _ from 'lodash-es';
|
import _ from 'lodash-es';
|
||||||
|
|
||||||
|
import * as envVarsUtils from '@/react/components/form-components/EnvironmentVariablesFieldset/utils';
|
||||||
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
|
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
|
||||||
|
|
||||||
import { confirmDestructive } from '@@/modals/confirm';
|
import { confirmDestructive } from '@@/modals/confirm';
|
||||||
import * as envVarsUtils from '@/portainer/helpers/env-vars';
|
|
||||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||||
import { buildConfirmButton } from '@@/modals/utils';
|
import { buildConfirmButton } from '@@/modals/utils';
|
||||||
|
|
||||||
|
|
|
@ -578,12 +578,14 @@
|
||||||
<!-- !tab-labels -->
|
<!-- !tab-labels -->
|
||||||
<!-- tab-env -->
|
<!-- tab-env -->
|
||||||
<div class="tab-pane" id="env">
|
<div class="tab-pane" id="env">
|
||||||
|
<div class="form-horizontal">
|
||||||
<environment-variables-panel
|
<environment-variables-panel
|
||||||
ng-model="formValues.Env"
|
values="formValues.Env"
|
||||||
explanation="These values will be applied to the container when deployed"
|
explanation="'These values will be applied to the container when deployed'"
|
||||||
on-change="(handleEnvVarChange)"
|
on-change="(handleEnvVarChange)"
|
||||||
></environment-variables-panel>
|
></environment-variables-panel>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<!-- !tab-env -->
|
<!-- !tab-env -->
|
||||||
<!-- tab-restart-policy -->
|
<!-- tab-restart-policy -->
|
||||||
<div class="tab-pane" id="restart-policy">
|
<div class="tab-pane" id="restart-policy">
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import _ from 'lodash-es';
|
import _ from 'lodash-es';
|
||||||
|
|
||||||
|
import * as envVarsUtils from '@/react/components/form-components/EnvironmentVariablesFieldset/utils';
|
||||||
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
|
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
|
||||||
import * as envVarsUtils from '@/portainer/helpers/env-vars';
|
|
||||||
import { AccessControlFormData } from '../../../../portainer/components/accessControlForm/porAccessControlFormModel';
|
import { AccessControlFormData } from '../../../../portainer/components/accessControlForm/porAccessControlFormModel';
|
||||||
|
|
||||||
require('./includes/update-restart.html');
|
require('./includes/update-restart.html');
|
||||||
|
|
|
@ -396,12 +396,14 @@
|
||||||
<!-- !tab-network -->
|
<!-- !tab-network -->
|
||||||
<!-- tab-env -->
|
<!-- tab-env -->
|
||||||
<div class="tab-pane" id="env">
|
<div class="tab-pane" id="env">
|
||||||
|
<div class="form-horizontal">
|
||||||
<environment-variables-panel
|
<environment-variables-panel
|
||||||
ng-model="formValues.Env"
|
values="formValues.Env"
|
||||||
explanation="These values will be applied to the service when created"
|
explanation="'These values will be applied to the service when created'"
|
||||||
on-change="(handleEnvVarChange)"
|
on-change="(handleEnvVarChange)"
|
||||||
></environment-variables-panel>
|
></environment-variables-panel>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<!-- !tab-env -->
|
<!-- !tab-env -->
|
||||||
<!-- tab-labels -->
|
<!-- tab-labels -->
|
||||||
<div class="tab-pane" id="labels">
|
<div class="tab-pane" id="labels">
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
<ng-form ng-if="service.EnvironmentVariables" id="service-env-variables" name="serviceEnvForm">
|
<ng-form ng-if="service.EnvironmentVariables" id="service-env-variables" name="serviceEnvForm" class="form-horizontal">
|
||||||
<rd-widget>
|
<rd-widget>
|
||||||
<rd-widget-header icon="list" title-text="Environment variables">
|
<rd-widget-header icon="list" title-text="Environment variables">
|
||||||
<div class="nopadding" authorization="DockerServiceUpdate">
|
<div class="nopadding" authorization="DockerServiceUpdate">
|
||||||
|
@ -11,7 +11,9 @@
|
||||||
<p>There are no environment variables for this service.</p>
|
<p>There are no environment variables for this service.</p>
|
||||||
</rd-widget-body>
|
</rd-widget-body>
|
||||||
<rd-widget-body ng-if="service.EnvironmentVariables.length > 0">
|
<rd-widget-body ng-if="service.EnvironmentVariables.length > 0">
|
||||||
<environment-variables-panel is-name-disabled="true" ng-model="service.EnvironmentVariables" on-change="(onChangeEnvVars)"></environment-variables-panel>
|
<div class="form-group">
|
||||||
|
<environment-variables-fieldset values="service.EnvironmentVariables" on-change="(onChangeEnvVars)"></environment-variables-fieldset>
|
||||||
|
</div>
|
||||||
</rd-widget-body>
|
</rd-widget-body>
|
||||||
<rd-widget-footer authorization="DockerServiceUpdate">
|
<rd-widget-footer authorization="DockerServiceUpdate">
|
||||||
<div class="btn-toolbar" role="toolbar">
|
<div class="btn-toolbar" role="toolbar">
|
||||||
|
|
|
@ -19,8 +19,8 @@ require('./includes/updateconfig.html');
|
||||||
|
|
||||||
import _ from 'lodash-es';
|
import _ from 'lodash-es';
|
||||||
|
|
||||||
|
import * as envVarsUtils from '@/react/components/form-components/EnvironmentVariablesFieldset/utils';
|
||||||
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
|
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
|
||||||
import * as envVarsUtils from '@/portainer/helpers/env-vars';
|
|
||||||
import { ResourceControlType } from '@/react/portainer/access-control/types';
|
import { ResourceControlType } from '@/react/portainer/access-control/types';
|
||||||
import { confirmServiceForceUpdate } from '@/react/docker/services/common/update-service-modal';
|
import { confirmServiceForceUpdate } from '@/react/docker/services/common/update-service-modal';
|
||||||
import { confirm, confirmDelete } from '@@/modals/confirm';
|
import { confirm, confirmDelete } from '@@/modals/confirm';
|
||||||
|
@ -125,8 +125,10 @@ angular.module('portainer.docker').controller('ServiceController', [
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.addEnvironmentVariable = function addEnvironmentVariable(service) {
|
$scope.addEnvironmentVariable = function addEnvironmentVariable(service) {
|
||||||
service.EnvironmentVariables.push({ name: '', value: '' });
|
$scope.$evalAsync(() => {
|
||||||
|
service.EnvironmentVariables = service.EnvironmentVariables.concat({ name: '', value: '' });
|
||||||
updateServiceArray(service, 'EnvironmentVariables', service.EnvironmentVariables);
|
updateServiceArray(service, 'EnvironmentVariables', service.EnvironmentVariables);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.onChangeEnvVars = onChangeEnvVars;
|
$scope.onChangeEnvVars = onChangeEnvVars;
|
||||||
|
|
|
@ -1,34 +0,0 @@
|
||||||
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(value) {
|
|
||||||
this.editorText = value;
|
|
||||||
this.onChange(parseDotEnvFile(this.editorText));
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,11 +0,0 @@
|
||||||
.environment-variables-panel {
|
|
||||||
margin-top: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.environment-variables-panel--explanation {
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.environment-variables-panel--advanced > * + * {
|
|
||||||
margin-top: 5px;
|
|
||||||
}
|
|
|
@ -1,35 +0,0 @@
|
||||||
<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)"
|
|
||||||
show-help-message="$ctrl.showHelpMessage"
|
|
||||||
></environment-variables-simple-mode>
|
|
||||||
|
|
||||||
<div ng-if="$ctrl.mode == 'advanced'" class="environment-variables-panel--advanced">
|
|
||||||
<div class="col-sm-12 text-clickable">
|
|
||||||
<button type="button" class="btn btn-link btn-sm vertical-center !ml-0 p-0 hover:no-underline" ng-click="$ctrl.switchEnvMode()">
|
|
||||||
<pr-icon icon="'list'"></pr-icon> Simple mode
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-12 small text-muted">
|
|
||||||
<pr-icon icon="'alert-circle'" mode="'primary'"></pr-icon>
|
|
||||||
Switch to simple mode to define variables line by line, or load from .env file
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<code-editor identifier="environment-variables-editor" placeholder="e.g. key=value" value="$ctrl.editorText" on-change="($ctrl.editorUpdate)"></code-editor>
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-12 small text-muted" ng-if="$ctrl.showHelpMessage">
|
|
||||||
<pr-icon icon="'alert-circle'" mode="'primary'"></pr-icon>
|
|
||||||
Environment changes will not take effect until redeployment occurs manually or via webhook.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ng-form>
|
|
|
@ -1,25 +0,0 @@
|
||||||
class EnvironmentVariablesSimpleModeItemController {
|
|
||||||
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 });
|
|
||||||
}
|
|
||||||
|
|
||||||
$onInit() {
|
|
||||||
this.formName = `variableForm${this.index}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default EnvironmentVariablesSimpleModeItemController;
|
|
|
@ -1,59 +0,0 @@
|
||||||
<ng-form class="env-item mt-1" 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-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">
|
|
||||||
<div ng-messages="$ctrl[$ctrl.formName].name.$error">
|
|
||||||
<p ng-message="required"> <pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Name is required. </p>
|
|
||||||
<p ng-message="pattern">
|
|
||||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
|
|
||||||
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 class="col-sm-6">
|
|
||||||
<div class="env-item-value w-full">
|
|
||||||
<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"
|
|
||||||
name="value"
|
|
||||||
ng-change="$ctrl.onChangeValue($ctrl.variable.value)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</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="pattern">
|
|
||||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
|
|
||||||
Value is invalid.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button class="btn btn-dangerlight" type="button" ng-click="$ctrl.onRemove($ctrl.index)">
|
|
||||||
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</ng-form>
|
|
|
@ -1,17 +0,0 @@
|
||||||
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);
|
|
|
@ -1,43 +0,0 @@
|
||||||
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)));
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,33 +0,0 @@
|
||||||
.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: 10px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
|
@ -1,40 +0,0 @@
|
||||||
<div class="environment-variables-simple-mode">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<button type="button" class="btn btn-link btn-sm !ml-0 p-0 hover:no-underline" ng-click="$ctrl.onSwitchModeClick()"> <pr-icon icon="'edit'"></pr-icon> Advanced mode </button>
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-12 small text-muted">
|
|
||||||
<pr-icon icon="'alert-circle'" mode="'primary'"></pr-icon>
|
|
||||||
Switch to advanced mode to copy & paste multiple variables
|
|
||||||
</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 class="col-sm-12 environment-variables-simple-mode--actions">
|
|
||||||
<button type="button" class="btn btn-sm btn-default" ng-click="$ctrl.add()"> <pr-icon icon="'plus'"></pr-icon> 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"
|
|
||||||
>
|
|
||||||
<pr-icon icon="'upload'"></pr-icon> Load variables from .env file
|
|
||||||
</button>
|
|
||||||
<span class="space-left" ng-if="errorFile.$error == 'maxSize'">
|
|
||||||
<pr-icon icon="'x'" mode="'warning'" class-name="'space-right'"></pr-icon>
|
|
||||||
File too large! Try uploading a file smaller than 1MB
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-12 small text-muted" ng-if="$ctrl.ngModel.length > 0 && $ctrl.showHelpMessage">
|
|
||||||
<pr-icon icon="'alert-circle'" mode="'primary'"></pr-icon>
|
|
||||||
Environment changes will not take effect until redeployment occurs manually or via webhook.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
|
@ -1,16 +0,0 @@
|
||||||
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: '<',
|
|
||||||
showHelpMessage: '<',
|
|
||||||
},
|
|
||||||
});
|
|
|
@ -1,16 +0,0 @@
|
||||||
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: '<',
|
|
||||||
showHelpMessage: '<',
|
|
||||||
},
|
|
||||||
});
|
|
|
@ -62,8 +62,8 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<environment-variables-panel
|
<environment-variables-panel
|
||||||
ng-model="$ctrl.formValues.Env"
|
values="$ctrl.formValues.Env"
|
||||||
explanation="These values will be used as substitutions in the stack file. To reference the .env file in your compose file, use ‘stack.env’."
|
explanation="'These values will be used as substitutions in the stack file. To reference the .env file in your compose file, use ‘stack.env’.'"
|
||||||
on-change="($ctrl.onChangeEnvVar)"
|
on-change="($ctrl.onChangeEnvVar)"
|
||||||
show-help-message="true"
|
show-help-message="true"
|
||||||
></environment-variables-panel>
|
></environment-variables-panel>
|
||||||
|
|
|
@ -1,60 +0,0 @@
|
||||||
import _ from 'lodash-es';
|
|
||||||
|
|
||||||
export const KEY_REGEX = /(.+?)/.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('#') && v !== '')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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].trim(), value: parsedKeyValArr[3].trim() || '' };
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* 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));
|
|
||||||
}
|
|
|
@ -9,4 +9,5 @@ export const fileUploadField = r2a(FileUploadField, [
|
||||||
'required',
|
'required',
|
||||||
'accept',
|
'accept',
|
||||||
'inputId',
|
'inputId',
|
||||||
|
'color',
|
||||||
]);
|
]);
|
||||||
|
|
|
@ -5,7 +5,13 @@ import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||||
import { withReactQuery } from '@/react-tools/withReactQuery';
|
import { withReactQuery } from '@/react-tools/withReactQuery';
|
||||||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||||
import { AnnotationsBeTeaser } from '@/react/kubernetes/annotations/AnnotationsBeTeaser';
|
import { AnnotationsBeTeaser } from '@/react/kubernetes/annotations/AnnotationsBeTeaser';
|
||||||
|
import { withFormValidation } from '@/react-tools/withFormValidation';
|
||||||
|
|
||||||
|
import {
|
||||||
|
EnvironmentVariablesFieldset,
|
||||||
|
EnvironmentVariablesPanel,
|
||||||
|
envVarValidation,
|
||||||
|
} from '@@/form-components/EnvironmentVariablesFieldset';
|
||||||
import { Icon } from '@@/Icon';
|
import { Icon } from '@@/Icon';
|
||||||
import { ReactQueryDevtoolsWrapper } from '@@/ReactQueryDevtoolsWrapper';
|
import { ReactQueryDevtoolsWrapper } from '@@/ReactQueryDevtoolsWrapper';
|
||||||
import { PageHeader } from '@@/PageHeader';
|
import { PageHeader } from '@@/PageHeader';
|
||||||
|
@ -37,7 +43,7 @@ import { environmentsModule } from './environments';
|
||||||
import { envListModule } from './environments-list-view-components';
|
import { envListModule } from './environments-list-view-components';
|
||||||
import { registriesModule } from './registries';
|
import { registriesModule } from './registries';
|
||||||
|
|
||||||
export const componentsModule = angular
|
export const ngModule = angular
|
||||||
.module('portainer.app.react.components', [
|
.module('portainer.app.react.components', [
|
||||||
accessControlModule,
|
accessControlModule,
|
||||||
customTemplatesModule,
|
customTemplatesModule,
|
||||||
|
@ -197,4 +203,22 @@ export const componentsModule = angular
|
||||||
'height',
|
'height',
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
.component('annotationsBeTeaser', r2a(AnnotationsBeTeaser, [])).name;
|
.component('annotationsBeTeaser', r2a(AnnotationsBeTeaser, []));
|
||||||
|
|
||||||
|
export const componentsModule = ngModule.name;
|
||||||
|
|
||||||
|
withFormValidation(
|
||||||
|
ngModule,
|
||||||
|
EnvironmentVariablesFieldset,
|
||||||
|
'environmentVariablesFieldset',
|
||||||
|
[],
|
||||||
|
envVarValidation
|
||||||
|
);
|
||||||
|
|
||||||
|
withFormValidation(
|
||||||
|
ngModule,
|
||||||
|
EnvironmentVariablesPanel,
|
||||||
|
'environmentVariablesPanel',
|
||||||
|
['explanation', 'showHelpMessage'],
|
||||||
|
envVarValidation
|
||||||
|
);
|
||||||
|
|
|
@ -0,0 +1,25 @@
|
||||||
|
export function readFileAsArrayBuffer(file: File): Promise<ArrayBuffer | null> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.readAsArrayBuffer(file);
|
||||||
|
reader.onload = () => {
|
||||||
|
if (reader.result instanceof ArrayBuffer) {
|
||||||
|
resolve(reader.result);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = (error) => reject(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readFileAsText(file: File): Promise<string | null> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.readAsText(file);
|
||||||
|
reader.onload = () => {
|
||||||
|
if (typeof reader.result === 'string') {
|
||||||
|
resolve(reader.result);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = (error) => reject(error);
|
||||||
|
});
|
||||||
|
}
|
|
@ -153,8 +153,8 @@
|
||||||
|
|
||||||
<!-- environment-variables -->
|
<!-- environment-variables -->
|
||||||
<environment-variables-panel
|
<environment-variables-panel
|
||||||
ng-model="formValues.Env"
|
values="formValues.Env"
|
||||||
explanation="These values will be used as substitutions in the stack file. To reference the .env file in your compose file, use ‘stack.env’"
|
explanation="'These values will be used as substitutions in the stack file. To reference the .env file in your compose file, use ‘stack.env’'"
|
||||||
on-change="(handleEnvVarChange)"
|
on-change="(handleEnvVarChange)"
|
||||||
>
|
>
|
||||||
</environment-variables-panel>
|
</environment-variables-panel>
|
||||||
|
|
|
@ -181,8 +181,8 @@
|
||||||
<!-- environment-variables -->
|
<!-- environment-variables -->
|
||||||
<div ng-if="stack">
|
<div ng-if="stack">
|
||||||
<environment-variables-panel
|
<environment-variables-panel
|
||||||
ng-model="formValues.Env"
|
values="formValues.Env"
|
||||||
explanation="These values will be used as substitutions in the stack file. To reference the .env file in your compose file, use ‘stack.env’."
|
explanation="'These values will be used as substitutions in the stack file. To reference the .env file in your compose file, use ‘stack.env’.'"
|
||||||
on-change="(handleEnvVarChange)"
|
on-change="(handleEnvVarChange)"
|
||||||
show-help-message="true"
|
show-help-message="true"
|
||||||
></environment-variables-panel>
|
></environment-variables-panel>
|
||||||
|
|
|
@ -24,7 +24,7 @@ function toProps(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type PropNames<T> = Exclude<keyof T, number | symbol>;
|
export type PropNames<T> = Exclude<keyof T, number | symbol>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* react2angular is used to bind a React component to an AngularJS component
|
* react2angular is used to bind a React component to an AngularJS component
|
||||||
|
|
|
@ -0,0 +1,128 @@
|
||||||
|
import { IFormController, IComponentOptions, IModule } from 'angular';
|
||||||
|
import { FormikErrors } from 'formik';
|
||||||
|
import { SchemaOf } from 'yup';
|
||||||
|
import _ from 'lodash';
|
||||||
|
import { ComponentType } from 'react';
|
||||||
|
|
||||||
|
import { PropNames, r2a } from '@/react-tools/react2angular';
|
||||||
|
|
||||||
|
import { validateForm } from '@@/form-components/validate-form';
|
||||||
|
import { ArrayError } from '@@/form-components/InputList/InputList';
|
||||||
|
|
||||||
|
interface FormFieldProps<TValue> {
|
||||||
|
onChange(values: TValue): void;
|
||||||
|
values: TValue;
|
||||||
|
errors?: FormikErrors<TValue> | ArrayError<TValue>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type WithFormFieldProps<TProps, TValue> = TProps & FormFieldProps<TValue>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function to use for wrapping react components with form validation
|
||||||
|
* when used inside an angular form, it will set the form to invalid if the component values are invalid.
|
||||||
|
*
|
||||||
|
* this registers two angularjs components:
|
||||||
|
* 1. the react component with r2a wrapping
|
||||||
|
* 2. an angularjs component that handles form validation
|
||||||
|
*/
|
||||||
|
export function withFormValidation<TProps, TValue, TData = never>(
|
||||||
|
ngModule: IModule,
|
||||||
|
Component: ComponentType<WithFormFieldProps<TProps, TValue>>,
|
||||||
|
componentName: string,
|
||||||
|
propNames: PropNames<TProps>[],
|
||||||
|
schemaBuilder: (data?: TData) => SchemaOf<TValue>
|
||||||
|
) {
|
||||||
|
const reactComponentName = `react${_.upperFirst(componentName)}`;
|
||||||
|
|
||||||
|
ngModule
|
||||||
|
.component(
|
||||||
|
reactComponentName,
|
||||||
|
r2a(Component, ['errors', 'onChange', 'values', ...propNames])
|
||||||
|
)
|
||||||
|
.component(
|
||||||
|
componentName,
|
||||||
|
createFormValidationComponent(
|
||||||
|
reactComponentName,
|
||||||
|
propNames,
|
||||||
|
schemaBuilder
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFormValidationComponent<TFormModel, TData = never>(
|
||||||
|
componentName: string,
|
||||||
|
props: Array<string>,
|
||||||
|
schemaBuilder: (data?: TData) => SchemaOf<TFormModel>
|
||||||
|
): IComponentOptions {
|
||||||
|
const kebabName = _.kebabCase(componentName);
|
||||||
|
const propsWithErrors = [...props, 'errors', 'values'];
|
||||||
|
|
||||||
|
return {
|
||||||
|
template: `<ng-form name="$ctrl.form">
|
||||||
|
<${kebabName} ${propsWithErrors
|
||||||
|
.filter((p) => p !== 'onChange')
|
||||||
|
.map((p) => `${_.kebabCase(p)}="$ctrl.${p}"`)
|
||||||
|
.join(' ')}
|
||||||
|
on-change="($ctrl.handleChange)"
|
||||||
|
></${kebabName}>
|
||||||
|
</ng-form>`,
|
||||||
|
controller: createFormValidatorController(schemaBuilder),
|
||||||
|
bindings: Object.fromEntries(
|
||||||
|
[...propsWithErrors, 'validationData', 'onChange'].map((p) => [p, '<'])
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFormValidatorController<TFormModel, TData = never>(
|
||||||
|
schemaBuilder: (data?: TData) => SchemaOf<TFormModel>
|
||||||
|
) {
|
||||||
|
return class FormValidatorController {
|
||||||
|
errors?: FormikErrors<TFormModel> = {};
|
||||||
|
|
||||||
|
$async: <T>(fn: () => Promise<T>) => Promise<T>;
|
||||||
|
|
||||||
|
form?: IFormController;
|
||||||
|
|
||||||
|
values?: TFormModel;
|
||||||
|
|
||||||
|
validationData?: TData;
|
||||||
|
|
||||||
|
onChange?: (value: TFormModel) => void;
|
||||||
|
|
||||||
|
/* @ngInject */
|
||||||
|
constructor($async: <T>(fn: () => Promise<T>) => Promise<T>) {
|
||||||
|
this.$async = $async;
|
||||||
|
|
||||||
|
this.handleChange = this.handleChange.bind(this);
|
||||||
|
this.runValidation = this.runValidation.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleChange(newValues: TFormModel) {
|
||||||
|
return this.$async(async () => {
|
||||||
|
this.onChange?.(newValues);
|
||||||
|
await this.runValidation(newValues);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async runValidation(value: TFormModel) {
|
||||||
|
return this.$async(async () => {
|
||||||
|
this.form?.$setValidity('form', true, this.form);
|
||||||
|
|
||||||
|
this.errors = await validateForm<TFormModel>(
|
||||||
|
() => schemaBuilder(this.validationData),
|
||||||
|
value
|
||||||
|
);
|
||||||
|
|
||||||
|
if (this.errors && Object.keys(this.errors).length > 0) {
|
||||||
|
this.form?.$setValidity('form', false, this.form);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async $onChanges(changes: { values?: { currentValue: TFormModel } }) {
|
||||||
|
if (changes.values) {
|
||||||
|
await this.runValidation(changes.values.currentValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
|
@ -11,17 +11,25 @@ export interface Props {
|
||||||
color?: Color;
|
color?: Color;
|
||||||
className?: string;
|
className?: string;
|
||||||
childrenWrapperClassName?: string;
|
childrenWrapperClassName?: string;
|
||||||
|
inline?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TextTip({
|
export function TextTip({
|
||||||
color = 'orange',
|
color = 'orange',
|
||||||
icon = AlertCircle,
|
icon = AlertCircle,
|
||||||
|
inline = true,
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
childrenWrapperClassName = 'text-muted',
|
childrenWrapperClassName = 'text-muted',
|
||||||
}: PropsWithChildren<Props>) {
|
}: PropsWithChildren<Props>) {
|
||||||
return (
|
return (
|
||||||
<div className={clsx('small inline-flex gap-1', className)}>
|
<div
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'small items-center gap-1',
|
||||||
|
inline ? 'inline-flex' : 'flex'
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Icon icon={icon} mode={getMode(color)} className="!mt-[2px]" />
|
<Icon icon={icon} mode={getMode(color)} className="!mt-[2px]" />
|
||||||
|
|
||||||
<span className={childrenWrapperClassName}>{children}</span>
|
<span className={childrenWrapperClassName}>{children}</span>
|
||||||
|
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { List } from 'lucide-react';
|
||||||
|
|
||||||
|
import { CodeEditor } from '@@/CodeEditor';
|
||||||
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
import { Button } from '@@/buttons';
|
||||||
|
|
||||||
|
import { convertToArrayOfStrings, parseDotEnvFile } from './utils';
|
||||||
|
import { type Value } from './types';
|
||||||
|
|
||||||
|
export function AdvancedMode({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onSimpleModeClick,
|
||||||
|
}: {
|
||||||
|
value: Value;
|
||||||
|
onChange: (value: Value) => void;
|
||||||
|
onSimpleModeClick: () => void;
|
||||||
|
}) {
|
||||||
|
const editorValue = convertToArrayOfStrings(value).join('\n');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="link"
|
||||||
|
icon={List}
|
||||||
|
className="!ml-0 p-0 hover:no-underline"
|
||||||
|
onClick={onSimpleModeClick}
|
||||||
|
>
|
||||||
|
Simple mode
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<TextTip color="blue" inline={false}>
|
||||||
|
Switch to simple mode to define variables line by line, or load from
|
||||||
|
.env file
|
||||||
|
</TextTip>
|
||||||
|
|
||||||
|
<CodeEditor
|
||||||
|
id="environment-variables-editor"
|
||||||
|
value={editorValue}
|
||||||
|
onChange={handleEditorChange}
|
||||||
|
placeholder="e.g. key=value"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleEditorChange(value: string) {
|
||||||
|
onChange(parseDotEnvFile(value));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { array, object, SchemaOf, string } from 'yup';
|
||||||
|
|
||||||
|
import { ArrayError } from '../InputList/InputList';
|
||||||
|
|
||||||
|
import { AdvancedMode } from './AdvancedMode';
|
||||||
|
import { SimpleMode } from './SimpleMode';
|
||||||
|
import { Value } from './types';
|
||||||
|
|
||||||
|
export function EnvironmentVariablesFieldset({
|
||||||
|
onChange,
|
||||||
|
values,
|
||||||
|
errors,
|
||||||
|
}: {
|
||||||
|
values: Value;
|
||||||
|
onChange(value: Value): void;
|
||||||
|
errors?: ArrayError<Value>;
|
||||||
|
}) {
|
||||||
|
const [simpleMode, setSimpleMode] = useState(true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="col-sm-12">
|
||||||
|
{simpleMode ? (
|
||||||
|
<SimpleMode
|
||||||
|
onAdvancedModeClick={() => setSimpleMode(false)}
|
||||||
|
onChange={onChange}
|
||||||
|
value={values}
|
||||||
|
errors={errors}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<AdvancedMode
|
||||||
|
onSimpleModeClick={() => setSimpleMode(true)}
|
||||||
|
onChange={onChange}
|
||||||
|
value={values}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function envVarValidation(): SchemaOf<Value> {
|
||||||
|
return array(
|
||||||
|
object({
|
||||||
|
name: string().required('Name is required'),
|
||||||
|
value: string().default(''),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { FormSection } from '@@/form-components/FormSection';
|
||||||
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
|
||||||
|
import { ArrayError } from '../InputList/InputList';
|
||||||
|
|
||||||
|
import { Value } from './types';
|
||||||
|
import { EnvironmentVariablesFieldset } from './EnvironmentVariablesFieldset';
|
||||||
|
|
||||||
|
export function EnvironmentVariablesPanel({
|
||||||
|
explanation,
|
||||||
|
onChange,
|
||||||
|
values,
|
||||||
|
showHelpMessage,
|
||||||
|
errors,
|
||||||
|
}: {
|
||||||
|
explanation?: string;
|
||||||
|
values: Value;
|
||||||
|
onChange(value: Value): void;
|
||||||
|
showHelpMessage?: boolean;
|
||||||
|
errors?: ArrayError<Value>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<FormSection title="Environment variables">
|
||||||
|
<div className="form-group">
|
||||||
|
{!!explanation && (
|
||||||
|
<div className="col-sm-12 environment-variables-panel--explanation">
|
||||||
|
{explanation}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<EnvironmentVariablesFieldset
|
||||||
|
values={values}
|
||||||
|
onChange={onChange}
|
||||||
|
errors={errors}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showHelpMessage && (
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<TextTip color="blue" inline={false}>
|
||||||
|
Environment changes will not take effect until redeployment occurs
|
||||||
|
manually or via webhook.
|
||||||
|
</TextTip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FormSection>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,155 @@
|
||||||
|
import { Edit, Plus } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { readFileAsText } from '@/portainer/services/fileUploadReact';
|
||||||
|
|
||||||
|
import { Button } from '@@/buttons';
|
||||||
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
import { FileUploadField } from '@@/form-components/FileUpload';
|
||||||
|
import { InputList } from '@@/form-components/InputList';
|
||||||
|
import { ArrayError, ItemProps } from '@@/form-components/InputList/InputList';
|
||||||
|
import { InputLabeled } from '@@/form-components/Input/InputLabeled';
|
||||||
|
|
||||||
|
import { FormError } from '../FormError';
|
||||||
|
|
||||||
|
import { type EnvVar, type Value } from './types';
|
||||||
|
import { parseDotEnvFile } from './utils';
|
||||||
|
|
||||||
|
export function SimpleMode({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onAdvancedModeClick,
|
||||||
|
errors,
|
||||||
|
}: {
|
||||||
|
value: Value;
|
||||||
|
onChange: (value: Value) => void;
|
||||||
|
onAdvancedModeClick: () => void;
|
||||||
|
errors?: ArrayError<Value>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="link"
|
||||||
|
icon={Edit}
|
||||||
|
className="!ml-0 p-0 hover:no-underline"
|
||||||
|
onClick={onAdvancedModeClick}
|
||||||
|
>
|
||||||
|
Advanced mode
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<TextTip color="blue" inline={false}>
|
||||||
|
Switch to advanced mode to copy & paste multiple variables
|
||||||
|
</TextTip>
|
||||||
|
|
||||||
|
<InputList
|
||||||
|
aria-label="environment variables list"
|
||||||
|
onChange={onChange}
|
||||||
|
value={value}
|
||||||
|
isAddButtonHidden
|
||||||
|
item={Item}
|
||||||
|
errors={errors}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => onChange([...value, { name: '', value: '' }])}
|
||||||
|
color="default"
|
||||||
|
icon={Plus}
|
||||||
|
>
|
||||||
|
Add an environment variable
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<FileEnv onChooseFile={(add) => onChange([...value, ...add])} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Item({
|
||||||
|
item,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
error,
|
||||||
|
readOnly,
|
||||||
|
index,
|
||||||
|
}: ItemProps<EnvVar>) {
|
||||||
|
return (
|
||||||
|
<div className="relative flex w-full flex-col">
|
||||||
|
<div className="flex w-full items-center gap-2">
|
||||||
|
<InputLabeled
|
||||||
|
className="w-1/2"
|
||||||
|
label="name"
|
||||||
|
value={item.name}
|
||||||
|
onChange={(e) => handleChange({ name: e.target.value })}
|
||||||
|
disabled={disabled}
|
||||||
|
readOnly={readOnly}
|
||||||
|
placeholder="e.g. FOO"
|
||||||
|
size="small"
|
||||||
|
id={`env-name${index}`}
|
||||||
|
/>
|
||||||
|
<InputLabeled
|
||||||
|
className="w-1/2"
|
||||||
|
label="value"
|
||||||
|
value={item.value}
|
||||||
|
onChange={(e) => handleChange({ value: e.target.value })}
|
||||||
|
disabled={disabled}
|
||||||
|
readOnly={readOnly}
|
||||||
|
placeholder="e.g. bar"
|
||||||
|
size="small"
|
||||||
|
id={`env-value${index}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!!error && (
|
||||||
|
<div className="absolute -bottom-5">
|
||||||
|
<FormError className="m-0">{Object.values(error)[0]}</FormError>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleChange(partial: Partial<EnvVar>) {
|
||||||
|
onChange({ ...item, ...partial });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileEnv({ onChooseFile }: { onChooseFile: (file: Value) => void }) {
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
|
||||||
|
const fileTooBig = file && file.size > 1024 * 1024;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FileUploadField
|
||||||
|
inputId="env-file-upload"
|
||||||
|
onChange={handleChange}
|
||||||
|
title="Load variables from .env file"
|
||||||
|
accept=".env"
|
||||||
|
value={file}
|
||||||
|
color="default"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{fileTooBig && (
|
||||||
|
<TextTip color="orange" inline>
|
||||||
|
File too large! Try uploading a file smaller than 1MB
|
||||||
|
</TextTip>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleChange(file: File) {
|
||||||
|
setFile(file);
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await readFileAsText(file);
|
||||||
|
if (!text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseDotEnvFile(text);
|
||||||
|
onChooseFile(parsed);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
export {
|
||||||
|
EnvironmentVariablesFieldset,
|
||||||
|
envVarValidation,
|
||||||
|
} from './EnvironmentVariablesFieldset';
|
||||||
|
|
||||||
|
export { EnvironmentVariablesPanel } from './EnvironmentVariablesPanel';
|
||||||
|
|
||||||
|
export { type Value as EnvVarValues } from './types';
|
|
@ -0,0 +1,6 @@
|
||||||
|
export interface EnvVar {
|
||||||
|
name: string;
|
||||||
|
value?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Value = Array<EnvVar>;
|
|
@ -0,0 +1,53 @@
|
||||||
|
import _ from 'lodash';
|
||||||
|
|
||||||
|
import { EnvVar } from './types';
|
||||||
|
|
||||||
|
export const KEY_REGEX = /(.+?)/.source;
|
||||||
|
export const VALUE_REGEX = /(.*)?/.source;
|
||||||
|
|
||||||
|
const KEY_VALUE_REGEX = new RegExp(`^(${KEY_REGEX})\\s*=(${VALUE_REGEX})$`);
|
||||||
|
const NEWLINES_REGEX = /\n|\r|\r\n/;
|
||||||
|
|
||||||
|
export function parseDotEnvFile(src: string) {
|
||||||
|
return parseArrayOfStrings(
|
||||||
|
_.compact(src.split(NEWLINES_REGEX))
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter((v) => !v.startsWith('#') && v !== '')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseArrayOfStrings(array: Array<string> = []): Array<EnvVar> {
|
||||||
|
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 null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: parsedKeyValArr[1].trim(),
|
||||||
|
value: parsedKeyValArr[3].trim() || '',
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertToArrayOfStrings(array: Array<EnvVar>) {
|
||||||
|
if (!array) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array
|
||||||
|
.filter((variable) => variable.name)
|
||||||
|
.map(({ name, value }) =>
|
||||||
|
value || value === '' ? `${name}=${value}` : name
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
import { ChangeEvent, createRef } from 'react';
|
import { ChangeEvent, ComponentProps, createRef } from 'react';
|
||||||
import { XCircle } from 'lucide-react';
|
import { Upload, XCircle } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@@/buttons';
|
import { Button } from '@@/buttons';
|
||||||
import { Icon } from '@@/Icon';
|
import { Icon } from '@@/Icon';
|
||||||
|
@ -8,11 +8,12 @@ import styles from './FileUploadField.module.css';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
onChange(value: File): void;
|
onChange(value: File): void;
|
||||||
value?: File;
|
value?: File | null;
|
||||||
accept?: string;
|
accept?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
inputId: string;
|
inputId: string;
|
||||||
|
color?: ComponentProps<typeof Button>['color'];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FileUploadField({
|
export function FileUploadField({
|
||||||
|
@ -22,6 +23,7 @@ export function FileUploadField({
|
||||||
title = 'Select a file',
|
title = 'Select a file',
|
||||||
required = false,
|
required = false,
|
||||||
inputId,
|
inputId,
|
||||||
|
color = 'primary',
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const fileRef = createRef<HTMLInputElement>();
|
const fileRef = createRef<HTMLInputElement>();
|
||||||
|
|
||||||
|
@ -39,15 +41,16 @@ export function FileUploadField({
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
color="primary"
|
color={color}
|
||||||
onClick={handleButtonClick}
|
onClick={handleButtonClick}
|
||||||
className={styles.fileButton}
|
className={styles.fileButton}
|
||||||
|
icon={Upload}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<span className="vertical-center">
|
<span className="vertical-center">
|
||||||
{value ? value.name : <Icon icon={XCircle} mode="danger" />}
|
{value ? value.name : required && <Icon icon={XCircle} mode="danger" />}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { Meta } from '@storybook/react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { InputLabeled } from './InputLabeled';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
component: InputLabeled,
|
||||||
|
title: 'Components/Form/InputLabeled',
|
||||||
|
} as Meta;
|
||||||
|
|
||||||
|
export { TextInput, NumberInput };
|
||||||
|
|
||||||
|
function TextInput() {
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InputLabeled
|
||||||
|
label="label"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumberInput() {
|
||||||
|
const [value, setValue] = useState(5);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InputLabeled
|
||||||
|
label="label"
|
||||||
|
type="number"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.valueAsNumber)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { ComponentProps, InputHTMLAttributes } from 'react';
|
||||||
|
|
||||||
|
import { InputGroup } from '../InputGroup';
|
||||||
|
|
||||||
|
export function InputLabeled({
|
||||||
|
label,
|
||||||
|
className,
|
||||||
|
size,
|
||||||
|
id,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
className?: string;
|
||||||
|
size?: ComponentProps<typeof InputGroup>['size'];
|
||||||
|
} & Omit<InputHTMLAttributes<HTMLInputElement>, 'size' | 'children'>) {
|
||||||
|
return (
|
||||||
|
<InputGroup className={className} size={size}>
|
||||||
|
<InputGroup.Addon as="label" htmlFor={id}>
|
||||||
|
{label}
|
||||||
|
</InputGroup.Addon>
|
||||||
|
<InputGroup.Input
|
||||||
|
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||||
|
{...props}
|
||||||
|
id={id}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,9 +1,19 @@
|
||||||
import { PropsWithChildren } from 'react';
|
import { ComponentType, PropsWithChildren } from 'react';
|
||||||
|
|
||||||
import { useInputGroupContext } from './InputGroup';
|
import { useInputGroupContext } from './InputGroup';
|
||||||
|
|
||||||
export function InputGroupAddon({ children }: PropsWithChildren<unknown>) {
|
export function InputGroupAddon<TProps>({
|
||||||
|
children,
|
||||||
|
as = 'span',
|
||||||
|
...props
|
||||||
|
}: PropsWithChildren<{ as?: ComponentType<TProps> | string } & TProps>) {
|
||||||
useInputGroupContext();
|
useInputGroupContext();
|
||||||
|
const Component = as as 'span';
|
||||||
|
|
||||||
return <span className="input-group-addon">{children}</span>;
|
return (
|
||||||
|
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||||
|
<Component className="input-group-addon" {...props}>
|
||||||
|
{children}
|
||||||
|
</Component>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,12 +13,25 @@ import { FormError } from '../FormError';
|
||||||
import styles from './InputList.module.css';
|
import styles from './InputList.module.css';
|
||||||
import { arrayMove } from './utils';
|
import { arrayMove } from './utils';
|
||||||
|
|
||||||
|
type ArrElement<ArrType> = ArrType extends readonly (infer ElementType)[]
|
||||||
|
? ElementType
|
||||||
|
: never;
|
||||||
|
|
||||||
|
export type ArrayError<T> =
|
||||||
|
| FormikErrors<ArrElement<T>>[]
|
||||||
|
| string
|
||||||
|
| string[]
|
||||||
|
| undefined;
|
||||||
|
export type ItemError<T> = FormikErrors<T> | string | undefined;
|
||||||
|
|
||||||
export interface ItemProps<T> {
|
export interface ItemProps<T> {
|
||||||
item: T;
|
item: T;
|
||||||
onChange(value: T): void;
|
onChange(value: T): void;
|
||||||
error?: string | FormikErrors<T>;
|
error?: ItemError<T>;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
|
// eslint-disable-next-line react/no-unused-prop-types
|
||||||
|
index: number;
|
||||||
}
|
}
|
||||||
type Key = string | number;
|
type Key = string | number;
|
||||||
type ChangeType = 'delete' | 'create' | 'update';
|
type ChangeType = 'delete' | 'create' | 'update';
|
||||||
|
@ -38,11 +51,12 @@ type OnChangeEvent<T> =
|
||||||
type RenderItemFunction<T> = (
|
type RenderItemFunction<T> = (
|
||||||
item: T,
|
item: T,
|
||||||
onChange: (value: T) => void,
|
onChange: (value: T) => void,
|
||||||
error?: string | FormikErrors<T>
|
index: number,
|
||||||
|
error?: ItemError<T>
|
||||||
) => React.ReactNode;
|
) => React.ReactNode;
|
||||||
|
|
||||||
interface Props<T> {
|
interface Props<T> {
|
||||||
label: string;
|
label?: string;
|
||||||
value: T[];
|
value: T[];
|
||||||
onChange(value: T[], e: OnChangeEvent<T>): void;
|
onChange(value: T[], e: OnChangeEvent<T>): void;
|
||||||
itemBuilder?(): T;
|
itemBuilder?(): T;
|
||||||
|
@ -52,11 +66,12 @@ interface Props<T> {
|
||||||
addLabel?: string;
|
addLabel?: string;
|
||||||
itemKeyGetter?(item: T, index: number): Key;
|
itemKeyGetter?(item: T, index: number): Key;
|
||||||
movable?: boolean;
|
movable?: boolean;
|
||||||
errors?: FormikErrors<T>[] | string | string[];
|
errors?: ArrayError<T[]>;
|
||||||
textTip?: string;
|
textTip?: string;
|
||||||
isAddButtonHidden?: boolean;
|
isAddButtonHidden?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
|
'aria-label'?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InputList<T = DefaultType>({
|
export function InputList<T = DefaultType>({
|
||||||
|
@ -75,15 +90,22 @@ export function InputList<T = DefaultType>({
|
||||||
isAddButtonHidden = false,
|
isAddButtonHidden = false,
|
||||||
disabled,
|
disabled,
|
||||||
readOnly,
|
readOnly,
|
||||||
|
'aria-label': ariaLabel,
|
||||||
}: Props<T>) {
|
}: Props<T>) {
|
||||||
|
const isAddButtonVisible = !(isAddButtonHidden || readOnly);
|
||||||
return (
|
return (
|
||||||
<div className={clsx('form-group', styles.root)}>
|
<div
|
||||||
|
className={clsx('form-group', styles.root)}
|
||||||
|
aria-label={ariaLabel || label}
|
||||||
|
>
|
||||||
|
{label && (
|
||||||
<div className={clsx('col-sm-12', styles.header)}>
|
<div className={clsx('col-sm-12', styles.header)}>
|
||||||
<span className="control-label space-right pt-2 text-left !font-bold">
|
<span className="control-label space-right pt-2 text-left !font-bold">
|
||||||
{label}
|
{label}
|
||||||
{tooltip && <Tooltip message={tooltip} />}
|
{tooltip && <Tooltip message={tooltip} />}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{textTip && (
|
{textTip && (
|
||||||
<div className="col-sm-12 mt-5">
|
<div className="col-sm-12 mt-5">
|
||||||
|
@ -114,11 +136,13 @@ export function InputList<T = DefaultType>({
|
||||||
error={error}
|
error={error}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
|
index={index}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
renderItem(
|
renderItem(
|
||||||
item,
|
item,
|
||||||
(value: T) => handleChangeItem(key, value),
|
(value: T) => handleChangeItem(key, value),
|
||||||
|
index,
|
||||||
error
|
error
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
@ -157,8 +181,9 @@ export function InputList<T = DefaultType>({
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isAddButtonVisible && (
|
||||||
<div className="col-sm-12 mt-5">
|
<div className="col-sm-12 mt-5">
|
||||||
{!(isAddButtonHidden || readOnly) && (
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleAdd}
|
onClick={handleAdd}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
@ -170,8 +195,8 @@ export function InputList<T = DefaultType>({
|
||||||
>
|
>
|
||||||
{addLabel}
|
{addLabel}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -259,7 +284,10 @@ function DefaultItem({
|
||||||
function renderDefaultItem(
|
function renderDefaultItem(
|
||||||
item: DefaultType,
|
item: DefaultType,
|
||||||
onChange: (value: DefaultType) => void,
|
onChange: (value: DefaultType) => void,
|
||||||
error?: FormikErrors<DefaultType>
|
index: number,
|
||||||
|
error?: ItemError<DefaultType>
|
||||||
) {
|
) {
|
||||||
return <DefaultItem item={item} onChange={onChange} error={error} />;
|
return (
|
||||||
|
<DefaultItem item={item} onChange={onChange} error={error} index={index} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,11 +30,12 @@ export function CustomTemplatesVariablesDefinitionField({
|
||||||
label="Variables definition"
|
label="Variables definition"
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
value={value}
|
value={value}
|
||||||
renderItem={(item, onChange, error) => (
|
renderItem={(item, onChange, index, error) => (
|
||||||
<Item
|
<Item
|
||||||
item={item}
|
item={item}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
error={error}
|
error={error}
|
||||||
|
index={index}
|
||||||
isNameReadonly={isVariablesNamesFromParent}
|
isNameReadonly={isVariablesNamesFromParent}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
Loading…
Reference in New Issue