Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Execution Environment add or edit #1054

Merged
merged 37 commits into from
Nov 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
2d4515b
Basic skeleton
MilanPospisil Oct 17, 2023
3aac95f
Load
MilanPospisil Oct 17, 2023
a0b7e0c
Name and upstream name and validation
MilanPospisil Oct 17, 2023
5265de4
Description and shape object
MilanPospisil Oct 17, 2023
1908d47
first version of submit
MilanPospisil Oct 17, 2023
56abb40
Add works
MilanPospisil Oct 17, 2023
a0678fc
WIP - browse
MilanPospisil Oct 18, 2023
dd736a5
OnBrowse working
MilanPospisil Oct 18, 2023
ef42474
Refactor
MilanPospisil Oct 18, 2023
3d0e190
Tags
MilanPospisil Oct 18, 2023
3b7e71f
Tags2
MilanPospisil Oct 18, 2023
38a2208
Single column
MilanPospisil Oct 18, 2023
788e8eb
Determine isRemote
MilanPospisil Oct 18, 2023
ed78d7b
Editing working, TODO - edit ditro
MilanPospisil Oct 18, 2023
80f1e9e
Labels in their own line
MilanPospisil Oct 22, 2023
5eb3edd
Add tags on enter
MilanPospisil Oct 22, 2023
dc86662
Prevent default on submit enter
MilanPospisil Oct 22, 2023
5506417
Patch request for description
MilanPospisil Oct 22, 2023
6656304
Rename to formData
MilanPospisil Oct 22, 2023
95a7df2
Initial selection not working yet
MilanPospisil Oct 22, 2023
9dafb5a
Delete console.log
MilanPospisil Oct 22, 2023
f9a7295
Add local ee
MilanPospisil Oct 22, 2023
26587d8
Description in both remote/non remote
MilanPospisil Oct 22, 2023
3b3f51d
Registry finally selected in edit mode
MilanPospisil Oct 24, 2023
c2846b6
Checks
MilanPospisil Oct 24, 2023
ceda8b6
WIP
MilanPospisil Oct 27, 2023
dc81350
Remove unecessary error handling, use usePageNavigate
MilanPospisil Oct 27, 2023
eac2835
useGet for registries
MilanPospisil Oct 27, 2023
2d07950
Major rework - useGet, error handling, fix some mistakes
MilanPospisil Oct 27, 2023
45caf0a
Create EE when empty list
MilanPospisil Oct 27, 2023
e046254
Refactor promise all
MilanPospisil Oct 27, 2023
bc8e4ba
Repair mistake
MilanPospisil Oct 30, 2023
0e54b49
PR Review
MilanPospisil Oct 31, 2023
d1cea1c
Error handling and loading
MilanPospisil Oct 31, 2023
0b01e0a
Old fashion error handling back
MilanPospisil Nov 2, 2023
37c4390
Get rid of conflict
MilanPospisil Nov 14, 2023
f1dd3ec
Prettier
MilanPospisil Nov 21, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion framework/PageForm/PageForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export interface PageFormProps<T extends object> {
autoComplete?: 'on' | 'off';
footer?: ReactNode;
errorAdapter?: ErrorAdapter;
disableSubmitOnEnter?: boolean;
}

