diff --git a/app/docker/views/containers/create/createContainerController.js b/app/docker/views/containers/create/createContainerController.js index 9a10f258a..20c0148ae 100644 --- a/app/docker/views/containers/create/createContainerController.js +++ b/app/docker/views/containers/create/createContainerController.js @@ -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'; diff --git a/app/docker/views/containers/create/createcontainer.html b/app/docker/views/containers/create/createcontainer.html index 63abdb1da..ddb5d4f15 100644 --- a/app/docker/views/containers/create/createcontainer.html +++ b/app/docker/views/containers/create/createcontainer.html @@ -578,11 +578,13 @@
- +
+ +
diff --git a/app/docker/views/services/create/createServiceController.js b/app/docker/views/services/create/createServiceController.js index 82fea76ca..e069a9e35 100644 --- a/app/docker/views/services/create/createServiceController.js +++ b/app/docker/views/services/create/createServiceController.js @@ -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'); diff --git a/app/docker/views/services/create/createservice.html b/app/docker/views/services/create/createservice.html index 49527fadb..12e814dbc 100644 --- a/app/docker/views/services/create/createservice.html +++ b/app/docker/views/services/create/createservice.html @@ -396,11 +396,13 @@
- +
+ +
diff --git a/app/docker/views/services/edit/includes/environmentvariables.html b/app/docker/views/services/edit/includes/environmentvariables.html index 6d9b15491..a950c002a 100644 --- a/app/docker/views/services/edit/includes/environmentvariables.html +++ b/app/docker/views/services/edit/includes/environmentvariables.html @@ -1,4 +1,4 @@ - +
@@ -11,7 +11,9 @@

There are no environment variables for this service.

