1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-08 15:25:22 +02:00

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

This commit is contained in:
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

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