export function useFormErrors<T extends object>(
Expand All @@ -62,7 +63,6 @@ export function useFormErrors<T extends object>(
const form = useForm<T>({
defaultValues: defaultValue ?? ({} as DefaultValues<T>),
});

const { handleSubmit, setError: setFieldError } = form;
const [error, setError] = useState<(string | ReactNode)[] | string | null>(null);

Expand Down Expand Up @@ -111,6 +111,11 @@ export function PageForm<T extends object>(props: PageFormProps<T>) {
return (
<FormProvider {...form}>
<Form
onKeyDown={(event) => {
if (event.key === 'Enter' && props.disableSubmitOnEnter) {
event.preventDefault();
}
}}
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onSubmit={handleSubmit(
async (
Expand Down
2 changes: 2 additions & 0 deletions frontend/common/crud/useGet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export function useGet<T>(
swrConfiguration: SWRConfiguration = {}
) {
const getRequest = useGetRequest<T>();

url += normalizeQueryString(query);
const response = useSWR<T>(url, getRequest, {
dedupingInterval: 0,
Expand All @@ -23,6 +24,7 @@ export function useGet<T>(
if (error && !(error instanceof Error)) {
error = new Error('Unknown error');
}

return useMemo(
() => ({
data: response.data,
Expand Down
2 changes: 2 additions & 0 deletions frontend/hub/HubRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export enum HubRoute {
CollectionPage = 'hub-collection-page',

ExecutionEnvironments = 'hub-execution-environments',
EditExecutionEnvironment = 'hub-edit-execution-environment',
CreateExecutionEnvironment = 'hub-create-execution-environment',

SignatureKeys = 'hub-signature-keys',

Expand Down
343 changes: 343 additions & 0 deletions frontend/hub/execution-environments/ExecutionEnvironmentForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,343 @@
import { useTranslation } from 'react-i18next';
import { TFunction } from 'i18next';
import { useParams } from 'react-router-dom';
import {
PageFormSubmitHandler,
PageFormTextInput,
PageHeader,
PageLayout,
useGetPageUrl,
PageFormTextArea,
} from '../../../framework';
import { TagIcon } from '@patternfly/react-icons';
import { Button, InputGroup, Label, LabelGroup, TextInput } from '@patternfly/react-core';
import { PageFormGroup } from '../../../framework/PageForm/Inputs/PageFormGroup';
import { useGet } from '../../common/crud/useGet';
import { HubRoute } from '../HubRoutes';
import { hubAPI, pulpAPI } from '../api/formatPath';
import { hubAPIPost } from '../api/utils';
import { ExecutionEnvironment } from './ExecutionEnvironment';
import { HubPageForm } from '../HubPageForm';
import { HubItemsResponse } from '../useHubView';
import { useState, useCallback } from 'react';

import { patchHubRequest, putHubRequest } from './../api/request';
import { PageFormAsyncSelect } from '../../../framework/PageForm/Inputs/PageFormAsyncSelect';
import { useSelectRegistrySingle } from './hooks/useRegistrySelector';
import { usePageNavigate } from '../../../framework';

import { LoadingPage } from '../../../framework/components/LoadingPage';
import { AwxError } from '../../awx/common/AwxError';

export function CreateExecutionEnvironment() {
return <ExecutionEnvironmentForm mode="add" />;
}

export function EditExecutionEnvironment() {
return <ExecutionEnvironmentForm mode="edit" />;
}

function ExecutionEnvironmentForm(props: { mode: 'add' | 'edit' }) {
const page_size = 50;
const { t } = useTranslation();
const navigate = usePageNavigate();
const getPageUrl = useGetPageUrl();
const mode = props.mode;
const params = useParams<{ id?: string }>();

const [tagsToInclude, setTagsToInclude] = useState<string[]>([]);
const [tagsToExclude, setTagsToExclude] = useState<string[]>([]);
const [tagsSet, setTagsSet] = useState<boolean>(false);

const registry = useGet<HubItemsResponse<Registry>>(
hubAPI`/_ui/v1/execution-environments/registries?page_size=${page_size.toString()}`
MilanPospisil marked this conversation as resolved.
Show resolved Hide resolved
);

const eeUrl =
mode == 'edit' && params?.id
? hubAPI`/v3/plugin/execution-environments/repositories/${params?.id}/`
: '';

const executionEnvironment = useGet<ExecutionEnvironment>(eeUrl);

const singleRegistryUrl =
mode == 'edit' &&
executionEnvironment.data &&
executionEnvironment.data?.pulp?.repository?.remote?.registry
? hubAPI`/_ui/v1/execution-environments/registries/${executionEnvironment.data?.pulp?.repository?.remote?.registry}/`
: '';

const singleRegistry = useGet<Registry>(singleRegistryUrl);

const isLoading = (!executionEnvironment.data || !singleRegistry.data) && mode == 'edit';

if (mode == 'edit' && !tagsSet && isLoading == false) {
setTagsSet(true);
setTagsToExclude(executionEnvironment.data?.pulp?.repository?.remote?.exclude_tags || []);
setTagsToInclude(executionEnvironment.data?.pulp?.repository?.remote?.include_tags || []);
}

const selectRegistrySingle = useSelectRegistrySingle();
const registrySelector = selectRegistrySingle.onBrowse;

const isNew = !executionEnvironment.data?.pulp?.repository;
const isRemote = executionEnvironment.data?.pulp?.repository
? !!executionEnvironment.data?.pulp?.repository?.remote
: true;

const query = useCallback(() => {
return Promise.resolve({
total: registry?.data?.meta?.count || 0,
values: registry?.data?.data || [],
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [registry.data]);

const onSubmit: PageFormSubmitHandler<ExecutionEnvironmentFormProps> = async (
formData: ExecutionEnvironmentFormProps
) => {
const payload: PayloadDataType = {
exclude_tags: tagsToExclude,
include_tags: tagsToInclude,
name: formData.name,
upstream_name: formData.upstream_name,
registry: formData.registry?.id || '',
};

if (isRemote && isNew) {
await hubAPIPost<ExecutionEnvironmentFormProps>(
hubAPI`/_ui/v1/execution-environments/remotes/`,
payload
);
} else {
const promises = [];

if (isRemote && !isNew) {
promises.push(
putHubRequest(
hubAPI`/_ui/v1/execution-environments/remotes/${
executionEnvironment.data?.pulp?.repository?.remote?.id || ''
}/`,
payload
)
);
}

if (formData.description !== executionEnvironment.data?.description) {
promises.push(
patchHubRequest(
pulpAPI`/distributions/container/container/${
executionEnvironment.data?.pulp?.distribution?.id || ''
}/`,
{ description: formData.description }
)
);
}
await Promise.all([promises]);
}

navigate(HubRoute.ExecutionEnvironments);
};

const defaultFormValue = {
name: executionEnvironment.data?.name,
upstream_name: executionEnvironment.data?.pulp?.repository?.remote?.upstream_name,
description: executionEnvironment.data?.description,
registry: { id: singleRegistry.data?.id, name: singleRegistry.data?.name },
namespace: executionEnvironment.data?.namespace,
};

if (isLoading) return <LoadingPage breadcrumbs tabs />;
if (registry.error) return <AwxError error={registry.error} handleRefresh={registry.refresh} />;
if (executionEnvironment.error)
return (
<AwxError error={executionEnvironment.error} handleRefresh={executionEnvironment.refresh} />
);
if (singleRegistry.error)
return <AwxError error={singleRegistry.error} handleRefresh={singleRegistry.refresh} />;

return (
<PageLayout>
<PageHeader
title={
props.mode == 'edit' ? t('Edit Execution Environment') : t('Add Execution Environment')
}
breadcrumbs={[
{ label: t('Execution Environments'), to: getPageUrl(HubRoute.ExecutionEnvironments) },
{ label: t(' Execution Environment') },
]}
/>

{!isLoading && (
<HubPageForm<ExecutionEnvironmentFormProps>
submitText={
props.mode == 'edit' ? t('Edit Execution Environment') : t('Add Execution Environment')
}
onCancel={() => navigate(HubRoute.ExecutionEnvironments)}
onSubmit={onSubmit}
defaultValue={defaultFormValue}
singleColumn={true}
disableSubmitOnEnter={true}
>
<PageFormTextInput<ExecutionEnvironmentFormProps>
name="name"
label={t('Name')}
placeholder={t('Enter a execution environment name')}
isRequired
isDisabled={mode == 'edit' || !isRemote}
validate={(name: string) => validateName(name, t)}
/>

{!isRemote && (
<PageFormTextInput<ExecutionEnvironmentFormProps>
name="namespace.name"
label={t('Namespace')}
placeholder={t('Enter a namespace name')}
isDisabled
AlexSCorey marked this conversation as resolved.
Show resolved Hide resolved
/>
)}

{isRemote && (
<>
<PageFormTextInput<ExecutionEnvironmentFormProps>
name="upstream_name"
label={t('Upstream name')}
placeholder={t('Enter a upstream name')}
isRequired
/>

<PageFormAsyncSelect<ExecutionEnvironmentFormProps>
name="registry"
label={t('Registry')}
placeholder={t('Select registry')}
query={query}
loadingPlaceholder={t('Loading registry...')}
loadingErrorText={t('Error loading registry')}
limit={page_size}
valueToString={(value: ExecutionEnvironment) => value.name}
openSelectDialog={registrySelector}
isRequired
/>

<TagsSelector tags={tagsToInclude} setTags={setTagsToInclude} mode={'include'} />
<TagsSelector tags={tagsToExclude} setTags={setTagsToExclude} mode={'exclude'} />
</>
)}

<PageFormTextArea<ExecutionEnvironmentFormProps>
name="description"
label={t('Description')}
placeholder={t('Enter a description')}
isDisabled={mode == 'add'}
/>
</HubPageForm>
)}
</PageLayout>
);
}

function validateName(name: string, t: TFunction<'translation', undefined>) {
const regex = /^([0-9A-Za-z._-]+\/)?[0-9A-Za-z._-]+$/;
if (regex.test(name)) {
return undefined;
} else {
return t(
`Container names can only contain alphanumeric characters, ".", "_", "-" and up to one "/".`
);
}
}

type ExecutionEnvironmentFormProps = {
name: string;
upstream_name: string;
description?: string;
registry: Registry;
namespace?: { name: string };
};

type PayloadDataType = {
include_tags?: string[];
exclude_tags?: string[];
name: string;
upstream_name: string;
registry: string;
};

type Registry = {
id: string;
name: string;
};

function TagsSelector(props: {
tags: string[];
setTags: (tags: string[]) => void;
mode: 'exclude' | 'include';
}) {
const [tagsText, setTagsText] = useState<string>('');
const { tags, setTags, mode } = props;
const { t } = useTranslation();

const label = mode == 'exclude' ? t('Add tag(s) to exclude') : t('Add tag(s) to include');
const label2 = mode == 'exclude' ? t('Currently excluded tags') : t('Currently included tags');

const chipGroupProps = () => {
const count = '${remaining}'; // pf templating
AlexSCorey marked this conversation as resolved.
Show resolved Hide resolved
return {
collapsedText: t(`{{count}} more`, count.toString()),
expandedText: t(`Show Less`),
};
};

const addTags = () => {
if (tagsText == '' || !tagsText.trim().length) {
return;
}
const tagsArray = tagsText.split(/\s+|\s*,\s*/).filter(Boolean);
const uniqueArray = [...new Set([...tags, ...tagsArray])];
setTags(uniqueArray);
setTagsText('');
};

return (
<PageFormGroup label={label}>
<InputGroup>
<TextInput
type="text"
id={`addTags-${mode}`}
value={tagsText}
onChange={(val) => {
setTagsText(val?.currentTarget?.value || '');
}}
onKeyUp={(e) => {
// l10n: don't translate
if (e.key === 'Enter') {
addTags();
}
}}
/>
<Button
variant="secondary"
onClick={() => {
addTags();
}}
>
{t`Add`}
</Button>
</InputGroup>

<div>{label2}</div>
<LabelGroup
{...chipGroupProps()}
id={`remove-tag-${mode}`}
defaultIsOpen={true}
numLabels={5}
>
{tags.map((tag) => (
<Label icon={<TagIcon />} onClose={() => setTags(tags.filter((t) => t != tag))} key={tag}>
{tag}
</Label>
))}
</LabelGroup>
</PageFormGroup>
);
}
Loading