- +
+ +
diff --git a/app/portainer/helpers/env-vars.js b/app/portainer/helpers/env-vars.js deleted file mode 100644 index f64f52b5e..000000000 --- a/app/portainer/helpers/env-vars.js +++ /dev/null @@ -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)); -} diff --git a/app/portainer/react/components/file-upload-field.ts b/app/portainer/react/components/file-upload-field.ts index 3675111d8..74f03d94d 100644 --- a/app/portainer/react/components/file-upload-field.ts +++ b/app/portainer/react/components/file-upload-field.ts @@ -9,4 +9,5 @@ export const fileUploadField = r2a(FileUploadField, [ 'required', 'accept', 'inputId', + 'color', ]); diff --git a/app/portainer/react/components/index.ts b/app/portainer/react/components/index.ts index c470d3b08..af2306fd3 100644 --- a/app/portainer/react/components/index.ts +++ b/app/portainer/react/components/index.ts @@ -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 +); diff --git a/app/portainer/services/fileUploadReact.ts b/app/portainer/services/fileUploadReact.ts new file mode 100644 index 000000000..f8b50a393 --- /dev/null +++ b/app/portainer/services/fileUploadReact.ts @@ -0,0 +1,25 @@ +export function readFileAsArrayBuffer(file: File): Promise { + 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 { + 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); + }); +} diff --git a/app/portainer/views/stacks/create/createstack.html b/app/portainer/views/stacks/create/createstack.html index a216b99de..41ddb57e0 100644 --- a/app/portainer/views/stacks/create/createstack.html +++ b/app/portainer/views/stacks/create/createstack.html @@ -153,8 +153,8 @@ diff --git a/app/portainer/views/stacks/edit/stack.html b/app/portainer/views/stacks/edit/stack.html index 679d72160..e0ecd6cdd 100644 --- a/app/portainer/views/stacks/edit/stack.html +++ b/app/portainer/views/stacks/edit/stack.html @@ -181,8 +181,8 @@
diff --git a/app/react-tools/react2angular.tsx b/app/react-tools/react2angular.tsx index ae1814803..f00ebb593 100644 --- a/app/react-tools/react2angular.tsx +++ b/app/react-tools/react2angular.tsx @@ -24,7 +24,7 @@ function toProps( ); } -type PropNames = Exclude; +export type PropNames = Exclude; /** * react2angular is used to bind a React component to an AngularJS component diff --git a/app/react-tools/withFormValidation.ts b/app/react-tools/withFormValidation.ts new file mode 100644 index 000000000..ed105dc7a --- /dev/null +++ b/app/react-tools/withFormValidation.ts @@ -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 { + onChange(values: TValue): void; + values: TValue; + errors?: FormikErrors | ArrayError; +} + +type WithFormFieldProps = TProps & FormFieldProps; + +/** + * 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( + ngModule: IModule, + Component: ComponentType>, + componentName: string, + propNames: PropNames[], + schemaBuilder: (data?: TData) => SchemaOf +) { + const reactComponentName = `react${_.upperFirst(componentName)}`; + + ngModule + .component( + reactComponentName, + r2a(Component, ['errors', 'onChange', 'values', ...propNames]) + ) + .component( + componentName, + createFormValidationComponent( + reactComponentName, + propNames, + schemaBuilder + ) + ); +} + +export function createFormValidationComponent( + componentName: string, + props: Array, + schemaBuilder: (data?: TData) => SchemaOf +): IComponentOptions { + const kebabName = _.kebabCase(componentName); + const propsWithErrors = [...props, 'errors', 'values']; + + return { + template: ` + <${kebabName} ${propsWithErrors + .filter((p) => p !== 'onChange') + .map((p) => `${_.kebabCase(p)}="$ctrl.${p}"`) + .join(' ')} + on-change="($ctrl.handleChange)" + > + `, + controller: createFormValidatorController(schemaBuilder), + bindings: Object.fromEntries( + [...propsWithErrors, 'validationData', 'onChange'].map((p) => [p, '<']) + ), + }; +} + +export function createFormValidatorController( + schemaBuilder: (data?: TData) => SchemaOf +) { + return class FormValidatorController { + errors?: FormikErrors = {}; + + $async: (fn: () => Promise) => Promise; + + form?: IFormController; + + values?: TFormModel; + + validationData?: TData; + + onChange?: (value: TFormModel) => void; + + /* @ngInject */ + constructor($async: (fn: () => Promise) => Promise) { + 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( + () => 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); + } + } + }; +} diff --git a/app/react/components/Tip/TextTip/TextTip.tsx b/app/react/components/Tip/TextTip/TextTip.tsx index 97c914c61..b6608ef9a 100644 --- a/app/react/components/Tip/TextTip/TextTip.tsx +++ b/app/react/components/Tip/TextTip/TextTip.tsx @@ -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) { return ( -
+
{children} diff --git a/app/react/components/form-components/EnvironmentVariablesFieldset/AdvancedMode.tsx b/app/react/components/form-components/EnvironmentVariablesFieldset/AdvancedMode.tsx new file mode 100644 index 000000000..fe6f6745f --- /dev/null +++ b/app/react/components/form-components/EnvironmentVariablesFieldset/AdvancedMode.tsx @@ -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 ( + <> + + + + Switch to simple mode to define variables line by line, or load from + .env file + + + + + ); + + function handleEditorChange(value: string) { + onChange(parseDotEnvFile(value)); + } +} diff --git a/app/react/components/form-components/EnvironmentVariablesFieldset/EnvironmentVariablesFieldset.tsx b/app/react/components/form-components/EnvironmentVariablesFieldset/EnvironmentVariablesFieldset.tsx new file mode 100644 index 000000000..7596482be --- /dev/null +++ b/app/react/components/form-components/EnvironmentVariablesFieldset/EnvironmentVariablesFieldset.tsx @@ -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; +}) { + const [simpleMode, setSimpleMode] = useState(true); + + return ( +
+ {simpleMode ? ( + setSimpleMode(false)} + onChange={onChange} + value={values} + errors={errors} + /> + ) : ( + setSimpleMode(true)} + onChange={onChange} + value={values} + /> + )} +
+ ); +} + +export function envVarValidation(): SchemaOf { + return array( + object({ + name: string().required('Name is required'), + value: string().default(''), + }) + ); +} diff --git a/app/react/components/form-components/EnvironmentVariablesFieldset/EnvironmentVariablesPanel.tsx b/app/react/components/form-components/EnvironmentVariablesFieldset/EnvironmentVariablesPanel.tsx new file mode 100644 index 000000000..3eed62246 --- /dev/null +++ b/app/react/components/form-components/EnvironmentVariablesFieldset/EnvironmentVariablesPanel.tsx @@ -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; +}) { + return ( + +
+ {!!explanation && ( +
+ {explanation} +
+ )} + + + + {showHelpMessage && ( +
+ + Environment changes will not take effect until redeployment occurs + manually or via webhook. + +
+ )} +
+
+ ); +} diff --git a/app/react/components/form-components/EnvironmentVariablesFieldset/SimpleMode.tsx b/app/react/components/form-components/EnvironmentVariablesFieldset/SimpleMode.tsx new file mode 100644 index 000000000..852c27700 --- /dev/null +++ b/app/react/components/form-components/EnvironmentVariablesFieldset/SimpleMode.tsx @@ -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; +}) { + return ( + <> + + + + Switch to advanced mode to copy & paste multiple variables + + + + +
+ + + onChange([...value, ...add])} /> +
+ + ); +} + +function Item({ + item, + onChange, + disabled, + error, + readOnly, + index, +}: ItemProps) { + return ( +
+
+ handleChange({ name: e.target.value })} + disabled={disabled} + readOnly={readOnly} + placeholder="e.g. FOO" + size="small" + id={`env-name${index}`} + /> + handleChange({ value: e.target.value })} + disabled={disabled} + readOnly={readOnly} + placeholder="e.g. bar" + size="small" + id={`env-value${index}`} + /> +
+ + {!!error && ( +
+ {Object.values(error)[0]} +
+ )} +
+ ); + + function handleChange(partial: Partial) { + onChange({ ...item, ...partial }); + } +} + +function FileEnv({ onChooseFile }: { onChooseFile: (file: Value) => void }) { + const [file, setFile] = useState(null); + + const fileTooBig = file && file.size > 1024 * 1024; + + return ( + <> + + + {fileTooBig && ( + + File too large! Try uploading a file smaller than 1MB + + )} + + ); + + async function handleChange(file: File) { + setFile(file); + if (!file) { + return; + } + + const text = await readFileAsText(file); + if (!text) { + return; + } + + const parsed = parseDotEnvFile(text); + onChooseFile(parsed); + } +} diff --git a/app/react/components/form-components/EnvironmentVariablesFieldset/index.ts b/app/react/components/form-components/EnvironmentVariablesFieldset/index.ts new file mode 100644 index 000000000..8f4fe0670 --- /dev/null +++ b/app/react/components/form-components/EnvironmentVariablesFieldset/index.ts @@ -0,0 +1,8 @@ +export { + EnvironmentVariablesFieldset, + envVarValidation, +} from './EnvironmentVariablesFieldset'; + +export { EnvironmentVariablesPanel } from './EnvironmentVariablesPanel'; + +export { type Value as EnvVarValues } from './types'; diff --git a/app/react/components/form-components/EnvironmentVariablesFieldset/types.ts b/app/react/components/form-components/EnvironmentVariablesFieldset/types.ts new file mode 100644 index 000000000..5366256db --- /dev/null +++ b/app/react/components/form-components/EnvironmentVariablesFieldset/types.ts @@ -0,0 +1,6 @@ +export interface EnvVar { + name: string; + value?: string; +} + +export type Value = Array; diff --git a/app/react/components/form-components/EnvironmentVariablesFieldset/utils.ts b/app/react/components/form-components/EnvironmentVariablesFieldset/utils.ts new file mode 100644 index 000000000..7d17b9b84 --- /dev/null +++ b/app/react/components/form-components/EnvironmentVariablesFieldset/utils.ts @@ -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 = []): 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 null; + } + + return { + name: parsedKeyValArr[1].trim(), + value: parsedKeyValArr[3].trim() || '', + }; + }) + ); +} + +export function convertToArrayOfStrings(array: Array) { + if (!array) { + return []; + } + + return array + .filter((variable) => variable.name) + .map(({ name, value }) => + value || value === '' ? `${name}=${value}` : name + ); +} diff --git a/app/react/components/form-components/FileUpload/FileUploadField.tsx b/app/react/components/form-components/FileUpload/FileUploadField.tsx index d6e57607f..06bdcacba 100644 --- a/app/react/components/form-components/FileUpload/FileUploadField.tsx +++ b/app/react/components/form-components/FileUpload/FileUploadField.tsx @@ -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['color']; } export function FileUploadField({ @@ -22,6 +23,7 @@ export function FileUploadField({ title = 'Select a file', required = false, inputId, + color = 'primary', }: Props) { const fileRef = createRef(); @@ -39,15 +41,16 @@ export function FileUploadField({ /> - {value ? value.name : } + {value ? value.name : required && }
); diff --git a/app/react/components/form-components/Input/InputLabeled.stories.tsx b/app/react/components/form-components/Input/InputLabeled.stories.tsx new file mode 100644 index 000000000..246967289 --- /dev/null +++ b/app/react/components/form-components/Input/InputLabeled.stories.tsx @@ -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 ( + setValue(e.target.value)} + /> + ); +} + +function NumberInput() { + const [value, setValue] = useState(5); + + return ( + setValue(e.target.valueAsNumber)} + /> + ); +} diff --git a/app/react/components/form-components/Input/InputLabeled.tsx b/app/react/components/form-components/Input/InputLabeled.tsx new file mode 100644 index 000000000..5a19af279 --- /dev/null +++ b/app/react/components/form-components/Input/InputLabeled.tsx @@ -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['size']; +} & Omit, 'size' | 'children'>) { + return ( + + + {label} + + + + ); +} diff --git a/app/react/components/form-components/InputGroup/InputGroupAddon.tsx b/app/react/components/form-components/InputGroup/InputGroupAddon.tsx index 32d294367..09cef2c29 100644 --- a/app/react/components/form-components/InputGroup/InputGroupAddon.tsx +++ b/app/react/components/form-components/InputGroup/InputGroupAddon.tsx @@ -1,9 +1,19 @@ -import { PropsWithChildren } from 'react'; +import { ComponentType, PropsWithChildren } from 'react'; import { useInputGroupContext } from './InputGroup'; -export function InputGroupAddon({ children }: PropsWithChildren) { +export function InputGroupAddon({ + children, + as = 'span', + ...props +}: PropsWithChildren<{ as?: ComponentType | string } & TProps>) { useInputGroupContext(); + const Component = as as 'span'; - return {children}; + return ( + // eslint-disable-next-line react/jsx-props-no-spreading + + {children} + + ); } diff --git a/app/react/components/form-components/InputList/InputList.tsx b/app/react/components/form-components/InputList/InputList.tsx index 89540d5d5..700de04ec 100644 --- a/app/react/components/form-components/InputList/InputList.tsx +++ b/app/react/components/form-components/InputList/InputList.tsx @@ -13,12 +13,25 @@ import { FormError } from '../FormError'; import styles from './InputList.module.css'; import { arrayMove } from './utils'; +type ArrElement = ArrType extends readonly (infer ElementType)[] + ? ElementType + : never; + +export type ArrayError = + | FormikErrors>[] + | string + | string[] + | undefined; +export type ItemError = FormikErrors | string | undefined; + export interface ItemProps { item: T; onChange(value: T): void; - error?: string | FormikErrors; + error?: ItemError; 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 = type RenderItemFunction = ( item: T, onChange: (value: T) => void, - error?: string | FormikErrors + index: number, + error?: ItemError ) => React.ReactNode; interface Props { - label: string; + label?: string; value: T[]; onChange(value: T[], e: OnChangeEvent): void; itemBuilder?(): T; @@ -52,11 +66,12 @@ interface Props { addLabel?: string; itemKeyGetter?(item: T, index: number): Key; movable?: boolean; - errors?: FormikErrors[] | string | string[]; + errors?: ArrayError; textTip?: string; isAddButtonHidden?: boolean; disabled?: boolean; readOnly?: boolean; + 'aria-label'?: string; } export function InputList({ @@ -75,15 +90,22 @@ export function InputList({ isAddButtonHidden = false, disabled, readOnly, + 'aria-label': ariaLabel, }: Props) { + const isAddButtonVisible = !(isAddButtonHidden || readOnly); return ( -
-
- - {label} - {tooltip && } - -
+
+ {label && ( +
+ + {label} + {tooltip && } + +
+ )} {textTip && (
@@ -114,11 +136,13 @@ export function InputList({ error={error} disabled={disabled} readOnly={readOnly} + index={index} /> ) : ( renderItem( item, (value: T) => handleChangeItem(key, value), + index, error ) )} @@ -157,8 +181,9 @@ export function InputList({ })}
)} -
- {!(isAddButtonHidden || readOnly) && ( + + {isAddButtonVisible && ( +
- )} -
+
+ )}
); @@ -259,7 +284,10 @@ function DefaultItem({ function renderDefaultItem( item: DefaultType, onChange: (value: DefaultType) => void, - error?: FormikErrors + index: number, + error?: ItemError ) { - return ; + return ( + + ); } diff --git a/app/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField.tsx b/app/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField.tsx index 44eadc326..e24ee56de 100644 --- a/app/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField.tsx +++ b/app/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField.tsx @@ -30,11 +30,12 @@ export function CustomTemplatesVariablesDefinitionField({ label="Variables definition" onChange={onChange} value={value} - renderItem={(item, onChange, error) => ( + renderItem={(item, onChange, index, error) => ( )}