refactor(custom-templates): render template variables [EE-2602] (#6937)

pull/6979/head
Chaim Lev-Ari 2022-05-31 13:00:47 +03:00 committed by GitHub
parent 71c0e8e661
commit 1ccdb64938
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 829 additions and 47 deletions

View File

@ -1,6 +1,7 @@
package customtemplates package customtemplates
import ( import (
"encoding/json"
"errors" "errors"
"log" "log"
"net/http" "net/http"
@ -115,6 +116,8 @@ type customTemplateFromFileContentPayload struct {
Type portainer.StackType `example:"1" enums:"1,2,3" validate:"required"` Type portainer.StackType `example:"1" enums:"1,2,3" validate:"required"`
// Content of stack file // Content of stack file
FileContent string `validate:"required"` FileContent string `validate:"required"`
// Definitions of variables in the stack file
Variables []portainer.CustomTemplateVariableDefinition
} }
func (payload *customTemplateFromFileContentPayload) Validate(r *http.Request) error { func (payload *customTemplateFromFileContentPayload) Validate(r *http.Request) error {
@ -136,6 +139,12 @@ func (payload *customTemplateFromFileContentPayload) Validate(r *http.Request) e
if !isValidNote(payload.Note) { if !isValidNote(payload.Note) {
return errors.New("Invalid note. <img> tag is not supported") return errors.New("Invalid note. <img> tag is not supported")
} }
err := validateVariablesDefinitions(payload.Variables)
if err != nil {
return err
}
return nil return nil
} }
@ -164,6 +173,7 @@ func (handler *Handler) createCustomTemplateFromFileContent(r *http.Request) (*p
Platform: (payload.Platform), Platform: (payload.Platform),
Type: (payload.Type), Type: (payload.Type),
Logo: payload.Logo, Logo: payload.Logo,
Variables: payload.Variables,
} }
templateFolder := strconv.Itoa(customTemplateID) templateFolder := strconv.Itoa(customTemplateID)
@ -204,6 +214,8 @@ type customTemplateFromGitRepositoryPayload struct {
RepositoryPassword string `example:"myGitPassword"` RepositoryPassword string `example:"myGitPassword"`
// Path to the Stack file inside the Git repository // Path to the Stack file inside the Git repository
ComposeFilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"` ComposeFilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"`
// Definitions of variables in the stack file
Variables []portainer.CustomTemplateVariableDefinition
} }
func (payload *customTemplateFromGitRepositoryPayload) Validate(r *http.Request) error { func (payload *customTemplateFromGitRepositoryPayload) Validate(r *http.Request) error {
@ -236,6 +248,12 @@ func (payload *customTemplateFromGitRepositoryPayload) Validate(r *http.Request)
if !isValidNote(payload.Note) { if !isValidNote(payload.Note) {
return errors.New("Invalid note. <img> tag is not supported") return errors.New("Invalid note. <img> tag is not supported")
} }
err := validateVariablesDefinitions(payload.Variables)
if err != nil {
return err
}
return nil return nil
} }
@ -256,6 +274,7 @@ func (handler *Handler) createCustomTemplateFromGitRepository(r *http.Request) (
Platform: payload.Platform, Platform: payload.Platform,
Type: payload.Type, Type: payload.Type,
Logo: payload.Logo, Logo: payload.Logo,
Variables: payload.Variables,
} }
projectPath := handler.FileService.GetCustomTemplateProjectPath(strconv.Itoa(customTemplateID)) projectPath := handler.FileService.GetCustomTemplateProjectPath(strconv.Itoa(customTemplateID))
@ -316,6 +335,8 @@ type customTemplateFromFileUploadPayload struct {
Platform portainer.CustomTemplatePlatform Platform portainer.CustomTemplatePlatform
Type portainer.StackType Type portainer.StackType
FileContent []byte FileContent []byte
// Definitions of variables in the stack file
Variables []portainer.CustomTemplateVariableDefinition
} }
func (payload *customTemplateFromFileUploadPayload) Validate(r *http.Request) error { func (payload *customTemplateFromFileUploadPayload) Validate(r *http.Request) error {
@ -361,6 +382,17 @@ func (payload *customTemplateFromFileUploadPayload) Validate(r *http.Request) er
} }
payload.FileContent = composeFileContent payload.FileContent = composeFileContent
varsString, _ := request.RetrieveMultiPartFormValue(r, "Variables", true)
err = json.Unmarshal([]byte(varsString), &payload.Variables)
if err != nil {
return errors.New("Invalid variables. Ensure that the variables are valid JSON")
}
err = validateVariablesDefinitions(payload.Variables)
if err != nil {
return err
}
return nil return nil
} }
@ -381,6 +413,7 @@ func (handler *Handler) createCustomTemplateFromFileUpload(r *http.Request) (*po
Type: payload.Type, Type: payload.Type,
Logo: payload.Logo, Logo: payload.Logo,
EntryPoint: filesystem.ComposeFileDefaultName, EntryPoint: filesystem.ComposeFileDefaultName,
Variables: payload.Variables,
} }
templateFolder := strconv.Itoa(customTemplateID) templateFolder := strconv.Itoa(customTemplateID)

View File

@ -31,6 +31,8 @@ type customTemplateUpdatePayload struct {
Type portainer.StackType `example:"1" enums:"1,2,3" validate:"required"` Type portainer.StackType `example:"1" enums:"1,2,3" validate:"required"`
// Content of stack file // Content of stack file
FileContent string `validate:"required"` FileContent string `validate:"required"`
// Definitions of variables in the stack file
Variables []portainer.CustomTemplateVariableDefinition
} }
func (payload *customTemplateUpdatePayload) Validate(r *http.Request) error { func (payload *customTemplateUpdatePayload) Validate(r *http.Request) error {
@ -52,6 +54,12 @@ func (payload *customTemplateUpdatePayload) Validate(r *http.Request) error {
if !isValidNote(payload.Note) { if !isValidNote(payload.Note) {
return errors.New("Invalid note. <img> tag is not supported") return errors.New("Invalid note. <img> tag is not supported")
} }
err := validateVariablesDefinitions(payload.Variables)
if err != nil {
return err
}
return nil return nil
} }
@ -124,6 +132,7 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ
customTemplate.Note = payload.Note customTemplate.Note = payload.Note
customTemplate.Platform = payload.Platform customTemplate.Platform = payload.Platform
customTemplate.Type = payload.Type customTemplate.Type = payload.Type
customTemplate.Variables = payload.Variables
err = handler.DataStore.CustomTemplate().UpdateCustomTemplate(customTemplate.ID, customTemplate) err = handler.DataStore.CustomTemplate().UpdateCustomTemplate(customTemplate.ID, customTemplate)
if err != nil { if err != nil {

View File

@ -0,0 +1,19 @@
package customtemplates
import (
"errors"
portainer "github.com/portainer/portainer/api"
)
func validateVariablesDefinitions(variables []portainer.CustomTemplateVariableDefinition) error {
for _, variable := range variables {
if variable.Name == "" {
return errors.New("variable name is required")
}
if variable.Label == "" {
return errors.New("variable label is required")
}
}
return nil
}

View File

@ -128,6 +128,14 @@ type (
SecretKeyName *string SecretKeyName *string
} }
// CustomTemplateVariableDefinition
CustomTemplateVariableDefinition struct {
Name string `json:"name" example:"MY_VAR"`
Label string `json:"label" example:"My Variable"`
DefaultValue string `json:"defaultValue" example:"default value"`
Description string `json:"description" example:"Description"`
}
// CustomTemplate represents a custom template // CustomTemplate represents a custom template
CustomTemplate struct { CustomTemplate struct {
// CustomTemplate Identifier // CustomTemplate Identifier
@ -152,6 +160,7 @@ type (
// Type of created stack (1 - swarm, 2 - compose) // Type of created stack (1 - swarm, 2 - compose)
Type StackType `json:"Type" example:"1"` Type StackType `json:"Type" example:"1"`
ResourceControl *ResourceControl `json:"ResourceControl"` ResourceControl *ResourceControl `json:"ResourceControl"`
Variables []CustomTemplateVariableDefinition
} }
// CustomTemplateID represents a custom template identifier // CustomTemplateID represents a custom template identifier

View File

@ -47,7 +47,7 @@ export function PortsMappingField({ value, onChange, errors }: Props) {
function Item({ onChange, item, error }: ItemProps<PortMapping>) { function Item({ onChange, item, error }: ItemProps<PortMapping>) {
return ( return (
<div className={styles.item}> <div className={styles.item}>
<div className={styles.inputs}> <div className="flex items-center gap-2">
<InputGroup size="small"> <InputGroup size="small">
<InputGroup.Addon>host</InputGroup.Addon> <InputGroup.Addon>host</InputGroup.Addon>
<InputGroup.Input <InputGroup.Input
@ -57,7 +57,7 @@ function Item({ onChange, item, error }: ItemProps<PortMapping>) {
/> />
</InputGroup> </InputGroup>
<span style={{ margin: '0 10px 0 10px' }}> <span className="mx-3">
<i className="fa fa-long-arrow-alt-right" aria-hidden="true" /> <i className="fa fa-long-arrow-alt-right" aria-hidden="true" />
</span> </span>

View File

@ -1,5 +1,6 @@
import { buildOption } from '@/portainer/components/BoxSelector'; import { buildOption } from '@/portainer/components/BoxSelector';
import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel'; import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel';
import { getTemplateVariables, intersectVariables } from '@/react/portainer/custom-templates/components/utils';
class KubeCreateCustomTemplateViewController { class KubeCreateCustomTemplateViewController {
/* @ngInject */ /* @ngInject */
@ -18,6 +19,7 @@ class KubeCreateCustomTemplateViewController {
actionInProgress: false, actionInProgress: false,
formValidationError: '', formValidationError: '',
isEditorDirty: false, isEditorDirty: false,
isTemplateValid: true,
}; };
this.formValues = { this.formValues = {
@ -28,26 +30,54 @@ class KubeCreateCustomTemplateViewController {
Note: '', Note: '',
Logo: '', Logo: '',
AccessControlData: new AccessControlFormData(), AccessControlData: new AccessControlFormData(),
Variables: [],
}; };
this.onChangeFile = this.onChangeFile.bind(this); this.onChangeFile = this.onChangeFile.bind(this);
this.onChangeFileContent = this.onChangeFileContent.bind(this); this.onChangeFileContent = this.onChangeFileContent.bind(this);
this.onChangeMethod = this.onChangeMethod.bind(this); this.onChangeMethod = this.onChangeMethod.bind(this);
this.onBeforeOnload = this.onBeforeOnload.bind(this); this.onBeforeOnload = this.onBeforeOnload.bind(this);
this.handleChange = this.handleChange.bind(this);
this.onVariablesChange = this.onVariablesChange.bind(this);
} }
onChangeMethod(method) { onChangeMethod(method) {
this.state.method = method; this.state.method = method;
this.formValues.Variables = [];
} }
onChangeFileContent(content) { onChangeFileContent(content) {
this.formValues.FileContent = content; this.handleChange({ FileContent: content });
this.parseTemplate(content);
this.state.isEditorDirty = true; this.state.isEditorDirty = true;
} }
parseTemplate(templateStr) {
const variables = getTemplateVariables(templateStr);
const isValid = !!variables;
this.state.isTemplateValid = isValid;
if (isValid) {
this.onVariablesChange(intersectVariables(this.formValues.Variables, variables));
}
}
onVariablesChange(value) {
this.handleChange({ Variables: value });
}
onChangeFile(file) { onChangeFile(file) {
this.handleChange({ File: file });
}
handleChange(values) {
return this.$async(async () => { return this.$async(async () => {
this.formValues.File = file; this.formValues = {
...this.formValues,
...values,
};
}); });
} }
@ -113,6 +143,11 @@ class KubeCreateCustomTemplateViewController {
return false; return false;
} }
if (!this.state.isTemplateValid) {
this.state.formValidationError = 'Template is not valid';
return false;
}
const isAdmin = this.Authentication.isAdmin(); const isAdmin = this.Authentication.isAdmin();
const accessControlData = this.formValues.AccessControlData; const accessControlData = this.formValues.AccessControlData;
const error = this.FormValidator.validateAccessControl(accessControlData, isAdmin); const error = this.FormValidator.validateAccessControl(accessControlData, isAdmin);
@ -130,6 +165,7 @@ class KubeCreateCustomTemplateViewController {
const { fileContent, type } = this.$state.params; const { fileContent, type } = this.$state.params;
this.formValues.FileContent = fileContent; this.formValues.FileContent = fileContent;
this.parseTemplate(fileContent);
if (type) { if (type) {
this.formValues.Type = +type; this.formValues.Type = +type;
} }

View File

@ -36,6 +36,12 @@
<file-upload-description> You can upload a Manifest file from your computer. </file-upload-description> <file-upload-description> You can upload a Manifest file from your computer. </file-upload-description>
</file-upload-form> </file-upload-form>
<custom-templates-variables-definition-field
value="$ctrl.formValues.Variables"
on-change="($ctrl.onVariablesChange)"
is-variables-names-from-parent="$ctrl.state.method === 'editor'"
></custom-templates-variables-definition-field>
<por-access-control-form form-data="$ctrl.formValues.AccessControlData"></por-access-control-form> <por-access-control-form form-data="$ctrl.formValues.AccessControlData"></por-access-control-form>
<!-- actions --> <!-- actions -->
@ -45,7 +51,7 @@
<button <button
type="button" type="button"
class="btn btn-primary btn-sm" class="btn btn-primary btn-sm"
ng-disabled="$ctrl.state.actionInProgress || $ctrl.form.$invalid || ($ctrl.state.method === 'editor' && !$ctrl.formValues.FileContent)" ng-disabled="!$ctrl.state.isTemplateValid ||$ctrl.state.actionInProgress || $ctrl.form.$invalid || ($ctrl.state.method === 'editor' && !$ctrl.formValues.FileContent)"
ng-click="$ctrl.createCustomTemplate()" ng-click="$ctrl.createCustomTemplate()"
button-spinner="$ctrl.state.actionInProgress" button-spinner="$ctrl.state.actionInProgress"
> >

View File

@ -1,5 +1,6 @@
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel'; import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel'; import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel';
import { getTemplateVariables, intersectVariables } from '@/react/portainer/custom-templates/components/utils';
class KubeEditCustomTemplateViewController { class KubeEditCustomTemplateViewController {
/* @ngInject */ /* @ngInject */
@ -10,6 +11,7 @@ class KubeEditCustomTemplateViewController {
this.state = { this.state = {
formValidationError: '', formValidationError: '',
isEditorDirty: false, isEditorDirty: false,
isTemplateValid: true,
}; };
this.templates = []; this.templates = [];
@ -17,6 +19,8 @@ class KubeEditCustomTemplateViewController {
this.submitAction = this.submitAction.bind(this); this.submitAction = this.submitAction.bind(this);
this.onChangeFileContent = this.onChangeFileContent.bind(this); this.onChangeFileContent = this.onChangeFileContent.bind(this);
this.onBeforeUnload = this.onBeforeUnload.bind(this); this.onBeforeUnload = this.onBeforeUnload.bind(this);
this.handleChange = this.handleChange.bind(this);
this.onVariablesChange = this.onVariablesChange.bind(this);
} }
getTemplate() { getTemplate() {
@ -26,7 +30,12 @@ class KubeEditCustomTemplateViewController {
const [template, file] = await Promise.all([this.CustomTemplateService.customTemplate(id), this.CustomTemplateService.customTemplateFile(id)]); const [template, file] = await Promise.all([this.CustomTemplateService.customTemplate(id), this.CustomTemplateService.customTemplateFile(id)]);
template.FileContent = file; template.FileContent = file;
template.Variables = template.Variables || [];
this.formValues = template; this.formValues = template;
this.parseTemplate(file);
this.oldFileContent = this.formValues.FileContent; this.oldFileContent = this.formValues.FileContent;
this.formValues.ResourceControl = new ResourceControlViewModel(template.ResourceControl); this.formValues.ResourceControl = new ResourceControlViewModel(template.ResourceControl);
@ -37,6 +46,31 @@ class KubeEditCustomTemplateViewController {
}); });
} }
onVariablesChange(values) {
this.handleChange({ Variables: values });
}
handleChange(values) {
return this.$async(async () => {
this.formValues = {
...this.formValues,
...values,
};
});
}
parseTemplate(templateStr) {
const variables = getTemplateVariables(templateStr);
const isValid = !!variables;
this.state.isTemplateValid = isValid;
if (isValid) {
this.onVariablesChange(intersectVariables(this.formValues.Variables, variables));
}
}
validateForm() { validateForm() {
this.state.formValidationError = ''; this.state.formValidationError = '';
@ -94,6 +128,7 @@ class KubeEditCustomTemplateViewController {
onChangeFileContent(value) { onChangeFileContent(value) {
if (stripSpaces(this.formValues.FileContent) !== stripSpaces(value)) { if (stripSpaces(this.formValues.FileContent) !== stripSpaces(value)) {
this.formValues.FileContent = value; this.formValues.FileContent = value;
this.parseTemplate(value);
this.state.isEditorDirty = true; this.state.isEditorDirty = true;
} }
} }

View File

@ -31,6 +31,12 @@
</editor-description> </editor-description>
</web-editor-form> </web-editor-form>
<custom-templates-variables-definition-field
value="$ctrl.formValues.Variables"
on-change="($ctrl.onVariablesChange)"
is-variables-names-from-parent="true"
></custom-templates-variables-definition-field>
<por-access-control-form form-data="$ctrl.formValues.AccessControlData" resource-control="$ctrl.formValues.ResourceControl"></por-access-control-form> <por-access-control-form form-data="$ctrl.formValues.AccessControlData" resource-control="$ctrl.formValues.ResourceControl"></por-access-control-form>
<div class="col-sm-12 form-section-title"> Actions </div> <div class="col-sm-12 form-section-title"> Actions </div>

View File

@ -84,14 +84,22 @@
></git-form> ></git-form>
<!-- !repository --> <!-- !repository -->
<div ng-show="ctrl.state.BuildMethod === ctrl.BuildMethods.CUSTOM_TEMPLATE">
<custom-template-selector <custom-template-selector
ng-show="ctrl.state.BuildMethod === ctrl.BuildMethods.CUSTOM_TEMPLATE"
new-template-path="kubernetes.templates.custom.new" new-template-path="kubernetes.templates.custom.new"
stack-type="3" stack-type="3"
on-change="(ctrl.onChangeTemplateId)" on-change="(ctrl.onChangeTemplateId)"
value="ctrl.state.templateId" value="ctrl.state.templateId"
></custom-template-selector> ></custom-template-selector>
<custom-templates-variables-field
ng-if="ctrl.state.template"
definitions="ctrl.state.template.Variables"
value="ctrl.formValues.Variables"
on-change="(ctrl.onChangeTemplateVariables)"
></custom-templates-variables-field>
</div>
<!-- editor --> <!-- editor -->
<web-editor-form <web-editor-form
ng-if="ctrl.state.BuildMethod === ctrl.BuildMethods.WEB_EDITOR || (ctrl.state.BuildMethod === ctrl.BuildMethods.CUSTOM_TEMPLATE && ctrl.state.templateId)" ng-if="ctrl.state.BuildMethod === ctrl.BuildMethods.WEB_EDITOR || (ctrl.state.BuildMethod === ctrl.BuildMethods.CUSTOM_TEMPLATE && ctrl.state.templateId)"

View File

@ -2,10 +2,11 @@ import angular from 'angular';
import _ from 'lodash-es'; import _ from 'lodash-es';
import stripAnsi from 'strip-ansi'; import stripAnsi from 'strip-ansi';
import uuidv4 from 'uuid/v4'; import uuidv4 from 'uuid/v4';
import PortainerError from 'Portainer/error';
import PortainerError from '@/portainer/error';
import { KubernetesDeployManifestTypes, KubernetesDeployBuildMethods, KubernetesDeployRequestMethods, RepositoryMechanismTypes } from 'Kubernetes/models/deploy'; import { KubernetesDeployManifestTypes, KubernetesDeployBuildMethods, KubernetesDeployRequestMethods, RepositoryMechanismTypes } from 'Kubernetes/models/deploy';
import { buildOption } from '@/portainer/components/BoxSelector'; import { buildOption } from '@/portainer/components/BoxSelector';
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
class KubernetesDeployController { class KubernetesDeployController {
/* @ngInject */ /* @ngInject */
@ -42,6 +43,7 @@ class KubernetesDeployController {
viewReady: false, viewReady: false,
isEditorDirty: false, isEditorDirty: false,
templateId: null, templateId: null,
template: null,
}; };
this.formValues = { this.formValues = {
@ -56,7 +58,8 @@ class KubernetesDeployController {
RepositoryAutomaticUpdates: false, RepositoryAutomaticUpdates: false,
RepositoryMechanism: RepositoryMechanismTypes.INTERVAL, RepositoryMechanism: RepositoryMechanismTypes.INTERVAL,
RepositoryFetchInterval: '5m', RepositoryFetchInterval: '5m',
RepositoryWebhookURL: this.WebhookHelper.returnStackWebhookUrl(uuidv4()), RepositoryWebhookURL: WebhookHelper.returnStackWebhookUrl(uuidv4()),
Variables: {},
}; };
this.ManifestDeployTypes = KubernetesDeployManifestTypes; this.ManifestDeployTypes = KubernetesDeployManifestTypes;
@ -70,6 +73,18 @@ class KubernetesDeployController {
this.buildAnalyticsProperties = this.buildAnalyticsProperties.bind(this); this.buildAnalyticsProperties = this.buildAnalyticsProperties.bind(this);
this.onChangeMethod = this.onChangeMethod.bind(this); this.onChangeMethod = this.onChangeMethod.bind(this);
this.onChangeDeployType = this.onChangeDeployType.bind(this); this.onChangeDeployType = this.onChangeDeployType.bind(this);
this.onChangeTemplateVariables = this.onChangeTemplateVariables.bind(this);
}
onChangeTemplateVariables(value) {
this.onChangeFormValues({ Variables: value });
this.renderTemplate();
}
renderTemplate() {
const rendered = renderTemplate(this.state.templateContent, this.formValues.Variables, this.state.template.Variables);
this.onChangeFormValues({ EditorContent: rendered });
} }
buildAnalyticsProperties() { buildAnalyticsProperties() {
@ -157,17 +172,24 @@ class KubernetesDeployController {
}; };
} }
onChangeTemplateId(templateId) { onChangeTemplateId(templateId, template) {
return this.$async(async () => { return this.$async(async () => {
if (this.state.templateId === templateId) { if (!template || (this.state.templateId === templateId && this.state.template === template)) {
return; return;
} }
this.state.templateId = templateId; this.state.templateId = templateId;
this.state.template = template;
try { try {
const fileContent = await this.CustomTemplateService.customTemplateFile(templateId); const fileContent = await this.CustomTemplateService.customTemplateFile(templateId);
this.state.templateContent = fileContent;
this.onChangeFileContent(fileContent); this.onChangeFileContent(fileContent);
if (template.Variables && template.Variables.length > 0) {
const variables = Object.fromEntries(template.Variables.map((variable) => [variable.name, '']));
this.onChangeTemplateVariables(variables);
}
} catch (err) { } catch (err) {
this.Notifications.error('Failure', err, 'Unable to load template file'); this.Notifications.error('Failure', err, 'Unable to load template file');
} }
@ -307,7 +329,7 @@ class KubernetesDeployController {
const templateId = parseInt(this.$state.params.templateId, 10); const templateId = parseInt(this.$state.params.templateId, 10);
if (templateId && !Number.isNaN(templateId)) { if (templateId && !Number.isNaN(templateId)) {
this.state.BuildMethod = KubernetesDeployBuildMethods.CUSTOM_TEMPLATE; this.state.BuildMethod = KubernetesDeployBuildMethods.CUSTOM_TEMPLATE;
this.onChangeTemplateId(templateId); this.state.templateId = templateId;
} }
} }

View File

@ -74,7 +74,7 @@ function SelectAndInputItem({
onChange: (value: ListWithSelectItem) => void; onChange: (value: ListWithSelectItem) => void;
}) { }) {
return ( return (
<div> <div className="flex gap-2">
<Input <Input
type="number" type="number"
value={item.value} value={item.value}

View File

@ -3,6 +3,7 @@ import clsx from 'clsx';
import { AddButton, Button } from '@/portainer/components/Button'; import { AddButton, Button } from '@/portainer/components/Button';
import { Tooltip } from '@/portainer/components/Tip/Tooltip'; import { Tooltip } from '@/portainer/components/Tip/Tooltip';
import { TextTip } from '@/portainer/components/Tip/TextTip';
import { Input } from '../Input'; import { Input } from '../Input';
import { FormError } from '../FormError'; import { FormError } from '../FormError';
@ -32,17 +33,26 @@ type OnChangeEvent<T> =
to: number; to: number;
}; };
type RenderItemFunction<T> = (
item: T,
onChange: (value: T) => void,
error?: InputListError<T>
) => React.ReactNode;
interface Props<T> { interface Props<T> {
label: string; label: string;
value: T[]; value: T[];
onChange(value: T[], e: OnChangeEvent<T>): void; onChange(value: T[], e: OnChangeEvent<T>): void;
itemBuilder?(): T; itemBuilder?(): T;
renderItem?: RenderItemFunction<T>;
item?: ComponentType<ItemProps<T>>; item?: ComponentType<ItemProps<T>>;
tooltip?: string; tooltip?: string;
addLabel?: string; addLabel?: string;
itemKeyGetter?(item: T, index: number): Key; itemKeyGetter?(item: T, index: number): Key;
movable?: boolean; movable?: boolean;
errors?: InputListError<T>[] | string; errors?: InputListError<T>[] | string;
textTip?: string;
isAddButtonHidden?: boolean;
} }
export function InputList<T = DefaultType>({ export function InputList<T = DefaultType>({
@ -50,15 +60,16 @@ export function InputList<T = DefaultType>({
value, value,
onChange, onChange,
itemBuilder = defaultItemBuilder as unknown as () => T, itemBuilder = defaultItemBuilder as unknown as () => T,
item = DefaultItem as unknown as ComponentType<ItemProps<T>>, renderItem = renderDefaultItem as unknown as RenderItemFunction<T>,
item: Item,
tooltip, tooltip,
addLabel = 'Add item', addLabel = 'Add item',
itemKeyGetter = (item: T, index: number) => index, itemKeyGetter = (item: T, index: number) => index,
movable, movable,
errors, errors,
textTip,
isAddButtonHidden = false,
}: Props<T>) { }: Props<T>) {
const Item = item;
return ( return (
<div className={clsx('form-group', styles.root)}> <div className={clsx('form-group', styles.root)}>
<div className={clsx('col-sm-12', styles.header)}> <div className={clsx('col-sm-12', styles.header)}>
@ -66,14 +77,22 @@ export function InputList<T = DefaultType>({
{label} {label}
{tooltip && <Tooltip message={tooltip} />} {tooltip && <Tooltip message={tooltip} />}
</div> </div>
{!isAddButtonHidden && (
<AddButton <AddButton
label={addLabel} label={addLabel}
className="space-left" className="space-left"
onClick={handleAdd} onClick={handleAdd}
/> />
)}
</div> </div>
<div className={clsx('col-sm-12 form-inline', styles.items)}> {textTip && (
<div className="col-sm-12 my-5">
<TextTip color="blue">{textTip}</TextTip>
</div>
)}
<div className={clsx('col-sm-12', styles.items, 'space-y-4')}>
{value.map((item, index) => { {value.map((item, index) => {
const key = itemKeyGetter(item, index); const key = itemKeyGetter(item, index);
const error = typeof errors === 'object' ? errors[index] : undefined; const error = typeof errors === 'object' ? errors[index] : undefined;
@ -83,12 +102,20 @@ export function InputList<T = DefaultType>({
key={key} key={key}
className={clsx(styles.itemLine, { [styles.hasError]: !!error })} className={clsx(styles.itemLine, { [styles.hasError]: !!error })}
> >
{Item ? (
<Item <Item
item={item} item={item}
onChange={(value: T) => handleChangeItem(key, value)} onChange={(value: T) => handleChangeItem(key, value)}
error={error} error={error}
/> />
<div className={styles.itemActions}> ) : (
renderItem(
item,
(value: T) => handleChangeItem(key, value),
error
)
)}
<div className={clsx(styles.itemActions, 'items-start')}>
{movable && ( {movable && (
<> <>
<Button <Button
@ -191,7 +218,15 @@ function DefaultItem({ item, onChange, error }: ItemProps<DefaultType>) {
onChange={(e) => onChange({ value: e.target.value })} onChange={(e) => onChange({ value: e.target.value })}
className={styles.defaultItem} className={styles.defaultItem}
/> />
<FormError>{error}</FormError> {error && <FormError>{error}</FormError>}
</> </>
); );
} }
function renderDefaultItem(
item: DefaultType,
onChange: (value: DefaultType) => void,
error?: InputListError<DefaultType>
) {
return <DefaultItem item={item} onChange={onChange} error={error} />;
}

View File

@ -59,7 +59,7 @@
<button <button
type="button" type="button"
class="btn btn-primary btn-sm" class="btn btn-primary btn-sm"
ng-disabled="$ctrl.state.actionInProgress || !$ctrl.formValues.name || !$ctrl.state.deployable" ng-disabled="$ctrl.state.actionInProgress || !$ctrl.formValues.name || !$ctrl.state.deployable || stackTemplateForm.$invalid"
ng-click="$ctrl.createTemplate()" ng-click="$ctrl.createTemplate()"
button-spinner="$ctrl.state.actionInProgress" button-spinner="$ctrl.state.actionInProgress"
> >

View File

@ -0,0 +1,24 @@
import angular from 'angular';
import { r2a } from '@/react-tools/react2angular';
import { CustomTemplatesVariablesDefinitionField } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField';
import { CustomTemplatesVariablesField } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
import { VariablesFieldAngular } from './variables-field';
export const customTemplatesModule = angular
.module('portainer.app.react.components.custom-templates', [])
.component(
'customTemplatesVariablesFieldReact',
r2a(CustomTemplatesVariablesField, ['value', 'onChange', 'definitions'])
)
.component('customTemplatesVariablesField', VariablesFieldAngular)
.component(
'customTemplatesVariablesDefinitionField',
r2a(CustomTemplatesVariablesDefinitionField, [
'onChange',
'value',
'errors',
'isVariablesNamesFromParent',
])
).name;

View File

@ -0,0 +1,71 @@
import {
IComponentOptions,
IComponentController,
IFormController,
IScope,
IOnChangesObject,
} from 'angular';
import { VariableDefinition } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField';
class VariablesFieldController implements IComponentController {
formCtrl!: IFormController;
value!: Record<string, string>;
definitions!: VariableDefinition[];
onChange!: (value: Record<string, string>) => void;
$scope: IScope;
/* @ngInject */
constructor($scope: IScope) {
this.handleChange = this.handleChange.bind(this);
this.$scope = $scope;
}
handleChange(value: Record<string, string>) {
this.$scope.$evalAsync(() => {
this.onChange(value);
});
}
$onChanges({ value }: IOnChangesObject) {
if (value.currentValue) {
this.checkValidity(value.currentValue);
}
}
checkValidity(value: Record<string, string>) {
this.formCtrl.$setValidity(
'templateVariables',
Object.entries(value).every(
([name, value]) =>
!!value ||
this.definitions.some(
(def) => def.name === name && !!def.defaultValue
)
),
this.formCtrl
);
}
}
export const VariablesFieldAngular: IComponentOptions = {
template: `<custom-templates-variables-field-react
value="$ctrl.value"
on-change="$ctrl.handleChange"
definitions="$ctrl.definitions"
></custom-templates-variables-field-react>`,
bindings: {
value: '<',
definitions: '<',
onChange: '<',
},
require: {
formCtrl: '^form',
},
controller: VariablesFieldController,
};

View File

@ -3,8 +3,10 @@ import angular from 'angular';
import { r2a } from '@/react-tools/react2angular'; import { r2a } from '@/react-tools/react2angular';
import { TagSelector } from '@/react/components/TagSelector'; import { TagSelector } from '@/react/components/TagSelector';
import { customTemplatesModule } from './custom-templates';
export const componentsModule = angular export const componentsModule = angular
.module('portainer.app.react.components', []) .module('portainer.app.react.components', [customTemplatesModule])
.component( .component(
'tagSelector', 'tagSelector',
r2a(TagSelector, ['allowCreate', 'onChange', 'value']) r2a(TagSelector, ['allowCreate', 'onChange', 'value'])

View File

@ -96,7 +96,23 @@
</div> </div>
<!-- !upload --> <!-- !upload -->
<!-- repository --> <!-- repository -->
<git-form ng-if="$ctrl.state.Method === 'repository'" model="$ctrl.formValues" on-change="($ctrl.onChangeFormValues)"></git-form> <git-form ng-if="$ctrl.state.Method === 'repository'" model="$ctrl.formValues" on-change="($ctrl.handleChange)"></git-form>
<div class="form-group" ng-if="!$ctrl.state.isTemplateValid">
<div class="col-sm-12">
<div class="small text-warning">
<i class="fa fa-exclamation-triangle space-right" aria-hidden="true"></i>
Template is invalid.
</div>
</div>
</div>
<custom-templates-variables-definition-field
value="$ctrl.formValues.Variables"
on-change="($ctrl.onVariablesChange)"
is-variables-names-from-parent="$ctrl.state.Method === 'editor'"
></custom-templates-variables-definition-field>
<!-- !repository --> <!-- !repository -->
<por-access-control-form form-data="$ctrl.formValues.AccessControlData"></por-access-control-form> <por-access-control-form form-data="$ctrl.formValues.AccessControlData"></por-access-control-form>
@ -111,7 +127,8 @@
|| ($ctrl.state.Method === 'editor' && !$ctrl.formValues.FileContent) || ($ctrl.state.Method === 'editor' && !$ctrl.formValues.FileContent)
|| ($ctrl.state.Method === 'upload' && !$ctrl.formValues.File) || ($ctrl.state.Method === 'upload' && !$ctrl.formValues.File)
|| ($ctrl.state.Method === 'repository' && ((!$ctrl.formValues.RepositoryURL || !$ctrl.formValues.ComposeFilePathInRepository) || ($ctrl.formValues.RepositoryAuthentication && (!$ctrl.formValues.RepositoryUsername || !$ctrl.formValues.RepositoryPassword)))) || ($ctrl.state.Method === 'repository' && ((!$ctrl.formValues.RepositoryURL || !$ctrl.formValues.ComposeFilePathInRepository) || ($ctrl.formValues.RepositoryAuthentication && (!$ctrl.formValues.RepositoryUsername || !$ctrl.formValues.RepositoryPassword))))
|| !$ctrl.formValues.Title" || !$ctrl.formValues.Title
|| !$ctrl.state.isTemplateValid"
ng-click="$ctrl.createCustomTemplate()" ng-click="$ctrl.createCustomTemplate()"
button-spinner="$ctrl.state.actionInProgress" button-spinner="$ctrl.state.actionInProgress"
> >

View File

@ -1,6 +1,7 @@
import _ from 'lodash'; import _ from 'lodash';
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel'; import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
import { TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants'; import { TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants';
import { getTemplateVariables, intersectVariables } from '@/react/portainer/custom-templates/components/utils';
class CreateCustomTemplateViewController { class CreateCustomTemplateViewController {
/* @ngInject */ /* @ngInject */
@ -35,6 +36,7 @@ class CreateCustomTemplateViewController {
Platform: 1, Platform: 1,
Type: 1, Type: 1,
AccessControlData: new AccessControlFormData(), AccessControlData: new AccessControlFormData(),
Variables: [],
}; };
this.state = { this.state = {
@ -45,6 +47,7 @@ class CreateCustomTemplateViewController {
loading: true, loading: true,
isEditorDirty: false, isEditorDirty: false,
templateNameRegex: TEMPLATE_NAME_VALIDATION_REGEX, templateNameRegex: TEMPLATE_NAME_VALIDATION_REGEX,
isTemplateValid: true,
}; };
this.templates = []; this.templates = [];
@ -58,7 +61,21 @@ class CreateCustomTemplateViewController {
this.createCustomTemplateFromGitRepository = this.createCustomTemplateFromGitRepository.bind(this); this.createCustomTemplateFromGitRepository = this.createCustomTemplateFromGitRepository.bind(this);
this.editorUpdate = this.editorUpdate.bind(this); this.editorUpdate = this.editorUpdate.bind(this);
this.onChangeMethod = this.onChangeMethod.bind(this); this.onChangeMethod = this.onChangeMethod.bind(this);
this.onChangeFormValues = this.onChangeFormValues.bind(this); this.onVariablesChange = this.onVariablesChange.bind(this);
this.handleChange = this.handleChange.bind(this);
}
onVariablesChange(value) {
this.handleChange({ Variables: value });
}
handleChange(values) {
return this.$async(async () => {
this.formValues = {
...this.formValues,
...values,
};
});
} }
createCustomTemplate() { createCustomTemplate() {
@ -67,6 +84,7 @@ class CreateCustomTemplateViewController {
onChangeMethod() { onChangeMethod() {
this.formValues.FileContent = ''; this.formValues.FileContent = '';
this.formValues.Variables = [];
this.selectedTemplate = null; this.selectedTemplate = null;
} }
@ -151,12 +169,22 @@ class CreateCustomTemplateViewController {
} }
editorUpdate(cm) { editorUpdate(cm) {
this.formValues.FileContent = cm.getValue(); const value = cm.getValue();
this.formValues.FileContent = value;
this.state.isEditorDirty = true; this.state.isEditorDirty = true;
this.parseTemplate(value);
} }
onChangeFormValues(newValues) { parseTemplate(templateStr) {
this.formValues = newValues; const variables = getTemplateVariables(templateStr);
const isValid = !!variables;
this.state.isTemplateValid = isValid;
if (isValid) {
this.onVariablesChange(intersectVariables(this.formValues.Variables, variables));
}
} }
async $onInit() { async $onInit() {

View File

@ -17,6 +17,12 @@
unselect-template="$ctrl.unselectTemplate" unselect-template="$ctrl.unselectTemplate"
> >
<advanced-form> <advanced-form>
<custom-templates-variables-field
definitions="$ctrl.state.selectedTemplate.Variables"
value="$ctrl.formValues.variables"
on-change="($ctrl.onChangeTemplateVariables)"
></custom-templates-variables-field>
<div class="form-group"> <div class="form-group">
<div class="col-sm-12"> <div class="col-sm-12">
<a class="small interactive" ng-show="!$ctrl.state.showAdvancedOptions" ng-click="$ctrl.state.showAdvancedOptions = true;"> <a class="small interactive" ng-show="!$ctrl.state.showAdvancedOptions" ng-click="$ctrl.state.showAdvancedOptions = true;">

View File

@ -1,6 +1,7 @@
import _ from 'lodash-es'; import _ from 'lodash-es';
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel'; import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
import { TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants'; import { TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants';
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
class CustomTemplatesViewController { class CustomTemplatesViewController {
/* @ngInject */ /* @ngInject */
@ -44,6 +45,7 @@ class CustomTemplatesViewController {
isEditorVisible: false, isEditorVisible: false,
deployable: false, deployable: false,
templateNameRegex: TEMPLATE_NAME_VALIDATION_REGEX, templateNameRegex: TEMPLATE_NAME_VALIDATION_REGEX,
templateContent: '',
}; };
this.currentUser = { this.currentUser = {
@ -56,6 +58,7 @@ class CustomTemplatesViewController {
name: '', name: '',
fileContent: '', fileContent: '',
AccessControlData: new AccessControlFormData(), AccessControlData: new AccessControlFormData(),
variables: [],
}; };
this.getTemplates = this.getTemplates.bind(this); this.getTemplates = this.getTemplates.bind(this);
@ -75,6 +78,8 @@ class CustomTemplatesViewController {
this.confirmDeleteAsync = this.confirmDeleteAsync.bind(this); this.confirmDeleteAsync = this.confirmDeleteAsync.bind(this);
this.editorUpdate = this.editorUpdate.bind(this); this.editorUpdate = this.editorUpdate.bind(this);
this.isEditAllowed = this.isEditAllowed.bind(this); this.isEditAllowed = this.isEditAllowed.bind(this);
this.onChangeFormValues = this.onChangeFormValues.bind(this);
this.onChangeTemplateVariables = this.onChangeTemplateVariables.bind(this);
} }
isEditAllowed(template) { isEditAllowed(template) {
@ -107,6 +112,24 @@ class CustomTemplatesViewController {
} }
} }
onChangeTemplateVariables(variables) {
this.onChangeFormValues({ variables });
this.renderTemplate();
}
renderTemplate() {
const fileContent = renderTemplate(this.state.templateContent, this.formValues.variables, this.state.selectedTemplate.Variables);
this.onChangeFormValues({ fileContent });
}
onChangeFormValues(values) {
this.formValues = {
...this.formValues,
...values,
};
}
validateForm(accessControlData, isAdmin) { validateForm(accessControlData, isAdmin) {
this.state.formValidationError = ''; this.state.formValidationError = '';
const error = this.FormValidator.validateAccessControl(accessControlData, isAdmin); const error = this.FormValidator.validateAccessControl(accessControlData, isAdmin);
@ -161,6 +184,7 @@ class CustomTemplatesViewController {
name: '', name: '',
fileContent: '', fileContent: '',
AccessControlData: new AccessControlFormData(), AccessControlData: new AccessControlFormData(),
variables: [],
}; };
} }
@ -184,7 +208,13 @@ class CustomTemplatesViewController {
const applicationState = this.StateManager.getState(); const applicationState = this.StateManager.getState();
this.state.deployable = this.isDeployable(applicationState.endpoint, template.Type); this.state.deployable = this.isDeployable(applicationState.endpoint, template.Type);
const file = await this.CustomTemplateService.customTemplateFile(template.Id); const file = await this.CustomTemplateService.customTemplateFile(template.Id);
this.state.templateContent = file;
this.formValues.fileContent = file; this.formValues.fileContent = file;
if (template.Variables && template.Variables.length > 0) {
const variables = Object.fromEntries(template.Variables.map((variable) => [variable.name, '']));
this.onChangeTemplateVariables(variables);
}
} }
getNetworks(provider, apiVersion) { getNetworks(provider, apiVersion) {

View File

@ -36,6 +36,21 @@
</div> </div>
<!-- !web-editor --> <!-- !web-editor -->
<div class="form-group" ng-if="!$ctrl.state.isTemplateValid">
<div class="col-sm-12">
<div class="small text-warning">
<i class="fa fa-exclamation-triangle space-right" aria-hidden="true"></i>
Template is invalid.
</div>
</div>
</div>
<custom-templates-variables-definition-field
value="$ctrl.formValues.Variables"
on-change="($ctrl.onVariablesChange)"
is-variables-names-from-parent="true"
></custom-templates-variables-definition-field>
<por-access-control-form form-data="$ctrl.formValues.AccessControlData" resource-control="$ctrl.formValues.ResourceControl"></por-access-control-form> <por-access-control-form form-data="$ctrl.formValues.AccessControlData" resource-control="$ctrl.formValues.ResourceControl"></por-access-control-form>
<div class="col-sm-12 form-section-title"> Actions </div> <div class="col-sm-12 form-section-title"> Actions </div>
@ -46,7 +61,9 @@
class="btn btn-primary btn-sm" class="btn btn-primary btn-sm"
ng-disabled="$ctrl.actionInProgress || customTemplateForm.$invalid ng-disabled="$ctrl.actionInProgress || customTemplateForm.$invalid
|| !$ctrl.formValues.Title || !$ctrl.formValues.Title
|| !$ctrl.formValues.FileContent" || !$ctrl.formValues.FileContent
|| !$ctrl.state.isTemplateValid
"
ng-click="$ctrl.submitAction()" ng-click="$ctrl.submitAction()"
button-spinner="$ctrl.actionInProgress" button-spinner="$ctrl.actionInProgress"
> >

View File

@ -2,6 +2,7 @@ import _ from 'lodash';
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel'; import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel'; import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
import { getTemplateVariables, intersectVariables } from '@/react/portainer/custom-templates/components/utils';
class EditCustomTemplateViewController { class EditCustomTemplateViewController {
/* @ngInject */ /* @ngInject */
@ -12,6 +13,7 @@ class EditCustomTemplateViewController {
this.state = { this.state = {
formValidationError: '', formValidationError: '',
isEditorDirty: false, isEditorDirty: false,
isTemplateValid: true,
}; };
this.templates = []; this.templates = [];
@ -20,6 +22,8 @@ class EditCustomTemplateViewController {
this.submitAction = this.submitAction.bind(this); this.submitAction = this.submitAction.bind(this);
this.submitActionAsync = this.submitActionAsync.bind(this); this.submitActionAsync = this.submitActionAsync.bind(this);
this.editorUpdate = this.editorUpdate.bind(this); this.editorUpdate = this.editorUpdate.bind(this);
this.onVariablesChange = this.onVariablesChange.bind(this);
this.handleChange = this.handleChange.bind(this);
} }
getTemplate() { getTemplate() {
@ -32,15 +36,33 @@ class EditCustomTemplateViewController {
this.CustomTemplateService.customTemplateFile(this.$state.params.id), this.CustomTemplateService.customTemplateFile(this.$state.params.id),
]); ]);
template.FileContent = file; template.FileContent = file;
template.Variables = template.Variables || [];
this.formValues = template; this.formValues = template;
this.parseTemplate(template.FileContent);
this.oldFileContent = this.formValues.FileContent; this.oldFileContent = this.formValues.FileContent;
if (template.ResourceControl) {
this.formValues.ResourceControl = new ResourceControlViewModel(template.ResourceControl); this.formValues.ResourceControl = new ResourceControlViewModel(template.ResourceControl);
}
this.formValues.AccessControlData = new AccessControlFormData(); this.formValues.AccessControlData = new AccessControlFormData();
} catch (err) { } catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve custom template data'); this.Notifications.error('Failure', err, 'Unable to retrieve custom template data');
} }
} }
onVariablesChange(value) {
this.handleChange({ Variables: value });
}
handleChange(values) {
return this.$async(async () => {
this.formValues = {
...this.formValues,
...values,
};
});
}
validateForm() { validateForm() {
this.state.formValidationError = ''; this.state.formValidationError = '';
@ -96,12 +118,26 @@ class EditCustomTemplateViewController {
} }
editorUpdate(cm) { editorUpdate(cm) {
if (this.formValues.FileContent.replace(/(\r\n|\n|\r)/gm, '') !== cm.getValue().replace(/(\r\n|\n|\r)/gm, '')) { const value = cm.getValue();
this.formValues.FileContent = cm.getValue(); if (this.formValues.FileContent.replace(/(\r\n|\n|\r)/gm, '') !== value.replace(/(\r\n|\n|\r)/gm, '')) {
this.formValues.FileContent = value;
this.parseTemplate(value);
this.state.isEditorDirty = true; this.state.isEditorDirty = true;
} }
} }
parseTemplate(templateStr) {
const variables = getTemplateVariables(templateStr);
const isValid = !!variables;
this.state.isTemplateValid = isValid;
if (isValid) {
this.onVariablesChange(intersectVariables(this.formValues.Variables, variables));
}
}
async uiCanExit() { async uiCanExit() {
if (this.formValues.FileContent !== this.oldFileContent && this.state.isEditorDirty) { if (this.formValues.FileContent !== this.oldFileContent && this.state.isEditorDirty) {
return this.ModalService.confirmWebEditorDiscard(); return this.ModalService.confirmWebEditorDiscard();

View File

@ -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({});

View File

@ -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 });
}
}

View File

@ -0,0 +1 @@
export { CustomTemplatesVariablesDefinitionField } from './CustomTemplatesVariablesDefinitionField';

View File

@ -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({});

View File

@ -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>
);
}

View File

@ -0,0 +1 @@
export { CustomTemplatesVariablesField } from './CustomTemplatesVariablesField';

View 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);
}

View File

@ -118,6 +118,7 @@
"lodash": "^4.17.21", "lodash": "^4.17.21",
"moment": "^2.29.1", "moment": "^2.29.1",
"msw": "^0.36.3", "msw": "^0.36.3",
"mustache": "^4.2.0",
"ng-file-upload": "~12.2.13", "ng-file-upload": "~12.2.13",
"parse-duration": "^1.0.2", "parse-duration": "^1.0.2",
"rc-slider": "^9.7.5", "rc-slider": "^9.7.5",
@ -164,6 +165,7 @@
"@types/file-saver": "^2.0.4", "@types/file-saver": "^2.0.4",
"@types/jest": "^27.0.3", "@types/jest": "^27.0.3",
"@types/jquery": "^3.5.10", "@types/jquery": "^3.5.10",
"@types/mustache": "^4.1.2",
"@types/nprogress": "^0.2.0", "@types/nprogress": "^0.2.0",
"@types/react": "^17.0.37", "@types/react": "^17.0.37",
"@types/react-dom": "^17.0.11", "@types/react-dom": "^17.0.11",

View File

@ -3346,6 +3346,11 @@
resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.5.tgz#1001cc5e6a3704b83c236027e77f2f58ea010f40" resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.5.tgz#1001cc5e6a3704b83c236027e77f2f58ea010f40"
integrity sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ== integrity sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==
"@types/mustache@^4.1.2":
version "4.1.2"
resolved "https://registry.yarnpkg.com/@types/mustache/-/mustache-4.1.2.tgz#d0e158013c81674a5b6d8780bc3fe234e1804eaf"
integrity sha512-c4OVMMcyodKQ9dpwBwh3ofK9P6U9ZktKU9S+p33UqwMNN1vlv2P0zJZUScTshnx7OEoIIRcCFNQ904sYxZz8kg==
"@types/node-fetch@^2.5.7": "@types/node-fetch@^2.5.7":
version "2.5.12" version "2.5.12"
resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.5.12.tgz#8a6f779b1d4e60b7a57fb6fd48d84fb545b9cc66" resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.5.12.tgz#8a6f779b1d4e60b7a57fb6fd48d84fb545b9cc66"
@ -12304,6 +12309,11 @@ multimatch@^2.0.0:
arrify "^1.0.0" arrify "^1.0.0"
minimatch "^3.0.0" minimatch "^3.0.0"
mustache@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/mustache/-/mustache-4.2.0.tgz#e5892324d60a12ec9c2a73359edca52972bf6f64"
integrity sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==
mute-stream@0.0.5: mute-stream@0.0.5:
version "0.0.5" version "0.0.5"
resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.5.tgz#8fbfabb0a98a253d3184331f9e8deb7372fac6c0" resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.5.tgz#8fbfabb0a98a253d3184331f9e8deb7372fac6c0"