From 4e7d1c7088737f9bbfb17ccd560074f320d78164 Mon Sep 17 00:00:00 2001 From: Ali <83188384+testA113@users.noreply.github.com> Date: Thu, 11 Jan 2024 15:13:28 +1300 Subject: [PATCH] refactor(app): migrate remaining form sections [EE-6231] (#10938) --- app/kubernetes/react/components/index.ts | 37 ++- .../create/createApplication.html | 238 +++--------------- .../create/createApplicationController.js | 66 ++--- app/portainer/react/components/index.ts | 1 + app/react-tools/withFormValidation.ts | 35 ++- app/react/components/WebEditorForm.tsx | 32 ++- .../FormControl/FormControl.tsx | 8 +- .../form-components/PortainerSelect.tsx | 5 + .../DeployView/StackName/StackName.tsx | 38 ++- .../components/EditYamlFormSection.tsx | 102 ++++++++ .../NameFormSection/NameFormSection.tsx | 38 +++ .../components/NameFormSection/index.ts | 2 + .../NameFormSection/nameValidation.ts | 43 ++++ .../NamespaceSelector/NamespaceSelector.tsx | 53 ++++ .../components/NamespaceSelector/index.ts | 2 + .../namespaceSelectorValidation.ts | 38 +++ .../kubernetes/components/YAMLInspector.tsx | 2 +- .../registries/queries/useRegistry.ts | 2 +- 18 files changed, 457 insertions(+), 285 deletions(-) create mode 100644 app/react/kubernetes/applications/components/EditYamlFormSection.tsx create mode 100644 app/react/kubernetes/applications/components/NameFormSection/NameFormSection.tsx create mode 100644 app/react/kubernetes/applications/components/NameFormSection/index.ts create mode 100644 app/react/kubernetes/applications/components/NameFormSection/nameValidation.ts create mode 100644 app/react/kubernetes/applications/components/NamespaceSelector/NamespaceSelector.tsx create mode 100644 app/react/kubernetes/applications/components/NamespaceSelector/index.ts create mode 100644 app/react/kubernetes/applications/components/NamespaceSelector/namespaceSelectorValidation.ts diff --git a/app/kubernetes/react/components/index.ts b/app/kubernetes/react/components/index.ts index 42a5ad9e7..38f6d0bfc 100644 --- a/app/kubernetes/react/components/index.ts +++ b/app/kubernetes/react/components/index.ts @@ -47,6 +47,15 @@ import { autoScalingValidation, } from '@/react/kubernetes/applications/components/AutoScalingFormSection'; import { withControlledInput } from '@/react-tools/withControlledInput'; +import { + NamespaceSelector, + namespaceSelectorValidation, +} from '@/react/kubernetes/applications/components/NamespaceSelector'; +import { EditYamlFormSection } from '@/react/kubernetes/applications/components/EditYamlFormSection'; +import { + NameFormSection, + appNameValidation, +} from '@/react/kubernetes/applications/components/NameFormSection'; import { EnvironmentVariablesFieldset } from '@@/form-components/EnvironmentVariablesFieldset'; @@ -135,9 +144,17 @@ export const ngModule = angular withUIRouter( withReactQuery(withCurrentUser(withControlledInput(StackName))) ), - ['setStackName', 'isAdmin', 'stackName'] + ['setStackName', 'stackName', 'stacks', 'inputClassName'] ) ) + .component( + 'editYamlFormSection', + r2a(withUIRouter(withReactQuery(withCurrentUser(EditYamlFormSection))), [ + 'values', + 'onChange', + 'isComposeFormat', + ]) + ) .component( 'applicationSummaryWidget', r2a( @@ -298,3 +315,21 @@ withFormValidation( [], placementValidation ); + +withFormValidation( + ngModule, + withUIRouter(withCurrentUser(NamespaceSelector)), + 'namespaceSelector', + ['isEdit'], + namespaceSelectorValidation, + true +); + +withFormValidation( + ngModule, + withUIRouter(withCurrentUser(withReactQuery(NameFormSection))), + 'nameFormSection', + ['isEdit'], + appNameValidation, + true +); diff --git a/app/kubernetes/views/applications/create/createApplication.html b/app/kubernetes/views/applications/create/createApplication.html index 48668b06b..51a00f257 100644 --- a/app/kubernetes/views/applications/create/createApplication.html +++ b/app/kubernetes/views/applications/create/createApplication.html @@ -63,33 +63,12 @@
Namespace
-
- -
- -
-
-
-
- - This namespace has exhausted its resource capacity and you will not be able to deploy the application. Contact your administrator to expand the capacity of the - namespace. -
-
-
-
- - You do not have access to any namespace. Contact your administrator to get access to a namespace. -
-
+
-
- -
- -
-
-
-
- - This namespace has exhausted its resource capacity and you will not be able to deploy the application. Contact your administrator to expand the capacity of the - namespace. -
-
-
-
- - You do not have access to any namespace. Contact your administrator to get access to a namespace. -
-
+ -
-
- - Portainer can automatically bundle multiple applications inside a stack. Enter a name of a new stack or select an existing stack in the list. Leave empty to use - the application name. -
-
- -
- -
- -
-
+ @@ -263,89 +183,21 @@ - - -
- -
-

- Portainer no longer supports docker-compose format manifests for Kubernetes - deployments, and we have removed the Kompose - conversion tool which enables this. The reason for this is because Kompose now poses a security risk, since it has a number of Common Vulnerabilities and - Exposures (CVEs). -

-

- Unfortunately, while the Kompose project has a maintainer and is part of the CNCF, it is not being actively maintained. Releases are very infrequent and new - pull requests to the project (including ones we've submitted) are taking months to be merged, with new CVEs arising in the meantime. -

-

- We advise installing your own instance of Kompose in a sandbox environment, performing conversions of your Docker Compose files to Kubernetes manifests and - using those manifests to set up applications. -

-
-
- -

- - This feature allows you to deploy any kind of Kubernetes resource in this environment (Deployment, Secret, ConfigMap...). -

-

- You can get more information about Kubernetes file format in the - official documentation. -

-
-
-
+ is-compose-format="ctrl.stack.IsComposeFormat" + >
-
- -
- -
-
-
-
-
 
-
-

This field is required.

-

- - This field must consist of lower case alphanumeric characters or '-', contain at most 63 characters, start with an alphabetic character, and end with an - alphanumeric character (e.g. 'my-name', or 'abc-123'). -

-
-
-

- - An application with the same name already exists inside the selected namespace. -

-
-
-
+ @@ -356,7 +208,7 @@ ng-if="ctrl.formValues.ResourcePool" auto-complete="false" label-class="col-sm-3 col-lg-2" - input-class="col-sm-8" + input-class="col-sm-9 col-lg-10" namespace="ctrl.formValues.ResourcePool.Namespace.Name" endpoint="ctrl.endpoint" is-admin="ctrl.isAdmin" @@ -373,29 +225,13 @@
-
-
- - Portainer can automatically bundle multiple applications inside a stack. Enter a name of a new stack or select an existing stack in the list. Leave empty to - use the application name. -
-
- -
- -
- -
-
+ diff --git a/app/kubernetes/views/applications/create/createApplicationController.js b/app/kubernetes/views/applications/create/createApplicationController.js index 1f7b05ffd..f0a268d3f 100644 --- a/app/kubernetes/views/applications/create/createApplicationController.js +++ b/app/kubernetes/views/applications/create/createApplicationController.js @@ -151,6 +151,7 @@ class KubernetesCreateApplicationController { this.getAppType = this.getAppType.bind(this); this.showDataAccessPolicySection = this.showDataAccessPolicySection.bind(this); this.refreshReactComponent = this.refreshReactComponent.bind(this); + this.onChangeNamespaceName = this.onChangeNamespaceName.bind(this); this.$scope.$watch( () => this.formValues, @@ -168,6 +169,15 @@ class KubernetesCreateApplicationController { this.$timeout(() => { this.isTemporaryRefresh = false; }, 10); + this.onChangeStackName = this.onChangeStackName.bind(this); + this.onChangeAppName = this.onChangeAppName.bind(this); + } + /* #endregion */ + + onChangeStackName(stackName) { + return this.$async(async () => { + this.formValues.StackName = stackName; + }); } onChangePlacements(values) { @@ -254,21 +264,16 @@ class KubernetesCreateApplicationController { } imageValidityIsValid() { - return this.state.pullImageValidity || this.formValues.ImageModel.Registry.Type !== RegistryTypes.DOCKERHUB; + return this.state.pullImageValidity || (this.formValues.registryDetails && this.formValues.registryDetails.Registry.Type !== RegistryTypes.DOCKERHUB); } - onChangeName() { - const existingApplication = _.find(this.applications, { Name: this.formValues.Name }); - this.state.alreadyExists = (this.state.isEdit && existingApplication && this.application.Id !== existingApplication.Id) || (!this.state.isEdit && existingApplication); + onChangeAppName(appName) { + return this.$async(async () => { + this.formValues.Name = appName; + }); } /* #region AUTO SCALER UI MANAGEMENT */ - unselectAutoScaler() { - if (this.formValues.DeploymentType === this.ApplicationDeploymentTypes.Global) { - this.formValues.AutoScaler.isUsed = false; - } - } - onAutoScaleChange(values) { return this.$async(async () => { if (!this.formValues.AutoScaler.isUsed && values.isUsed) { @@ -295,32 +300,6 @@ class KubernetesCreateApplicationController { clearConfigMaps() { this.formValues.ConfigMaps = []; } - - onChangeConfigMapPath() { - this.state.duplicates.configMapPaths.refs = []; - - const paths = _.reduce( - this.formValues.ConfigMaps, - (result, config) => { - const uniqOverridenKeysPath = _.uniq(_.map(config.overridenKeys, 'path')); - return _.concat(result, uniqOverridenKeysPath); - }, - [] - ); - - const duplicatePaths = KubernetesFormValidationHelper.getDuplicates(paths); - - _.forEach(this.formValues.ConfigMaps, (config, index) => { - _.forEach(config.overridenKeys, (overridenKey, keyIndex) => { - const findPath = _.find(duplicatePaths, (path) => path === overridenKey.path); - if (findPath) { - this.state.duplicates.configMapPaths.refs[index + '_' + keyIndex] = findPath; - } - }); - }); - - this.state.duplicates.configMapPaths.hasRefs = Object.keys(this.state.duplicates.configMapPaths.refs).length > 0; - } /* #endregion */ /* #region SECRET UI MANAGEMENT */ @@ -421,7 +400,6 @@ class KubernetesCreateApplicationController { /* #region STATE VALIDATION FUNCTIONS */ isValid() { return ( - !this.state.alreadyExists && !this.state.duplicates.environmentVariables.hasRefs && !this.state.duplicates.persistedFolders.hasRefs && !this.state.duplicates.configMapPaths.hasRefs && @@ -434,10 +412,6 @@ class KubernetesCreateApplicationController { return this.storageClasses && this.storageClasses.length > 0; } - hasMultipleStorageClassesAvailable() { - return this.storageClasses && this.storageClasses.length > 1; - } - resetDeploymentType() { this.formValues.DeploymentType = this.ApplicationDeploymentTypes.Replicated; } @@ -740,6 +714,7 @@ class KubernetesCreateApplicationController { return this.$async(async () => { try { this.applications = await this.KubernetesApplicationService.get(namespace); + this.applicationNames = _.map(this.applications, 'Name'); } catch (err) { this.Notifications.error('Failure', err, 'Unable to retrieve applications'); } @@ -796,7 +771,6 @@ class KubernetesCreateApplicationController { this.refreshIngresses(namespace), this.refreshVolumes(namespace), ]); - this.onChangeName(); }); } @@ -806,13 +780,13 @@ class KubernetesCreateApplicationController { this.resetPersistedFolders(); } - onResourcePoolSelectionChange() { + onChangeNamespaceName(namespaceName) { return this.$async(async () => { - const namespaceWithQuota = await this.KubernetesResourcePoolService.get(this.formValues.ResourcePool.Namespace.Name); - const namespace = this.formValues.ResourcePool.Namespace.Name; + this.formValues.ResourcePool.Namespace.Name = namespaceName; + const namespaceWithQuota = await this.KubernetesResourcePoolService.get(namespaceName); this.updateNamespaceLimits(namespaceWithQuota); this.updateSliders(namespaceWithQuota); - await this.refreshNamespaceData(namespace); + await this.refreshNamespaceData(namespaceName); this.resetFormValues(); }); } diff --git a/app/portainer/react/components/index.ts b/app/portainer/react/components/index.ts index 09ed446bb..b617d827e 100644 --- a/app/portainer/react/components/index.ts +++ b/app/portainer/react/components/index.ts @@ -181,6 +181,7 @@ export const ngModule = angular 'isClearable', 'components', 'isLoading', + 'noOptionsMessage', ]) ) .component( diff --git a/app/react-tools/withFormValidation.ts b/app/react-tools/withFormValidation.ts index 71af58d35..b2ea4f903 100644 --- a/app/react-tools/withFormValidation.ts +++ b/app/react-tools/withFormValidation.ts @@ -1,6 +1,6 @@ import { IFormController, IComponentOptions, IModule } from 'angular'; import { FormikErrors } from 'formik'; -import { SchemaOf } from 'yup'; +import { SchemaOf, object } from 'yup'; import _ from 'lodash'; import { ComponentType } from 'react'; @@ -40,7 +40,8 @@ export function withFormValidation( Component: ComponentType>, componentName: string, propNames: PropNames[], - schemaBuilder: (validationData?: TData) => SchemaOf + schemaBuilder: (validationData?: TData) => SchemaOf, + isPrimitive = false ) { const reactComponentName = `react${_.upperFirst(componentName)}`; @@ -54,7 +55,8 @@ export function withFormValidation( createFormValidationComponent( reactComponentName, propNames, - schemaBuilder + schemaBuilder, + isPrimitive ) ); } @@ -62,7 +64,8 @@ export function withFormValidation( export function createFormValidationComponent( componentName: string, propNames: Array, - schemaBuilder: (validationData?: TData) => SchemaOf + schemaBuilder: (validationData?: TData) => SchemaOf, + isPrimitive = false ): IComponentOptions { const kebabName = _.kebabCase(componentName); const propsWithErrors = [...propNames, 'errors', 'values']; @@ -76,7 +79,7 @@ export function createFormValidationComponent( on-change="($ctrl.handleChange)" > `, - controller: createFormValidatorController(schemaBuilder), + controller: createFormValidatorController(schemaBuilder, isPrimitive), bindings: Object.fromEntries( [...propsWithErrors, 'validationData', 'onChange'].map((p) => [p, '<']) ), @@ -84,10 +87,11 @@ export function createFormValidationComponent( } function createFormValidatorController( - schemaBuilder: (validationData?: TData) => SchemaOf + schemaBuilder: (validationData?: TData) => SchemaOf, + isPrimitive = false ) { return class FormValidatorController { - errors?: FormikErrors = {}; + errors?: FormikErrors; $async: (fn: () => Promise) => Promise; @@ -118,12 +122,17 @@ function createFormValidatorController( 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) { + const schema = schemaBuilder(this.validationData); + this.errors = undefined; + const errors = await (isPrimitive + ? validateForm<{ value: TFormModel }>( + () => object({ value: schema }), + { value } + ).then((r) => r?.value) + : validateForm(() => schema, value)); + + if (errors && Object.keys(errors).length > 0) { + this.errors = errors as FormikErrors | undefined; this.form?.$setValidity('form', false, this.form); } }); diff --git a/app/react/components/WebEditorForm.tsx b/app/react/components/WebEditorForm.tsx index ab8880d05..140cd9788 100644 --- a/app/react/components/WebEditorForm.tsx +++ b/app/react/components/WebEditorForm.tsx @@ -15,7 +15,7 @@ import { buildConfirmButton } from './modals/utils'; const otherEditorConfig = { tooltip: ( <> -
Ctrl+F - Start searching
+
CtrlF - Start searching
Ctrl+G - Find next
Ctrl+Shift+G - Find previous
Ctrl+Shift+F - Replace
@@ -29,7 +29,7 @@ const otherEditorConfig = { searchCmdLabel: 'Ctrl+F for search', } as const; -const editorConfig = { +export const editorConfig = { mac: { tooltip: ( <> @@ -59,6 +59,7 @@ interface Props { placeholder?: string; yaml?: boolean; readonly?: boolean; + titleContent?: React.ReactNode; hideTitle?: boolean; error?: string; height?: string; @@ -69,6 +70,7 @@ export function WebEditorForm({ onChange, placeholder, value, + titleContent = '', hideTitle, readonly, yaml, @@ -80,16 +82,11 @@ export function WebEditorForm({
{!hideTitle && ( - - Web editor -
- {editorConfig[BROWSER_OS_PLATFORM].searchCmdLabel} - - -
-
+ <> + + {titleContent ?? null} + )} - {children && (
{children}
@@ -116,6 +113,19 @@ export function WebEditorForm({ ); } +function DefaultTitle({ id }: { id: string }) { + return ( + + Web editor +
+ {editorConfig[BROWSER_OS_PLATFORM].searchCmdLabel} + + +
+
+ ); +} + export function usePreventExit( initialValue: string, value: string, diff --git a/app/react/components/form-components/FormControl/FormControl.tsx b/app/react/components/form-components/FormControl/FormControl.tsx index 36e362b86..5f4c8727a 100644 --- a/app/react/components/form-components/FormControl/FormControl.tsx +++ b/app/react/components/form-components/FormControl/FormControl.tsx @@ -2,6 +2,7 @@ import { ComponentProps, PropsWithChildren, ReactNode } from 'react'; import clsx from 'clsx'; import { Tooltip } from '@@/Tip/Tooltip'; +import { InlineLoader } from '@@/InlineLoader'; import { FormError } from '../FormError'; @@ -17,6 +18,8 @@ export interface Props { errors?: ReactNode; required?: boolean; className?: string; + isLoading?: boolean; // whether to show an inline loader, instead of the children + loadingText?: ReactNode; // text to show when isLoading is true } export function FormControl({ @@ -29,6 +32,8 @@ export function FormControl({ className, required = false, setTooltipHtmlMessage, + isLoading = false, + loadingText = 'Loading...', }: PropsWithChildren) { return (
- {children} + {isLoading && {loadingText}} + {!isLoading && children} {errors && {errors}}
diff --git a/app/react/components/form-components/PortainerSelect.tsx b/app/react/components/form-components/PortainerSelect.tsx index 156cc33da..81105652b 100644 --- a/app/react/components/form-components/PortainerSelect.tsx +++ b/app/react/components/form-components/PortainerSelect.tsx @@ -28,6 +28,7 @@ interface SharedProps extends AutomationTestingProps { isClearable?: boolean; bindToBody?: boolean; isLoading?: boolean; + noOptionsMessage?: () => string; } interface MultiProps extends SharedProps { @@ -85,6 +86,7 @@ export function SingleSelect({ bindToBody, components, isLoading, + noOptionsMessage, }: SingleProps) { const selectedValue = value || (typeof value === 'number' && value === 0) @@ -108,6 +110,7 @@ export function SingleSelect({ menuPortalTarget={bindToBody ? document.body : undefined} components={components} isLoading={isLoading} + noOptionsMessage={noOptionsMessage} /> ); } @@ -148,6 +151,7 @@ export function MultiSelect({ bindToBody, components, isLoading, + noOptionsMessage, }: Omit, 'isMulti'>) { const selectedOptions = findSelectedOptions(options, value); return ( @@ -169,6 +173,7 @@ export function MultiSelect({ menuPortalTarget={bindToBody ? document.body : undefined} components={components} isLoading={isLoading} + noOptionsMessage={noOptionsMessage} /> ); } diff --git a/app/react/kubernetes/DeployView/StackName/StackName.tsx b/app/react/kubernetes/DeployView/StackName/StackName.tsx index e6679962d..3d4e88c40 100644 --- a/app/react/kubernetes/DeployView/StackName/StackName.tsx +++ b/app/react/kubernetes/DeployView/StackName/StackName.tsx @@ -1,15 +1,31 @@ +import { useMemo } from 'react'; + +import { useCurrentUser } from '@/react/hooks/useUser'; + import { InsightsBox } from '@@/InsightsBox'; import { Link } from '@@/Link'; import { TextTip } from '@@/Tip/TextTip'; import { Tooltip } from '@@/Tip/Tooltip'; +import { AutocompleteSelect } from '@@/form-components/AutocompleteSelect'; type Props = { stackName: string; setStackName: (name: string) => void; - isAdmin?: boolean; + stacks?: string[]; + inputClassName?: string; }; -export function StackName({ stackName, setStackName, isAdmin = false }: Props) { +export function StackName({ + stackName, + setStackName, + stacks = [], + inputClassName, +}: Props) { + const { isAdmin } = useCurrentUser(); + const stackResults = useMemo( + () => stacks.filter((stack) => stack.includes(stackName ?? '')), + [stacks, stackName] + ); const tooltip = ( <> You may specify a stack name to label resources that you want to group. @@ -68,14 +84,16 @@ export function StackName({ stackName, setStackName, isAdmin = false }: Props) { Stack -
- setStackName(e.target.value)} - id="stack_name" - placeholder="myStack" +
+ ({ + value: result, + label: result, + }))} + value={stackName ?? ''} + onChange={setStackName} + placeholder="e.g. myStack" + inputId="stack_name" />
diff --git a/app/react/kubernetes/applications/components/EditYamlFormSection.tsx b/app/react/kubernetes/applications/components/EditYamlFormSection.tsx new file mode 100644 index 000000000..e3800a5a4 --- /dev/null +++ b/app/react/kubernetes/applications/components/EditYamlFormSection.tsx @@ -0,0 +1,102 @@ +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { useEnvironmentDeploymentOptions } from '@/react/portainer/environments/queries/useEnvironment'; +import { useAuthorizations } from '@/react/hooks/useUser'; + +import { WebEditorForm } from '@@/WebEditorForm'; +import { TextTip } from '@@/Tip/TextTip'; + +type StackFileContent = string; + +type Props = { + values: StackFileContent; + onChange: (values: StackFileContent) => void; + isComposeFormat?: boolean; +}; + +export function EditYamlFormSection({ + values, + onChange, + isComposeFormat, +}: Props) { + // check if the user is allowed to edit the yaml + const environmentId = useEnvironmentId(); + const { data: deploymentOptions } = + useEnvironmentDeploymentOptions(environmentId); + const roleHasAuth = useAuthorizations('K8sYAMLW'); + const isAllowedToEdit = roleHasAuth && !deploymentOptions?.hideWebEditor; + const formId = 'kubernetes-deploy-editor'; + + return ( +
+ } + onChange={(values) => onChange(values)} + id={formId} + placeholder="Define or paste the content of your manifest file here" + yaml + /> +
+ ); +} + +function TitleContent({ isComposeFormat }: { isComposeFormat?: boolean }) { + return ( + <> + {isComposeFormat && ( + +

+ Portainer no longer supports{' '} + + docker-compose + {' '} + format manifests for Kubernetes deployments, and we have removed the{' '} + + Kompose + {' '} + conversion tool which enables this. The reason for this is because + Kompose now poses a security risk, since it has a number of Common + Vulnerabilities and Exposures (CVEs). +

+

+ Unfortunately, while the Kompose project has a maintainer and is + part of the CNCF, it is not being actively maintained. Releases are + very infrequent and new pull requests to the project (including ones + we've submitted) are taking months to be merged, with new CVEs + arising in the meantime. +

+

+ We advise installing your own instance of Kompose in a sandbox + environment, performing conversions of your Docker Compose files to + Kubernetes manifests and using those manifests to set up + applications. +

+
+ )} + {!isComposeFormat && ( + +

+ This feature allows you to deploy any kind of Kubernetes resource in + this environment (Deployment, Secret, ConfigMap...). +

+

+ You can get more information about Kubernetes file format in the{' '} + + official documentation + + . +

+
+ )} + + ); +} diff --git a/app/react/kubernetes/applications/components/NameFormSection/NameFormSection.tsx b/app/react/kubernetes/applications/components/NameFormSection/NameFormSection.tsx new file mode 100644 index 000000000..99c7ba738 --- /dev/null +++ b/app/react/kubernetes/applications/components/NameFormSection/NameFormSection.tsx @@ -0,0 +1,38 @@ +import { FormikErrors } from 'formik'; + +import { FormControl } from '@@/form-components/FormControl'; +import { Input } from '@@/form-components/Input'; + +type Props = { + onChange: (value: string) => void; + values: string; + errors: FormikErrors; + isEdit: boolean; +}; + +export function NameFormSection({ + onChange, + values: appName, + errors, + isEdit, +}: Props) { + return ( + + onChange(e.target.value)} + autoFocus + placeholder="e.g. my-app" + disabled={isEdit} + id="application_name" + data-cy="k8sAppCreate-applicationName" + /> + + ); +} diff --git a/app/react/kubernetes/applications/components/NameFormSection/index.ts b/app/react/kubernetes/applications/components/NameFormSection/index.ts new file mode 100644 index 000000000..31c75800d --- /dev/null +++ b/app/react/kubernetes/applications/components/NameFormSection/index.ts @@ -0,0 +1,2 @@ +export { NameFormSection } from './NameFormSection'; +export { appNameValidation } from './nameValidation'; diff --git a/app/react/kubernetes/applications/components/NameFormSection/nameValidation.ts b/app/react/kubernetes/applications/components/NameFormSection/nameValidation.ts new file mode 100644 index 000000000..be9811ea4 --- /dev/null +++ b/app/react/kubernetes/applications/components/NameFormSection/nameValidation.ts @@ -0,0 +1,43 @@ +import { SchemaOf, string as yupString } from 'yup'; + +type ValidationData = { + existingNames: string[]; + isEdit: boolean; + originalName?: string; +}; + +export function appNameValidation( + validationData?: ValidationData +): SchemaOf { + return yupString() + .required('This field is required.') + .test( + 'is-unique', + 'An application with the same name already exists inside the selected namespace.', + (appName) => { + if (!validationData || !appName) { + return true; + } + // if creating, check if the name is unique + if (!validationData.isEdit) { + return !validationData.existingNames.includes(appName); + } + // if editing, the original name will be in the list of existing names + // remove it before checking if the name is unique + const updatedExistingNames = validationData.existingNames.filter( + (name) => name !== validationData.originalName + ); + return !updatedExistingNames.includes(appName); + } + ) + .test( + 'is-valid', + "This field must consist of lower case alphanumeric characters or '-', contain at most 63 characters, start with an alphabetic character, and end with an alphanumeric character (e.g. 'my-name', or 'abc-123').", + (appName) => { + if (!appName) { + return true; + } + return /^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$/g.test(appName); + } + ); +} diff --git a/app/react/kubernetes/applications/components/NamespaceSelector/NamespaceSelector.tsx b/app/react/kubernetes/applications/components/NamespaceSelector/NamespaceSelector.tsx new file mode 100644 index 000000000..4e241e15a --- /dev/null +++ b/app/react/kubernetes/applications/components/NamespaceSelector/NamespaceSelector.tsx @@ -0,0 +1,53 @@ +import { FormikErrors } from 'formik'; + +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { useNamespacesQuery } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery'; + +import { FormControl } from '@@/form-components/FormControl'; +import { PortainerSelect } from '@@/form-components/PortainerSelect'; + +type Props = { + onChange: (value: string) => void; + values: string; + errors: FormikErrors; + isEdit: boolean; +}; + +export function NamespaceSelector({ + values: value, + onChange, + errors, + isEdit, +}: Props) { + const environmentId = useEnvironmentId(); + const { data: namespaces, ...namespacesQuery } = + useNamespacesQuery(environmentId); + const namespaceNames = Object.entries(namespaces ?? {}) + .filter(([, ns]) => !ns.IsSystem) + .map(([nsName]) => ({ + label: nsName, + value: nsName, + })); + + return ( + + {namespaceNames.length > 0 && ( + 'No namespaces found'} + placeholder="No namespaces found" // will only show when there are no options + inputId="namespace-selector" + data-cy="k8sAppCreate-nsSelect" + /> + )} + + ); +} diff --git a/app/react/kubernetes/applications/components/NamespaceSelector/index.ts b/app/react/kubernetes/applications/components/NamespaceSelector/index.ts new file mode 100644 index 000000000..574ce18fb --- /dev/null +++ b/app/react/kubernetes/applications/components/NamespaceSelector/index.ts @@ -0,0 +1,2 @@ +export { NamespaceSelector } from './NamespaceSelector'; +export { namespaceSelectorValidation } from './namespaceSelectorValidation'; diff --git a/app/react/kubernetes/applications/components/NamespaceSelector/namespaceSelectorValidation.ts b/app/react/kubernetes/applications/components/NamespaceSelector/namespaceSelectorValidation.ts new file mode 100644 index 000000000..222a50c1f --- /dev/null +++ b/app/react/kubernetes/applications/components/NamespaceSelector/namespaceSelectorValidation.ts @@ -0,0 +1,38 @@ +import { SchemaOf, string } from 'yup'; + +type ValidationData = { + hasQuota: boolean; + isResourceQuotaCapacityExceeded: boolean; + namespaceOptionCount: number; + isAdmin: boolean; +}; + +const emptyValue = + 'You do not have access to any namespace. Contact your administrator to get access to a namespace.'; + +export function namespaceSelectorValidation( + validationData?: ValidationData +): SchemaOf { + const { + hasQuota, + isResourceQuotaCapacityExceeded, + namespaceOptionCount, + isAdmin, + } = validationData || {}; + return string() + .required(emptyValue) + .typeError(emptyValue) + .test( + 'resourceQuotaCapacityExceeded', + `This namespace has exhausted its resource capacity and you will not be able to deploy the application.${ + isAdmin + ? '' + : ' Contact your administrator to expand the capacity of the namespace.' + }`, + () => { + const hasQuotaExceeded = hasQuota && isResourceQuotaCapacityExceeded; + return !hasQuotaExceeded; + } + ) + .test('namespaceOptionCount', emptyValue, () => !!namespaceOptionCount); +} diff --git a/app/react/kubernetes/components/YAMLInspector.tsx b/app/react/kubernetes/components/YAMLInspector.tsx index 18a8c0da4..3f8df9095 100644 --- a/app/react/kubernetes/components/YAMLInspector.tsx +++ b/app/react/kubernetes/components/YAMLInspector.tsx @@ -54,7 +54,7 @@ export function YAMLInspector({ identifier, data, hideMessage }: Props) { ); } -function cleanYamlUnwantedFields(yml: string) { +export function cleanYamlUnwantedFields(yml: string) { try { const ymls = yml.split('---'); const cleanYmls = ymls.map((yml) => { diff --git a/app/react/portainer/registries/queries/useRegistry.ts b/app/react/portainer/registries/queries/useRegistry.ts index 779b8a85f..053888194 100644 --- a/app/react/portainer/registries/queries/useRegistry.ts +++ b/app/react/portainer/registries/queries/useRegistry.ts @@ -29,6 +29,6 @@ async function getRegistry(registryId: Registry['Id'], environmentId: number) { }); return data; } catch (err) { - throw parseAxiosError(err as Error, 'XXXUnable to retrieve registry'); + throw parseAxiosError(err as Error, 'Unable to retrieve registry'); } }