refactor(ui): migrate env var field to react [EE-4853] (#8451)

pull/9024/head
Chaim Lev-Ari 2023-05-31 10:08:41 +07:00 committed by GitHub
parent 6b5940e00e
commit 2d05103fed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 721 additions and 442 deletions

View File

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

View File

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

View File

@ -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');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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);

View File

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

View File

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

View File

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

View File

@ -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: '<',
},
});

View File

@ -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: '<',
},
});

View File

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

View File

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

View File

@ -9,4 +9,5 @@ export const fileUploadField = r2a(FileUploadField, [
'required',
'accept',
'inputId',
'color',
]);

View File

@ -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
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(''),
})
);
}

View File

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

View File

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

View File

@ -0,0 +1,8 @@
export {
EnvironmentVariablesFieldset,
envVarValidation,
} from './EnvironmentVariablesFieldset';
export { EnvironmentVariablesPanel } from './EnvironmentVariablesPanel';
export { type Value as EnvVarValues } from './types';

View File

@ -0,0 +1,6 @@
export interface EnvVar {
name: string;
value?: string;
}
export type Value = Array<EnvVar>;

View File

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

View File

@ -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>
);

View File

@ -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)}
/>
);
}

View File

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

View File

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

View File

@ -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} />
);
}

View File

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