1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-09 07:45:22 +02:00

feat(edge/stacks): add app templates to deploy types [EE-6632] (#11040)

This commit is contained in:
Chaim Lev-Ari 2024-02-15 09:01:01 +02:00 committed by GitHub
parent 31f5b42962
commit 437831fa80
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 1293 additions and 482 deletions

View file

@ -0,0 +1,98 @@
import { vi } from 'vitest';
import userEvent from '@testing-library/user-event';
import { render, screen } from '@/react-tools/test-utils';
import {
CustomTemplatesVariablesField,
Values,
} from './CustomTemplatesVariablesField';
test('renders CustomTemplatesVariablesField component', () => {
const onChange = vi.fn();
const definitions = [
{
name: 'Variable1',
label: 'Variable 1',
description: 'Description 1',
defaultValue: 'Default 1',
},
{
name: 'Variable2',
label: 'Variable 2',
description: 'Description 2',
defaultValue: 'Default 2',
},
];
const value: Values = [
{ key: 'Variable1', value: 'Value 1' },
{ key: 'Variable2', value: 'Value 2' },
];
render(
<CustomTemplatesVariablesField
onChange={onChange}
definitions={definitions}
value={value}
/>
);
const variableFieldItems = screen.getAllByLabelText(/Variable \d/);
expect(variableFieldItems).toHaveLength(2);
});
test('calls onChange when variable value is changed', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const definitions = [
{
name: 'Variable1',
label: 'Variable 1',
description: 'Description 1',
defaultValue: 'Default 1',
},
];
const value: Values = [{ key: 'Variable1', value: 'Value 1' }];
render(
<CustomTemplatesVariablesField
onChange={onChange}
definitions={definitions}
value={value}
/>
);
const inputElement = screen.getByLabelText('Variable 1');
await user.clear(inputElement);
expect(onChange).toHaveBeenCalledWith([{ key: 'Variable1', value: '' }]);
await user.type(inputElement, 'New Value');
expect(onChange).toHaveBeenCalled();
});
test('renders error message when errors prop is provided', () => {
const onChange = vi.fn();
const definitions = [
{
name: 'Variable1',
label: 'Variable 1',
description: 'Description 1',
defaultValue: 'Default 1',
},
];
const value: Values = [{ key: 'Variable1', value: 'Value 1' }];
const errors = [{ value: 'Error message' }];
render(
<CustomTemplatesVariablesField
onChange={onChange}
definitions={definitions}
value={value}
errors={errors}
/>
);
const errorElement = screen.getByText('Error message');
expect(errorElement).toBeInTheDocument();
});

View file

@ -1,13 +1,11 @@
import { array, object, string } from 'yup';
import { FormControl } from '@@/form-components/FormControl';
import { FormSection } from '@@/form-components/FormSection/FormSection';
import { Input } from '@@/form-components/Input';
import { ArrayError } from '@@/form-components/InputList/InputList';
import { FormError } from '@@/form-components/FormError';
import { VariableDefinition } from '../CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField';
import { VariableFieldItem } from './VariableFieldItem';
export type Values = Array<{ key: string; value?: string }>;
interface Props {
@ -33,8 +31,8 @@ export function CustomTemplatesVariablesField({
<VariableFieldItem
key={definition.name}
definition={definition}
value={value.find((v) => v.key === definition.name)?.value || ''}
error={getError(errors, index)}
value={value.find((v) => v.key === definition.name)?.value}
onChange={(fieldValue) => {
onChange(
value.map((v) =>
@ -50,39 +48,6 @@ export function CustomTemplatesVariablesField({
);
}
function VariableFieldItem({
definition,
value,
error,
onChange,
}: {
definition: VariableDefinition;
value: string;
error?: string;
onChange: (value: string) => void;
}) {
const inputId = `${definition.name}-input`;
return (
<FormControl
required={!definition.defaultValue}
label={definition.label}
key={definition.name}
inputId={inputId}
tooltip={definition.description}
size="small"
errors={error}
>
<Input
name={`variables.${definition.name}`}
value={value}
id={inputId}
onChange={(e) => onChange(e.target.value)}
/>
</FormControl>
);
}
function getError(errors: ArrayError<Values> | undefined, index: number) {
if (!errors || typeof errors !== 'object') {
return undefined;
@ -95,22 +60,3 @@ function getError(errors: ArrayError<Values> | undefined, index: number) {
return typeof error === 'object' ? error.value : error;
}
export function validation(definitions: VariableDefinition[]) {
return array(
object({
key: string().default(''),
value: string().default(''),
}).test('required-if-no-default-value', 'This field is required', (obj) => {
const definition = definitions.find((d) => d.name === obj.key);
if (!definition) {
return true;
}
if (!definition.defaultValue && !obj.value) {
return false;
}
return true;
})
);
}

View file

@ -0,0 +1,53 @@
import { vi } from 'vitest';
import userEvent from '@testing-library/user-event';
import { render, screen } from '@/react-tools/test-utils';
import { VariableFieldItem } from './VariableFieldItem';
test('renders VariableFieldItem component', () => {
const definition = {
name: 'variableName',
label: 'Variable Label',
description: 'Variable Description',
defaultValue: 'Default Value',
};
render(<VariableFieldItem definition={definition} onChange={vi.fn()} />);
const labelElement = screen.getByText('Variable Label');
expect(labelElement).toBeInTheDocument();
const inputElement = screen.getByPlaceholderText(
'Enter value or leave blank to use default of Default Value'
);
expect(inputElement).toBeInTheDocument();
});
test('calls onChange when input value changes', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const definition = {
name: 'variableName',
label: 'Variable Label',
description: 'Variable Description',
defaultValue: 'Default Value',
};
render(
<VariableFieldItem
definition={definition}
onChange={onChange}
value="value"
/>
);
const inputElement = screen.getByLabelText(definition.label);
await user.clear(inputElement);
expect(onChange).toHaveBeenCalledWith('');
await user.type(inputElement, 'New Value');
expect(onChange).toHaveBeenCalled();
});

View file

@ -0,0 +1,38 @@
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
import { VariableDefinition } from '../CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField';
export function VariableFieldItem({
definition,
error,
onChange,
value,
}: {
definition: VariableDefinition;
error?: string;
onChange: (value: string) => void;
value?: string;
}) {
const inputId = `${definition.name}-input`;
return (
<FormControl
required={!definition.defaultValue}
label={definition.label}
key={definition.name}
inputId={inputId}
tooltip={definition.description}
size="small"
errors={error}
>
<Input
name={`variables.${definition.name}`}
id={inputId}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={`Enter value or leave blank to use default of ${definition.defaultValue}`}
/>
</FormControl>
);
}

View file

@ -1,7 +1,8 @@
export {
CustomTemplatesVariablesField,
type Values as VariablesFieldValue,
validation as variablesFieldValidation,
} from './CustomTemplatesVariablesField';
export { validation as variablesFieldValidation } from './validation';
export { getDefaultValues as getVariablesFieldDefaultValues } from './getDefaultValues';

View file

@ -0,0 +1,23 @@
import { array, object, string } from 'yup';
import { VariableDefinition } from '../CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField';
export function validation(definitions: VariableDefinition[]) {
return array(
object({
key: string().default(''),
value: string().default(''),
}).test('required-if-no-default-value', 'This field is required', (obj) => {
const definition = definitions.find((d) => d.name === obj.key);
if (!definition) {
return true;
}
if (!definition.defaultValue && !obj.value) {
return false;
}
return true;
})
);
}

View file

@ -21,13 +21,18 @@ export function AppTemplatesList({
disabledTypes,
fixedCategories,
storageKey,
templateLinkParams,
}: {
storageKey: string;
templates?: TemplateViewModel[];
onSelect: (template: TemplateViewModel) => void;
onSelect?: (template: TemplateViewModel) => void;
selectedId?: TemplateViewModel['Id'];
disabledTypes?: Array<TemplateType>;
fixedCategories?: Array<string>;
templateLinkParams?: (template: TemplateViewModel) => {
to: string;
params: object;
};
}) {
const [page, setPage] = useState(0);
const [store] = useState(() =>
@ -88,6 +93,7 @@ export function AppTemplatesList({
template={template}
onSelect={onSelect}
isSelected={selectedId === template.Id}
linkParams={templateLinkParams?.(template)}
/>
))}
{!templates && <div className="text-muted text-center">Loading...</div>}

View file

@ -12,10 +12,12 @@ export function AppTemplatesListItem({
template,
onSelect,
isSelected,
linkParams,
}: {
template: TemplateViewModel;
onSelect: (template: TemplateViewModel) => void;
onSelect?: (template: TemplateViewModel) => void;
isSelected: boolean;
linkParams?: { to: string; params: object };
}) {
const duplicateCustomTemplateType = getCustomTemplateType(template.Type);
@ -25,7 +27,8 @@ export function AppTemplatesListItem({
typeLabel={
template.Type === TemplateType.Container ? 'container' : 'stack'
}
onSelect={() => onSelect(template)}
linkParams={linkParams}
onSelect={() => onSelect?.(template)}
isSelected={isSelected}
renderActions={
duplicateCustomTemplateType && (

View file

@ -10,7 +10,9 @@ import { TemplateViewModel } from '../view-model';
import { buildUrl } from './build-url';
export function useAppTemplates() {
export function useAppTemplates<T = Array<TemplateViewModel>>({
select,
}: { select?: (templates: Array<TemplateViewModel>) => T } = {}) {
const registriesQuery = useRegistries();
return useQuery(
@ -18,6 +20,7 @@ export function useAppTemplates() {
() => getTemplatesWithRegistry(registriesQuery.data),
{
enabled: !!registriesQuery.data,
select,
}
);
}
@ -29,7 +32,7 @@ async function getTemplatesWithRegistry(
return [];
}
const { templates, version } = await getTemplates();
const { templates, version } = await getAppTemplates();
return templates.map((item) => {
const template = new TemplateViewModel(item, version);
const registryURL = item.registry;
@ -41,7 +44,7 @@ async function getTemplatesWithRegistry(
});
}
async function getTemplates() {
export async function getAppTemplates() {
try {
const { data } = await axios.get<{
version: string;

View file

@ -150,7 +150,7 @@ function templateVolumes(data: AppTemplate) {
);
}
enum EnvVarType {
export enum EnvVarType {
PreSelected = 1,
Text = 2,
Select = 3,