refactor(custom-templates): render template variables [EE-2602] (#6937)
parent
71c0e8e661
commit
1ccdb64938
|
@ -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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -74,7 +74,7 @@ function SelectAndInputItem({
|
|||
onChange: (value: ListWithSelectItem) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={item.value}
|
||||
|
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
>
|
||||
|
|
|
@ -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;
|
|
@ -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,
|
||||
};
|
|
@ -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'])
|
||||
|
|
|
@ -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"
|
||||
>
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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;">
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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"
|
||||
>
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
import { ComponentMeta } from '@storybook/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
CustomTemplatesVariablesDefinitionField,
|
||||
VariableDefinition,
|
||||
} from './CustomTemplatesVariablesDefinitionField';
|
||||
|
||||
export default {
|
||||
title: 'Custom Templates/Variables Definition Field',
|
||||
component: CustomTemplatesVariablesDefinitionField,
|
||||
args: {},
|
||||
} as ComponentMeta<typeof CustomTemplatesVariablesDefinitionField>;
|
||||
|
||||
function Template() {
|
||||
const [value, setValue] = useState<VariableDefinition[]>([
|
||||
{ label: '', name: '', defaultValue: '', description: '' },
|
||||
]);
|
||||
|
||||
return (
|
||||
<CustomTemplatesVariablesDefinitionField
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
errors={[
|
||||
{
|
||||
name: 'required',
|
||||
defaultValue: 'non empty',
|
||||
description: '',
|
||||
label: 'invalid',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const Story = Template.bind({});
|
|
@ -0,0 +1,105 @@
|
|||
import { FormError } from '@/portainer/components/form-components/FormError';
|
||||
import { Input } from '@/portainer/components/form-components/Input';
|
||||
import { InputList } from '@/portainer/components/form-components/InputList';
|
||||
import {
|
||||
InputListError,
|
||||
ItemProps,
|
||||
} from '@/portainer/components/form-components/InputList/InputList';
|
||||
|
||||
export interface VariableDefinition {
|
||||
name: string;
|
||||
label: string;
|
||||
defaultValue: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: VariableDefinition[];
|
||||
onChange: (value: VariableDefinition[]) => void;
|
||||
errors?: InputListError<VariableDefinition>[] | string;
|
||||
isVariablesNamesFromParent?: boolean;
|
||||
}
|
||||
|
||||
export function CustomTemplatesVariablesDefinitionField({
|
||||
onChange,
|
||||
value,
|
||||
errors,
|
||||
isVariablesNamesFromParent,
|
||||
}: Props) {
|
||||
return (
|
||||
<InputList
|
||||
label="Variables Definition"
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
renderItem={(item, onChange, error) => (
|
||||
<Item
|
||||
item={item}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
isNameReadonly={isVariablesNamesFromParent}
|
||||
/>
|
||||
)}
|
||||
itemBuilder={() => ({
|
||||
label: '',
|
||||
name: '',
|
||||
defaultValue: '',
|
||||
description: '',
|
||||
})}
|
||||
errors={errors}
|
||||
textTip="List should map the mustache variables in the template file, if default value is empty, the variable will be required."
|
||||
isAddButtonHidden={isVariablesNamesFromParent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface DefinitionItemProps extends ItemProps<VariableDefinition> {
|
||||
isNameReadonly?: boolean;
|
||||
}
|
||||
|
||||
function Item({ item, onChange, error, isNameReadonly }: DefinitionItemProps) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<div>
|
||||
<Input
|
||||
value={item.name}
|
||||
name="name"
|
||||
onChange={handleChange}
|
||||
placeholder="Name (e.g var_name)"
|
||||
readOnly={isNameReadonly}
|
||||
/>
|
||||
{error?.name && <FormError>{error.name}</FormError>}
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
value={item.label}
|
||||
onChange={handleChange}
|
||||
placeholder="Label"
|
||||
name="label"
|
||||
/>
|
||||
{error?.label && <FormError>{error.label}</FormError>}
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
name="description"
|
||||
value={item.description}
|
||||
onChange={handleChange}
|
||||
placeholder="Description"
|
||||
/>
|
||||
{error?.description && <FormError>{error.description}</FormError>}
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
value={item.defaultValue}
|
||||
onChange={handleChange}
|
||||
placeholder="Default Value"
|
||||
name="defaultValue"
|
||||
/>
|
||||
{error?.defaultValue && <FormError>{error.defaultValue}</FormError>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
onChange({ ...item, [e.target.name]: e.target.value });
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { CustomTemplatesVariablesDefinitionField } from './CustomTemplatesVariablesDefinitionField';
|
|
@ -0,0 +1,52 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
import { VariableDefinition } from '../CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField';
|
||||
|
||||
import {
|
||||
CustomTemplatesVariablesField,
|
||||
Variables,
|
||||
} from './CustomTemplatesVariablesField';
|
||||
|
||||
export default {
|
||||
title: 'Custom Templates/Variables Field',
|
||||
component: CustomTemplatesVariablesField,
|
||||
};
|
||||
|
||||
const definitions: VariableDefinition[] = [
|
||||
{
|
||||
label: 'Image Name',
|
||||
name: 'image_name',
|
||||
defaultValue: 'nginx',
|
||||
description: '',
|
||||
},
|
||||
{
|
||||
label: 'Required field',
|
||||
name: 'required_field',
|
||||
defaultValue: '',
|
||||
description: '',
|
||||
},
|
||||
{
|
||||
label: 'Required field with tooltip',
|
||||
name: 'required_field',
|
||||
defaultValue: '',
|
||||
description: 'tooltip',
|
||||
},
|
||||
];
|
||||
|
||||
function Template() {
|
||||
const [value, setValue] = useState<Variables>(
|
||||
Object.fromEntries(
|
||||
definitions.map((def) => [def.name, def.defaultValue || ''])
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<CustomTemplatesVariablesField
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
definitions={definitions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const Story = Template.bind({});
|
|
@ -0,0 +1,54 @@
|
|||
import { FormControl } from '@/portainer/components/form-components/FormControl';
|
||||
import { FormSection } from '@/portainer/components/form-components/FormSection/FormSection';
|
||||
import { Input } from '@/portainer/components/form-components/Input';
|
||||
|
||||
import { VariableDefinition } from '../CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField';
|
||||
|
||||
export type Variables = Record<string, string>;
|
||||
|
||||
interface Props {
|
||||
value: Variables;
|
||||
definitions?: VariableDefinition[];
|
||||
onChange: (value: Variables) => void;
|
||||
}
|
||||
|
||||
export function CustomTemplatesVariablesField({
|
||||
value,
|
||||
definitions,
|
||||
onChange,
|
||||
}: Props) {
|
||||
if (!definitions || !definitions.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FormSection title="Template Variables">
|
||||
{definitions.map((def) => {
|
||||
const inputId = `${def.name}-input`;
|
||||
const variable = value[def.name] || '';
|
||||
return (
|
||||
<FormControl
|
||||
required={!def.defaultValue}
|
||||
label={def.label}
|
||||
key={def.name}
|
||||
inputId={inputId}
|
||||
tooltip={def.description}
|
||||
size="small"
|
||||
>
|
||||
<Input
|
||||
name={`variables.${def.name}`}
|
||||
value={variable}
|
||||
id={inputId}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...value,
|
||||
[def.name]: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
})}
|
||||
</FormSection>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { CustomTemplatesVariablesField } from './CustomTemplatesVariablesField';
|
|
@ -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);
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
10
yarn.lock
10
yarn.lock
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue