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:
parent
31f5b42962
commit
437831fa80
34 changed files with 1293 additions and 482 deletions
|
@ -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();
|
||||
});
|
|
@ -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;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
})
|
||||
);
|
||||
}
|
|
@ -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>}
|
||||
|
|
|
@ -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 && (
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -150,7 +150,7 @@ function templateVolumes(data: AppTemplate) {
|
|||
);
|
||||
}
|
||||
|
||||
enum EnvVarType {
|
||||
export enum EnvVarType {
|
||||
PreSelected = 1,
|
||||
Text = 2,
|
||||
Select = 3,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue