mirror of
https://github.com/portainer/portainer.git
synced 2025-08-06 06:15:22 +02:00
refactor(custom-templates): render template variables [EE-2602] (#6937)
This commit is contained in:
parent
71c0e8e661
commit
1ccdb64938
32 changed files with 829 additions and 47 deletions
|
@ -0,0 +1,36 @@
|
|||
import { ComponentMeta } from '@storybook/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
CustomTemplatesVariablesDefinitionField,
|
||||
VariableDefinition,
|
||||
} from './CustomTemplatesVariablesDefinitionField';
|
||||
|
||||
export default {
|
||||
title: 'Custom Templates/Variables Definition Field',
|
||||
component: CustomTemplatesVariablesDefinitionField,
|
||||
args: {},
|
||||
} as ComponentMeta<typeof CustomTemplatesVariablesDefinitionField>;
|
||||
|
||||
function Template() {
|
||||
const [value, setValue] = useState<VariableDefinition[]>([
|
||||
{ label: '', name: '', defaultValue: '', description: '' },
|
||||
]);
|
||||
|
||||
return (
|
||||
<CustomTemplatesVariablesDefinitionField
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
errors={[
|
||||
{
|
||||
name: 'required',
|
||||
defaultValue: 'non empty',
|
||||
description: '',
|
||||
label: 'invalid',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const Story = Template.bind({});
|
|
@ -0,0 +1,105 @@
|
|||
import { FormError } from '@/portainer/components/form-components/FormError';
|
||||
import { Input } from '@/portainer/components/form-components/Input';
|
||||
import { InputList } from '@/portainer/components/form-components/InputList';
|
||||
import {
|
||||
InputListError,
|
||||
ItemProps,
|
||||
} from '@/portainer/components/form-components/InputList/InputList';
|
||||
|
||||
export interface VariableDefinition {
|
||||
name: string;
|
||||
label: string;
|
||||
defaultValue: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: VariableDefinition[];
|
||||
onChange: (value: VariableDefinition[]) => void;
|
||||
errors?: InputListError<VariableDefinition>[] | string;
|
||||
isVariablesNamesFromParent?: boolean;
|
||||
}
|
||||
|
||||
export function CustomTemplatesVariablesDefinitionField({
|
||||
onChange,
|
||||
value,
|
||||
errors,
|
||||
isVariablesNamesFromParent,
|
||||
}: Props) {
|
||||
return (
|
||||
<InputList
|
||||
label="Variables Definition"
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
renderItem={(item, onChange, error) => (
|
||||
<Item
|
||||
item={item}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
isNameReadonly={isVariablesNamesFromParent}
|
||||
/>
|
||||
)}
|
||||
itemBuilder={() => ({
|
||||
label: '',
|
||||
name: '',
|
||||
defaultValue: '',
|
||||
description: '',
|
||||
})}
|
||||
errors={errors}
|
||||
textTip="List should map the mustache variables in the template file, if default value is empty, the variable will be required."
|
||||
isAddButtonHidden={isVariablesNamesFromParent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface DefinitionItemProps extends ItemProps<VariableDefinition> {
|
||||
isNameReadonly?: boolean;
|
||||
}
|
||||
|
||||
function Item({ item, onChange, error, isNameReadonly }: DefinitionItemProps) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<div>
|
||||
<Input
|
||||
value={item.name}
|
||||
name="name"
|
||||
onChange={handleChange}
|
||||
placeholder="Name (e.g var_name)"
|
||||
readOnly={isNameReadonly}
|
||||
/>
|
||||
{error?.name && <FormError>{error.name}</FormError>}
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
value={item.label}
|
||||
onChange={handleChange}
|
||||
placeholder="Label"
|
||||
name="label"
|
||||
/>
|
||||
{error?.label && <FormError>{error.label}</FormError>}
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
name="description"
|
||||
value={item.description}
|
||||
onChange={handleChange}
|
||||
placeholder="Description"
|
||||
/>
|
||||
{error?.description && <FormError>{error.description}</FormError>}
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
value={item.defaultValue}
|
||||
onChange={handleChange}
|
||||
placeholder="Default Value"
|
||||
name="defaultValue"
|
||||
/>
|
||||
{error?.defaultValue && <FormError>{error.defaultValue}</FormError>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
onChange({ ...item, [e.target.name]: e.target.value });
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { CustomTemplatesVariablesDefinitionField } from './CustomTemplatesVariablesDefinitionField';
|
|
@ -0,0 +1,52 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
import { VariableDefinition } from '../CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField';
|
||||
|
||||
import {
|
||||
CustomTemplatesVariablesField,
|
||||
Variables,
|
||||
} from './CustomTemplatesVariablesField';
|
||||
|
||||
export default {
|
||||
title: 'Custom Templates/Variables Field',
|
||||
component: CustomTemplatesVariablesField,
|
||||
};
|
||||
|
||||
const definitions: VariableDefinition[] = [
|
||||
{
|
||||
label: 'Image Name',
|
||||
name: 'image_name',
|
||||
defaultValue: 'nginx',
|
||||
description: '',
|
||||
},
|
||||
{
|
||||
label: 'Required field',
|
||||
name: 'required_field',
|
||||
defaultValue: '',
|
||||
description: '',
|
||||
},
|
||||
{
|
||||
label: 'Required field with tooltip',
|
||||
name: 'required_field',
|
||||
defaultValue: '',
|
||||
description: 'tooltip',
|
||||
},
|
||||
];
|
||||
|
||||
function Template() {
|
||||
const [value, setValue] = useState<Variables>(
|
||||
Object.fromEntries(
|
||||
definitions.map((def) => [def.name, def.defaultValue || ''])
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<CustomTemplatesVariablesField
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
definitions={definitions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const Story = Template.bind({});
|
|
@ -0,0 +1,54 @@
|
|||
import { FormControl } from '@/portainer/components/form-components/FormControl';
|
||||
import { FormSection } from '@/portainer/components/form-components/FormSection/FormSection';
|
||||
import { Input } from '@/portainer/components/form-components/Input';
|
||||
|
||||
import { VariableDefinition } from '../CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField';
|
||||
|
||||
export type Variables = Record<string, string>;
|
||||
|
||||
interface Props {
|
||||
value: Variables;
|
||||
definitions?: VariableDefinition[];
|
||||
onChange: (value: Variables) => void;
|
||||
}
|
||||
|
||||
export function CustomTemplatesVariablesField({
|
||||
value,
|
||||
definitions,
|
||||
onChange,
|
||||
}: Props) {
|
||||
if (!definitions || !definitions.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FormSection title="Template Variables">
|
||||
{definitions.map((def) => {
|
||||
const inputId = `${def.name}-input`;
|
||||
const variable = value[def.name] || '';
|
||||
return (
|
||||
<FormControl
|
||||
required={!def.defaultValue}
|
||||
label={def.label}
|
||||
key={def.name}
|
||||
inputId={inputId}
|
||||
tooltip={def.description}
|
||||
size="small"
|
||||
>
|
||||
<Input
|
||||
name={`variables.${def.name}`}
|
||||
value={variable}
|
||||
id={inputId}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...value,
|
||||
[def.name]: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
})}
|
||||
</FormSection>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { CustomTemplatesVariablesField } from './CustomTemplatesVariablesField';
|
72
app/react/portainer/custom-templates/components/utils.ts
Normal file
72
app/react/portainer/custom-templates/components/utils.ts
Normal file
|
@ -0,0 +1,72 @@
|
|||
import _ from 'lodash';
|
||||
import Mustache from 'mustache';
|
||||
|
||||
import { VariableDefinition } from './CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField';
|
||||
|
||||
export function getTemplateVariables(templateStr: string) {
|
||||
const template = validateAndParse(templateStr);
|
||||
|
||||
if (!template) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return template
|
||||
.filter(([type, value]) => type === 'name' && value)
|
||||
.map(([, value]) => ({
|
||||
name: value,
|
||||
label: '',
|
||||
defaultValue: '',
|
||||
description: '',
|
||||
}));
|
||||
}
|
||||
|
||||
function validateAndParse(templateStr: string) {
|
||||
if (!templateStr) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return Mustache.parse(templateStr);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function intersectVariables(
|
||||
oldVariables: VariableDefinition[] = [],
|
||||
newVariables: VariableDefinition[] = []
|
||||
) {
|
||||
const oldVariablesWithLabel = oldVariables.filter((v) => !!v.label);
|
||||
|
||||
return [
|
||||
...oldVariablesWithLabel,
|
||||
...newVariables.filter(
|
||||
(v) => !oldVariablesWithLabel.find(({ name }) => name === v.name)
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function renderTemplate(
|
||||
template: string,
|
||||
variables: Record<string, string>,
|
||||
definitions: VariableDefinition[]
|
||||
) {
|
||||
const state = Object.fromEntries(
|
||||
_.compact(
|
||||
Object.entries(variables).map(([name, value]) => {
|
||||
if (value) {
|
||||
return [name, value];
|
||||
}
|
||||
|
||||
const definition = definitions.find((def) => def.name === name);
|
||||
if (!definition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [name, definition.defaultValue || `{{ ${definition.name} }}`];
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return Mustache.render(template, state);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue