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 * as envVarsUtils from '@/react/components/form-components/EnvironmentVariablesFieldset/utils';
|
||||
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
|
||||
|
||||
import { confirmDestructive } from '@@/modals/confirm';
|
||||
import * as envVarsUtils from '@/portainer/helpers/env-vars';
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
import { buildConfirmButton } from '@@/modals/utils';
|
||||
|
||||
|
|
|
@ -578,11 +578,13 @@
|
|||
<!-- !tab-labels -->
|
||||
<!-- tab-env -->
|
||||
<div class="tab-pane" id="env">
|
||||
<environment-variables-panel
|
||||
ng-model="formValues.Env"
|
||||
explanation="These values will be applied to the container when deployed"
|
||||
on-change="(handleEnvVarChange)"
|
||||
></environment-variables-panel>
|
||||
<div class="form-horizontal">
|
||||
<environment-variables-panel
|
||||
values="formValues.Env"
|
||||
explanation="'These values will be applied to the container when deployed'"
|
||||
on-change="(handleEnvVarChange)"
|
||||
></environment-variables-panel>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !tab-env -->
|
||||
<!-- tab-restart-policy -->
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import _ from 'lodash-es';
|
||||
|
||||
import * as envVarsUtils from '@/react/components/form-components/EnvironmentVariablesFieldset/utils';
|
||||
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
|
||||
import * as envVarsUtils from '@/portainer/helpers/env-vars';
|
||||
import { AccessControlFormData } from '../../../../portainer/components/accessControlForm/porAccessControlFormModel';
|
||||
|
||||
require('./includes/update-restart.html');
|
||||
|
|
|
@ -396,11 +396,13 @@
|
|||
<!-- !tab-network -->
|
||||
<!-- tab-env -->
|
||||
<div class="tab-pane" id="env">
|
||||
<environment-variables-panel
|
||||
ng-model="formValues.Env"
|
||||
explanation="These values will be applied to the service when created"
|
||||
on-change="(handleEnvVarChange)"
|
||||
></environment-variables-panel>
|
||||
<div class="form-horizontal">
|
||||
<environment-variables-panel
|
||||
values="formValues.Env"
|
||||
explanation="'These values will be applied to the service when created'"
|
||||
on-change="(handleEnvVarChange)"
|
||||
></environment-variables-panel>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !tab-env -->
|
||||
<!-- tab-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-header icon="list" title-text="Environment variables">
|
||||
<div class="nopadding" authorization="DockerServiceUpdate">
|
||||
|
@ -11,7 +11,9 @@
|
|||
<p>There are no environment variables for this service.</p>
|
||||
</rd-widget-body>
|
||||
<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-footer authorization="DockerServiceUpdate">
|
||||
<div class="btn-toolbar" role="toolbar">
|
||||
|
|
|
@ -19,8 +19,8 @@ require('./includes/updateconfig.html');
|
|||
|
||||
import _ from 'lodash-es';
|
||||
|
||||
import * as envVarsUtils from '@/react/components/form-components/EnvironmentVariablesFieldset/utils';
|
||||
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
|
||||
import * as envVarsUtils from '@/portainer/helpers/env-vars';
|
||||
import { ResourceControlType } from '@/react/portainer/access-control/types';
|
||||
import { confirmServiceForceUpdate } from '@/react/docker/services/common/update-service-modal';
|
||||
import { confirm, confirmDelete } from '@@/modals/confirm';
|
||||
|
@ -125,8 +125,10 @@ angular.module('portainer.docker').controller('ServiceController', [
|
|||
};
|
||||
|
||||
$scope.addEnvironmentVariable = function addEnvironmentVariable(service) {
|
||||
service.EnvironmentVariables.push({ name: '', value: '' });
|
||||
updateServiceArray(service, 'EnvironmentVariables', service.EnvironmentVariables);
|
||||
$scope.$evalAsync(() => {
|
||||
service.EnvironmentVariables = service.EnvironmentVariables.concat({ name: '', value: '' });
|
||||
updateServiceArray(service, 'EnvironmentVariables', service.EnvironmentVariables);
|
||||
});
|
||||
};
|
||||
|
||||
$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>
|
||||
|
||||
<environment-variables-panel
|
||||
ng-model="$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’."
|
||||
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’.'"
|
||||
on-change="($ctrl.onChangeEnvVar)"
|
||||
show-help-message="true"
|
||||
></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',
|
||||
'accept',
|
||||
'inputId',
|
||||
'color',
|
||||
]);
|
||||
|
|
|
@ -5,7 +5,13 @@ import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
|||
import { withReactQuery } from '@/react-tools/withReactQuery';
|
||||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||
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 { ReactQueryDevtoolsWrapper } from '@@/ReactQueryDevtoolsWrapper';
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
|
@ -37,7 +43,7 @@ import { environmentsModule } from './environments';
|
|||
import { envListModule } from './environments-list-view-components';
|
||||
import { registriesModule } from './registries';
|
||||
|
||||
export const componentsModule = angular
|
||||
export const ngModule = angular
|
||||
.module('portainer.app.react.components', [
|
||||
accessControlModule,
|
||||
customTemplatesModule,
|
||||
|
@ -197,4 +203,22 @@ export const componentsModule = angular
|
|||
'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-panel
|
||||
ng-model="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’"
|
||||
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’'"
|
||||
on-change="(handleEnvVarChange)"
|
||||
>
|
||||
</environment-variables-panel>
|
||||
|
|
|
@ -181,8 +181,8 @@
|
|||
<!-- environment-variables -->
|
||||
<div ng-if="stack">
|
||||
<environment-variables-panel
|
||||
ng-model="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’."
|
||||
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’.'"
|
||||
on-change="(handleEnvVarChange)"
|
||||
show-help-message="true"
|
||||
></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
|
||||
|
|
|
@ -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;
|
||||
className?: string;
|
||||
childrenWrapperClassName?: string;
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
export function TextTip({
|
||||
color = 'orange',
|
||||
icon = AlertCircle,
|
||||
inline = true,
|
||||
className,
|
||||
children,
|
||||
childrenWrapperClassName = 'text-muted',
|
||||
}: PropsWithChildren<Props>) {
|
||||
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]" />
|
||||
|
||||
<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 { XCircle } from 'lucide-react';
|
||||
import { ChangeEvent, ComponentProps, createRef } from 'react';
|
||||
import { Upload, XCircle } from 'lucide-react';
|
||||
|
||||
import { Button } from '@@/buttons';
|
||||
import { Icon } from '@@/Icon';
|
||||
|
@ -8,11 +8,12 @@ import styles from './FileUploadField.module.css';
|
|||
|
||||
export interface Props {
|
||||
onChange(value: File): void;
|
||||
value?: File;
|
||||
value?: File | null;
|
||||
accept?: string;
|
||||
title?: string;
|
||||
required?: boolean;
|
||||
inputId: string;
|
||||
color?: ComponentProps<typeof Button>['color'];
|
||||
}
|
||||
|
||||
export function FileUploadField({
|
||||
|
@ -22,6 +23,7 @@ export function FileUploadField({
|
|||
title = 'Select a file',
|
||||
required = false,
|
||||
inputId,
|
||||
color = 'primary',
|
||||
}: Props) {
|
||||
const fileRef = createRef<HTMLInputElement>();
|
||||
|
||||
|
@ -39,15 +41,16 @@ export function FileUploadField({
|
|||
/>
|
||||
<Button
|
||||
size="small"
|
||||
color="primary"
|
||||
color={color}
|
||||
onClick={handleButtonClick}
|
||||
className={styles.fileButton}
|
||||
icon={Upload}
|
||||
>
|
||||
{title}
|
||||
</Button>
|
||||
|
||||
<span className="vertical-center">
|
||||
{value ? value.name : <Icon icon={XCircle} mode="danger" />}
|
||||
{value ? value.name : required && <Icon icon={XCircle} mode="danger" />}
|
||||
</span>
|
||||
</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';
|
||||
|
||||
export function InputGroupAddon({ children }: PropsWithChildren<unknown>) {
|
||||
export function InputGroupAddon<TProps>({
|
||||
children,
|
||||
as = 'span',
|
||||
...props
|
||||
}: PropsWithChildren<{ as?: ComponentType<TProps> | string } & TProps>) {
|
||||
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 { 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> {
|
||||
item: T;
|
||||
onChange(value: T): void;
|
||||
error?: string | FormikErrors<T>;
|
||||
error?: ItemError<T>;
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
// eslint-disable-next-line react/no-unused-prop-types
|
||||
index: number;
|
||||
}
|
||||
type Key = string | number;
|
||||
type ChangeType = 'delete' | 'create' | 'update';
|
||||
|
@ -38,11 +51,12 @@ type OnChangeEvent<T> =
|
|||
type RenderItemFunction<T> = (
|
||||
item: T,
|
||||
onChange: (value: T) => void,
|
||||
error?: string | FormikErrors<T>
|
||||
index: number,
|
||||
error?: ItemError<T>
|
||||
) => React.ReactNode;
|
||||
|
||||
interface Props<T> {
|
||||
label: string;
|
||||
label?: string;
|
||||
value: T[];
|
||||
onChange(value: T[], e: OnChangeEvent<T>): void;
|
||||
itemBuilder?(): T;
|
||||
|
@ -52,11 +66,12 @@ interface Props<T> {
|
|||
addLabel?: string;
|
||||
itemKeyGetter?(item: T, index: number): Key;
|
||||
movable?: boolean;
|
||||
errors?: FormikErrors<T>[] | string | string[];
|
||||
errors?: ArrayError<T[]>;
|
||||
textTip?: string;
|
||||
isAddButtonHidden?: boolean;
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
'aria-label'?: string;
|
||||
}
|
||||
|
||||
export function InputList<T = DefaultType>({
|
||||
|
@ -75,15 +90,22 @@ export function InputList<T = DefaultType>({
|
|||
isAddButtonHidden = false,
|
||||
disabled,
|
||||
readOnly,
|
||||
'aria-label': ariaLabel,
|
||||
}: Props<T>) {
|
||||
const isAddButtonVisible = !(isAddButtonHidden || readOnly);
|
||||
return (
|
||||
<div className={clsx('form-group', styles.root)}>
|
||||
<div className={clsx('col-sm-12', styles.header)}>
|
||||
<span className="control-label space-right pt-2 text-left !font-bold">
|
||||
{label}
|
||||
{tooltip && <Tooltip message={tooltip} />}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={clsx('form-group', styles.root)}
|
||||
aria-label={ariaLabel || label}
|
||||
>
|
||||
{label && (
|
||||
<div className={clsx('col-sm-12', styles.header)}>
|
||||
<span className="control-label space-right pt-2 text-left !font-bold">
|
||||
{label}
|
||||
{tooltip && <Tooltip message={tooltip} />}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{textTip && (
|
||||
<div className="col-sm-12 mt-5">
|
||||
|
@ -114,11 +136,13 @@ export function InputList<T = DefaultType>({
|
|||
error={error}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
index={index}
|
||||
/>
|
||||
) : (
|
||||
renderItem(
|
||||
item,
|
||||
(value: T) => handleChangeItem(key, value),
|
||||
index,
|
||||
error
|
||||
)
|
||||
)}
|
||||
|
@ -157,8 +181,9 @@ export function InputList<T = DefaultType>({
|
|||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="col-sm-12 mt-5">
|
||||
{!(isAddButtonHidden || readOnly) && (
|
||||
|
||||
{isAddButtonVisible && (
|
||||
<div className="col-sm-12 mt-5">
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
disabled={disabled}
|
||||
|
@ -170,8 +195,8 @@ export function InputList<T = DefaultType>({
|
|||
>
|
||||
{addLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
@ -259,7 +284,10 @@ function DefaultItem({
|
|||
function renderDefaultItem(
|
||||
item: DefaultType,
|
||||
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"
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
renderItem={(item, onChange, error) => (
|
||||
renderItem={(item, onChange, index, error) => (
|
||||
<Item
|
||||
item={item}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
index={index}
|
||||
isNameReadonly={isVariablesNamesFromParent}
|
||||
/>
|
||||
)}
|
||||
|
|
Loading…
Reference in New Issue