mirror of
https://github.com/portainer/portainer.git
synced 2025-08-05 13:55:21 +02:00
refactor(app): migrate remaining form sections [EE-6231] (#10938)
Some checks are pending
ci / build_images (map[arch:amd64 platform:linux]) (push) Waiting to run
ci / build_images (map[arch:amd64 platform:windows version:1809]) (push) Waiting to run
ci / build_images (map[arch:amd64 platform:windows version:ltsc2022]) (push) Waiting to run
ci / build_images (map[arch:arm64 platform:linux]) (push) Waiting to run
ci / build_manifests (push) Blocked by required conditions
/ triage (push) Waiting to run
Lint / Run linters (push) Waiting to run
Test / test-client (push) Waiting to run
Test / test-server (map[arch:amd64 platform:linux]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:1809]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:ltsc2022]) (push) Waiting to run
Test / test-server (map[arch:arm64 platform:linux]) (push) Waiting to run
Some checks are pending
ci / build_images (map[arch:amd64 platform:linux]) (push) Waiting to run
ci / build_images (map[arch:amd64 platform:windows version:1809]) (push) Waiting to run
ci / build_images (map[arch:amd64 platform:windows version:ltsc2022]) (push) Waiting to run
ci / build_images (map[arch:arm64 platform:linux]) (push) Waiting to run
ci / build_manifests (push) Blocked by required conditions
/ triage (push) Waiting to run
Lint / Run linters (push) Waiting to run
Test / test-client (push) Waiting to run
Test / test-server (map[arch:amd64 platform:linux]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:1809]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:ltsc2022]) (push) Waiting to run
Test / test-server (map[arch:arm64 platform:linux]) (push) Waiting to run
This commit is contained in:
parent
0b9cebc685
commit
4e7d1c7088
18 changed files with 456 additions and 284 deletions
|
@ -15,7 +15,7 @@ import { buildConfirmButton } from './modals/utils';
|
|||
const otherEditorConfig = {
|
||||
tooltip: (
|
||||
<>
|
||||
<div>Ctrl+F - Start searching</div>
|
||||
<div>CtrlF - Start searching</div>
|
||||
<div>Ctrl+G - Find next</div>
|
||||
<div>Ctrl+Shift+G - Find previous</div>
|
||||
<div>Ctrl+Shift+F - Replace</div>
|
||||
|
@ -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({
|
|||
<div>
|
||||
<div className="web-editor overflow-x-hidden">
|
||||
{!hideTitle && (
|
||||
<FormSectionTitle htmlFor={id}>
|
||||
Web editor
|
||||
<div className="text-muted small vertical-center ml-auto">
|
||||
{editorConfig[BROWSER_OS_PLATFORM].searchCmdLabel}
|
||||
|
||||
<Tooltip message={editorConfig[BROWSER_OS_PLATFORM].tooltip} />
|
||||
</div>
|
||||
</FormSectionTitle>
|
||||
<>
|
||||
<DefaultTitle id={id} />
|
||||
{titleContent ?? null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{children && (
|
||||
<div className="form-group text-muted small">
|
||||
<div className="col-sm-12 col-lg-12">{children}</div>
|
||||
|
@ -116,6 +113,19 @@ export function WebEditorForm({
|
|||
);
|
||||
}
|
||||
|
||||
function DefaultTitle({ id }: { id: string }) {
|
||||
return (
|
||||
<FormSectionTitle htmlFor={id}>
|
||||
Web editor
|
||||
<div className="text-muted small vertical-center ml-auto">
|
||||
{editorConfig[BROWSER_OS_PLATFORM].searchCmdLabel}
|
||||
|
||||
<Tooltip message={editorConfig[BROWSER_OS_PLATFORM].tooltip} />
|
||||
</div>
|
||||
</FormSectionTitle>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePreventExit(
|
||||
initialValue: string,
|
||||
value: string,
|
||||
|
|
|
@ -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<Props>) {
|
||||
return (
|
||||
<div
|
||||
|
@ -52,7 +57,8 @@ export function FormControl({
|
|||
</label>
|
||||
|
||||
<div className={sizeClassChildren(size)}>
|
||||
{children}
|
||||
{isLoading && <InlineLoader>{loadingText}</InlineLoader>}
|
||||
{!isLoading && children}
|
||||
{errors && <FormError>{errors}</FormError>}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -28,6 +28,7 @@ interface SharedProps extends AutomationTestingProps {
|
|||
isClearable?: boolean;
|
||||
bindToBody?: boolean;
|
||||
isLoading?: boolean;
|
||||
noOptionsMessage?: () => string;
|
||||
}
|
||||
|
||||
interface MultiProps<TValue> extends SharedProps {
|
||||
|
@ -85,6 +86,7 @@ export function SingleSelect<TValue = string>({
|
|||
bindToBody,
|
||||
components,
|
||||
isLoading,
|
||||
noOptionsMessage,
|
||||
}: SingleProps<TValue>) {
|
||||
const selectedValue =
|
||||
value || (typeof value === 'number' && value === 0)
|
||||
|
@ -108,6 +110,7 @@ export function SingleSelect<TValue = string>({
|
|||
menuPortalTarget={bindToBody ? document.body : undefined}
|
||||
components={components}
|
||||
isLoading={isLoading}
|
||||
noOptionsMessage={noOptionsMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -148,6 +151,7 @@ export function MultiSelect<TValue = string>({
|
|||
bindToBody,
|
||||
components,
|
||||
isLoading,
|
||||
noOptionsMessage,
|
||||
}: Omit<MultiProps<TValue>, 'isMulti'>) {
|
||||
const selectedOptions = findSelectedOptions(options, value);
|
||||
return (
|
||||
|
@ -169,6 +173,7 @@ export function MultiSelect<TValue = string>({
|
|||
menuPortalTarget={bindToBody ? document.body : undefined}
|
||||
components={components}
|
||||
isLoading={isLoading}
|
||||
noOptionsMessage={noOptionsMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
<Tooltip message={tooltip} setHtmlMessage />
|
||||
</label>
|
||||
<div className="col-sm-8">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
defaultValue={stackName}
|
||||
onChange={(e) => setStackName(e.target.value)}
|
||||
id="stack_name"
|
||||
placeholder="myStack"
|
||||
<div className={inputClassName || 'col-sm-8'}>
|
||||
<AutocompleteSelect
|
||||
searchResults={stackResults?.map((result) => ({
|
||||
value: result,
|
||||
label: result,
|
||||
}))}
|
||||
value={stackName ?? ''}
|
||||
onChange={setStackName}
|
||||
placeholder="e.g. myStack"
|
||||
inputId="stack_name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -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 (
|
||||
<div>
|
||||
<WebEditorForm
|
||||
value={values}
|
||||
readonly={!isAllowedToEdit}
|
||||
titleContent={<TitleContent isComposeFormat={isComposeFormat} />}
|
||||
onChange={(values) => onChange(values)}
|
||||
id={formId}
|
||||
placeholder="Define or paste the content of your manifest file here"
|
||||
yaml
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TitleContent({ isComposeFormat }: { isComposeFormat?: boolean }) {
|
||||
return (
|
||||
<>
|
||||
{isComposeFormat && (
|
||||
<TextTip color="orange">
|
||||
<p>
|
||||
Portainer no longer supports{' '}
|
||||
<a
|
||||
href="https://docs.docker.com/compose/compose-file/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
docker-compose
|
||||
</a>{' '}
|
||||
format manifests for Kubernetes deployments, and we have removed the{' '}
|
||||
<a href="https://kompose.io/" target="_blank" rel="noreferrer">
|
||||
Kompose
|
||||
</a>{' '}
|
||||
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).
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</TextTip>
|
||||
)}
|
||||
{!isComposeFormat && (
|
||||
<TextTip color="blue">
|
||||
<p>
|
||||
This feature allows you to deploy any kind of Kubernetes resource in
|
||||
this environment (Deployment, Secret, ConfigMap...).
|
||||
</p>
|
||||
<p>
|
||||
You can get more information about Kubernetes file format in the{' '}
|
||||
<a
|
||||
href="https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
official documentation
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</TextTip>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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<string>;
|
||||
isEdit: boolean;
|
||||
};
|
||||
|
||||
export function NameFormSection({
|
||||
onChange,
|
||||
values: appName,
|
||||
errors,
|
||||
isEdit,
|
||||
}: Props) {
|
||||
return (
|
||||
<FormControl
|
||||
label="Name"
|
||||
inputId="application_name"
|
||||
errors={errors}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={appName ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
autoFocus
|
||||
placeholder="e.g. my-app"
|
||||
disabled={isEdit}
|
||||
id="application_name"
|
||||
data-cy="k8sAppCreate-applicationName"
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
export { NameFormSection } from './NameFormSection';
|
||||
export { appNameValidation } from './nameValidation';
|
|
@ -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<string> {
|
||||
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);
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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<string>;
|
||||
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 (
|
||||
<FormControl
|
||||
label="Namespace"
|
||||
inputId="namespace-selector"
|
||||
isLoading={namespacesQuery.isLoading}
|
||||
errors={errors}
|
||||
>
|
||||
{namespaceNames.length > 0 && (
|
||||
<PortainerSelect
|
||||
value={value}
|
||||
options={namespaceNames}
|
||||
onChange={onChange}
|
||||
disabled={isEdit}
|
||||
noOptionsMessage={() => 'No namespaces found'}
|
||||
placeholder="No namespaces found" // will only show when there are no options
|
||||
inputId="namespace-selector"
|
||||
data-cy="k8sAppCreate-nsSelect"
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
export { NamespaceSelector } from './NamespaceSelector';
|
||||
export { namespaceSelectorValidation } from './namespaceSelectorValidation';
|
|
@ -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<string> {
|
||||
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);
|
||||
}
|
|
@ -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) => {
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue