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

View File

@ -31,6 +31,8 @@ type customTemplateUpdatePayload struct {
Type portainer.StackType `example:"1" enums:"1,2,3" validate:"required"`
// Content of stack file
FileContent string `validate:"required"`
// Definitions of variables in the stack file
Variables []portainer.CustomTemplateVariableDefinition
}
func (payload *customTemplateUpdatePayload) Validate(r *http.Request) error {
@ -52,6 +54,12 @@ func (payload *customTemplateUpdatePayload) Validate(r *http.Request) error {
if !isValidNote(payload.Note) {
return errors.New("Invalid note. <img> tag is not supported")
}
err := validateVariablesDefinitions(payload.Variables)
if err != nil {
return err
}
return nil
}
@ -124,6 +132,7 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ
customTemplate.Note = payload.Note
customTemplate.Platform = payload.Platform
customTemplate.Type = payload.Type
customTemplate.Variables = payload.Variables
err = handler.DataStore.CustomTemplate().UpdateCustomTemplate(customTemplate.ID, customTemplate)
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
}
// 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 struct {
// CustomTemplate Identifier
@ -152,6 +160,7 @@ type (
// Type of created stack (1 - swarm, 2 - compose)
Type StackType `json:"Type" example:"1"`
ResourceControl *ResourceControl `json:"ResourceControl"`
Variables []CustomTemplateVariableDefinition
}
// 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>) {
return (
<div className={styles.item}>
<div className={styles.inputs}>
<div className="flex items-center gap-2">
<InputGroup size="small">
<InputGroup.Addon>host</InputGroup.Addon>
<InputGroup.Input
@ -57,7 +57,7 @@ function Item({ onChange, item, error }: ItemProps<PortMapping>) {
/>
</InputGroup>
<span style={{ margin: '0 10px 0 10px' }}>
<span className="mx-3">
<i className="fa fa-long-arrow-alt-right" aria-hidden="true" />
</span>

View File

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

View File

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

View File

@ -31,6 +31,12 @@
</editor-description>
</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>
<div class="col-sm-12 form-section-title"> Actions </div>

View File

@ -84,13 +84,21 @@
></git-form>
<!-- !repository -->
<custom-template-selector
ng-show="ctrl.state.BuildMethod === ctrl.BuildMethods.CUSTOM_TEMPLATE"
new-template-path="kubernetes.templates.custom.new"
stack-type="3"
on-change="(ctrl.onChangeTemplateId)"
value="ctrl.state.templateId"
></custom-template-selector>
<div ng-show="ctrl.state.BuildMethod === ctrl.BuildMethods.CUSTOM_TEMPLATE">
<custom-template-selector
new-template-path="kubernetes.templates.custom.new"
stack-type="3"
on-change="(ctrl.onChangeTemplateId)"
value="ctrl.state.templateId"
></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 -->
<web-editor-form

View File

@ -2,10 +2,11 @@ import angular from 'angular';
import _ from 'lodash-es';
import stripAnsi from 'strip-ansi';
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 { buildOption } from '@/portainer/components/BoxSelector';
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
class KubernetesDeployController {
/* @ngInject */
@ -42,6 +43,7 @@ class KubernetesDeployController {
viewReady: false,
isEditorDirty: false,
templateId: null,
template: null,
};
this.formValues = {
@ -56,7 +58,8 @@ class KubernetesDeployController {
RepositoryAutomaticUpdates: false,
RepositoryMechanism: RepositoryMechanismTypes.INTERVAL,
RepositoryFetchInterval: '5m',
RepositoryWebhookURL: this.WebhookHelper.returnStackWebhookUrl(uuidv4()),
RepositoryWebhookURL: WebhookHelper.returnStackWebhookUrl(uuidv4()),
Variables: {},
};
this.ManifestDeployTypes = KubernetesDeployManifestTypes;
@ -70,6 +73,18 @@ class KubernetesDeployController {
this.buildAnalyticsProperties = this.buildAnalyticsProperties.bind(this);
this.onChangeMethod = this.onChangeMethod.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() {
@ -157,17 +172,24 @@ class KubernetesDeployController {
};
}
onChangeTemplateId(templateId) {
onChangeTemplateId(templateId, template) {
return this.$async(async () => {
if (this.state.templateId === templateId) {
if (!template || (this.state.templateId === templateId && this.state.template === template)) {
return;
}
this.state.templateId = templateId;
this.state.template = template;
try {
const fileContent = await this.CustomTemplateService.customTemplateFile(templateId);
this.state.templateContent = 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) {
this.Notifications.error('Failure', err, 'Unable to load template file');
}
@ -307,7 +329,7 @@ class KubernetesDeployController {
const templateId = parseInt(this.$state.params.templateId, 10);
if (templateId && !Number.isNaN(templateId)) {
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;
}) {
return (
<div>
<div className="flex gap-2">
<Input
type="number"
value={item.value}

View File

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

View File

@ -96,7 +96,23 @@
</div>
<!-- !upload -->
<!-- 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 -->
<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 === 'upload' && !$ctrl.formValues.File)
|| ($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()"
button-spinner="$ctrl.state.actionInProgress"
>

View File

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

View File

@ -17,6 +17,12 @@
unselect-template="$ctrl.unselectTemplate"
>
<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="col-sm-12">
<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 { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
import { TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants';
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
class CustomTemplatesViewController {
/* @ngInject */
@ -44,6 +45,7 @@ class CustomTemplatesViewController {
isEditorVisible: false,
deployable: false,
templateNameRegex: TEMPLATE_NAME_VALIDATION_REGEX,
templateContent: '',
};
this.currentUser = {
@ -56,6 +58,7 @@ class CustomTemplatesViewController {
name: '',
fileContent: '',
AccessControlData: new AccessControlFormData(),
variables: [],
};
this.getTemplates = this.getTemplates.bind(this);
@ -75,6 +78,8 @@ class CustomTemplatesViewController {
this.confirmDeleteAsync = this.confirmDeleteAsync.bind(this);
this.editorUpdate = this.editorUpdate.bind(this);
this.isEditAllowed = this.isEditAllowed.bind(this);
this.onChangeFormValues = this.onChangeFormValues.bind(this);
this.onChangeTemplateVariables = this.onChangeTemplateVariables.bind(this);
}
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) {
this.state.formValidationError = '';
const error = this.FormValidator.validateAccessControl(accessControlData, isAdmin);
@ -161,6 +184,7 @@ class CustomTemplatesViewController {
name: '',
fileContent: '',
AccessControlData: new AccessControlFormData(),
variables: [],
};
}
@ -184,7 +208,13 @@ class CustomTemplatesViewController {
const applicationState = this.StateManager.getState();
this.state.deployable = this.isDeployable(applicationState.endpoint, template.Type);
const file = await this.CustomTemplateService.customTemplateFile(template.Id);
this.state.templateContent = 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) {

View File

@ -36,6 +36,21 @@
</div>
<!-- !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>
<div class="col-sm-12 form-section-title"> Actions </div>
@ -46,7 +61,9 @@
class="btn btn-primary btn-sm"
ng-disabled="$ctrl.actionInProgress || customTemplateForm.$invalid
|| !$ctrl.formValues.Title
|| !$ctrl.formValues.FileContent"
|| !$ctrl.formValues.FileContent
|| !$ctrl.state.isTemplateValid
"
ng-click="$ctrl.submitAction()"
button-spinner="$ctrl.actionInProgress"
>

View File

@ -2,6 +2,7 @@ import _ from 'lodash';
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
import { getTemplateVariables, intersectVariables } from '@/react/portainer/custom-templates/components/utils';
class EditCustomTemplateViewController {
/* @ngInject */
@ -12,6 +13,7 @@ class EditCustomTemplateViewController {
this.state = {
formValidationError: '',
isEditorDirty: false,
isTemplateValid: true,
};
this.templates = [];
@ -20,6 +22,8 @@ class EditCustomTemplateViewController {
this.submitAction = this.submitAction.bind(this);
this.submitActionAsync = this.submitActionAsync.bind(this);
this.editorUpdate = this.editorUpdate.bind(this);
this.onVariablesChange = this.onVariablesChange.bind(this);
this.handleChange = this.handleChange.bind(this);
}
getTemplate() {
@ -32,15 +36,33 @@ class EditCustomTemplateViewController {
this.CustomTemplateService.customTemplateFile(this.$state.params.id),
]);
template.FileContent = file;
template.Variables = template.Variables || [];
this.formValues = template;
this.parseTemplate(template.FileContent);
this.oldFileContent = this.formValues.FileContent;
this.formValues.ResourceControl = new ResourceControlViewModel(template.ResourceControl);
if (template.ResourceControl) {
this.formValues.ResourceControl = new ResourceControlViewModel(template.ResourceControl);
}
this.formValues.AccessControlData = new AccessControlFormData();
} catch (err) {
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() {
this.state.formValidationError = '';
@ -96,12 +118,26 @@ class EditCustomTemplateViewController {
}
editorUpdate(cm) {
if (this.formValues.FileContent.replace(/(\r\n|\n|\r)/gm, '') !== cm.getValue().replace(/(\r\n|\n|\r)/gm, '')) {
this.formValues.FileContent = cm.getValue();
const value = 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;
}
}
parseTemplate(templateStr) {
const variables = getTemplateVariables(templateStr);
const isValid = !!variables;
this.state.isTemplateValid = isValid;
if (isValid) {
this.onVariablesChange(intersectVariables(this.formValues.Variables, variables));
}
}
async uiCanExit() {
if (this.formValues.FileContent !== this.oldFileContent && this.state.isEditorDirty) {
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",
"moment": "^2.29.1",
"msw": "^0.36.3",
"mustache": "^4.2.0",
"ng-file-upload": "~12.2.13",
"parse-duration": "^1.0.2",
"rc-slider": "^9.7.5",
@ -164,6 +165,7 @@
"@types/file-saver": "^2.0.4",
"@types/jest": "^27.0.3",
"@types/jquery": "^3.5.10",
"@types/mustache": "^4.1.2",
"@types/nprogress": "^0.2.0",
"@types/react": "^17.0.37",
"@types/react-dom": "^17.0.11",
@ -258,4 +260,4 @@
}
},
"browserslist": "last 2 versions"
}
}

View File

@ -3346,6 +3346,11 @@
resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.5.tgz#1001cc5e6a3704b83c236027e77f2f58ea010f40"
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":
version "2.5.12"
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"
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:
version "0.0.5"
resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.5.tgz#8fbfabb0a98a253d3184331f9e8deb7372fac6c0"