diff --git a/packages/core/src/forms/forms.module.js b/packages/core/src/forms/forms.module.js index 39b128285a4..c4ff56b08da 100644 --- a/packages/core/src/forms/forms.module.js +++ b/packages/core/src/forms/forms.module.js @@ -7,6 +7,7 @@ import { CORE_FORMS_CHECKLIST_CHECKLIST_DIRECTIVE } from './checklist/checklist. import { CORE_FORMS_CHECKMAP_CHECKMAP_DIRECTIVE } from './checkmap/checkmap.directive'; import { CORE_FORMS_IGNOREEMPTYDELETE_DIRECTIVE } from './ignoreEmptyDelete.directive'; import { CORE_FORMS_MAPEDITOR_MAPEDITOR_COMPONENT } from './mapEditor/mapEditor.component'; +import { CORE_FORMS_MAPOBJECTEDITOR_MAPOBJECTEDITOR_COMPONENT } from './mapObjectEditor/mapObjectEditor.component'; import { NUMBER_LIST_COMPONENT } from './numberList/numberList.component'; import { CORE_FORMS_VALIDATEONSUBMIT_VALIDATEONSUBMIT_DIRECTIVE } from './validateOnSubmit/validateOnSubmit.directive'; @@ -18,6 +19,7 @@ module(CORE_FORMS_FORMS_MODULE, [ CORE_FORMS_CHECKMAP_CHECKMAP_DIRECTIVE, CORE_FORMS_IGNOREEMPTYDELETE_DIRECTIVE, CORE_FORMS_MAPEDITOR_MAPEDITOR_COMPONENT, + CORE_FORMS_MAPOBJECTEDITOR_MAPOBJECTEDITOR_COMPONENT, CORE_FORMS_VALIDATEONSUBMIT_VALIDATEONSUBMIT_DIRECTIVE, NUMBER_LIST_COMPONENT, ]); diff --git a/packages/core/src/forms/mapObjectEditor/mapObjectEditor.component.html b/packages/core/src/forms/mapObjectEditor/mapObjectEditor.component.html new file mode 100644 index 00000000000..c0a0c6b3f58 --- /dev/null +++ b/packages/core/src/forms/mapObjectEditor/mapObjectEditor.component.html @@ -0,0 +1,56 @@ +
+
+ {{ $ctrl.label }} +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ {{ $ctrl.keyLabel }} + + +
Duplicate key
+
+ {{ $ctrl.valueLabel }} + + + + +
+ +
+
diff --git a/packages/core/src/forms/mapObjectEditor/mapObjectEditor.component.js b/packages/core/src/forms/mapObjectEditor/mapObjectEditor.component.js new file mode 100644 index 00000000000..651e0980627 --- /dev/null +++ b/packages/core/src/forms/mapObjectEditor/mapObjectEditor.component.js @@ -0,0 +1,113 @@ +'use strict'; + +import * as angular from 'angular'; +import { isString } from 'lodash'; + +import { CORE_VALIDATION_VALIDATEUNIQUE_DIRECTIVE } from '../../validation/validateUnique.directive'; + +import './mapObjectEditor.component.less'; + +export const CORE_FORMS_MAPOBJECTEDITOR_MAPOBJECTEDITOR_COMPONENT = 'spinnaker.core.forms.mapObjectEditor.component'; +export const name = CORE_FORMS_MAPOBJECTEDITOR_MAPOBJECTEDITOR_COMPONENT; // for backwards compatibility +angular + .module(CORE_FORMS_MAPOBJECTEDITOR_MAPOBJECTEDITOR_COMPONENT, [CORE_VALIDATION_VALIDATEUNIQUE_DIRECTIVE]) + .directive('jsonText', function () { + return { + restrict: 'A', + require: 'ngModel', + link: function (scope, element, attr, ngModel) { + function into(input) { + return JSON.parse(input); + } + function out(data) { + return JSON.stringify(data, null, 2); + } + ngModel.$parsers.push(into); + ngModel.$formatters.push(out); + }, + }; + }) + .component('mapObjectEditor', { + bindings: { + model: '=', + keyLabel: '@', + valueLabel: '@', + addButtonLabel: '@', + allowEmpty: '=?', + onChange: '&', + labelsLeft: ' Object.keys(this.model); + + this.addField = () => { + this.backingModel.push({ key: '', value: {}, checkUnique: modelKeys() }); + // do not fire the onChange event, since no values have been committed to the object + }; + + this.removeField = (index) => { + this.backingModel.splice(index, 1); + this.synchronize(); + this.onChange(); + }; + + // Clears existing values from model, then replaces them + this.synchronize = () => { + if (this.isParameterized) { + return; + } + const modelStart = JSON.stringify(this.model); + const allKeys = this.backingModel.map((pair) => pair.key); + modelKeys().forEach((key) => delete this.model[key]); + this.backingModel.forEach((pair) => { + if (pair.key && (this.allowEmpty || pair.value)) { + try { + // Parse value if it is a valid JSON object + this.model[pair.key] = JSON.parse(pair.value); + } catch (e) { + // If value is not a valid JSON object, just store the raw value + this.model[pair.key] = pair.value; + } + } + // include other keys to verify no duplicates + pair.checkUnique = allKeys.filter((key) => pair.key !== key); + }); + if (modelStart !== JSON.stringify(this.model)) { + this.onChange(); + } + }; + + // In Angular 1.7 Directive bindings were removed in the constructor, default values now must be instantiated within $onInit + // See https://docs.angularjs.org/guide/migration#-compile- and https://docs.angularjs.org/guide/migration#migrate1.5to1.6-ng-services-$compile + this.$onInit = () => { + // Set default values for optional fields + this.onChange = this.onChange || angular.noop; + this.keyLabel = this.keyLabel || 'Key'; + this.valueLabel = this.valueLabel || 'Value'; + this.addButtonLabel = this.addButtonLabel || 'Add Field'; + this.allowEmpty = this.allowEmpty || false; + this.labelsLeft = this.labelsLeft || false; + this.tableClass = this.label ? '' : 'no-border-top'; + this.columnCount = this.labelsLeft ? 5 : 3; + this.model = this.model || {}; + this.isParameterized = isString(this.model); + this.hiddenKeys = this.hiddenKeys || []; + + if (this.model && !this.isParameterized) { + modelKeys().forEach((key) => { + this.backingModel.push({ key: key, value: this.model[key] }); + }); + } + }; + + $scope.$watch(() => JSON.stringify(this.backingModel), this.synchronize); + }, + ], + templateUrl: require('./mapObjectEditor.component.html'), + }); diff --git a/packages/core/src/forms/mapObjectEditor/mapObjectEditor.component.less b/packages/core/src/forms/mapObjectEditor/mapObjectEditor.component.less new file mode 100644 index 00000000000..bb67f98d869 --- /dev/null +++ b/packages/core/src/forms/mapObjectEditor/mapObjectEditor.component.less @@ -0,0 +1,9 @@ +map-object-editor { + .table.no-border-top { + border-top: 2px solid var(--color-white); + + .table-label { + padding: 0.8rem 0 0 1rem; + } + } +} diff --git a/packages/core/src/forms/mapObjectEditor/mapObjectEditor.component.spec.js b/packages/core/src/forms/mapObjectEditor/mapObjectEditor.component.spec.js new file mode 100644 index 00000000000..41be1ae763f --- /dev/null +++ b/packages/core/src/forms/mapObjectEditor/mapObjectEditor.component.spec.js @@ -0,0 +1,85 @@ +'use strict'; + +describe('Component: mapObjectEditor', function () { + var scope; + + beforeEach(window.module(require('./mapObjectEditor.component').name)); + + beforeEach( + window.inject(function ($rootScope, $compile) { + scope = $rootScope.$new(); + this.compile = $compile; + }), + ); + + it('initializes with provided values', function () { + scope.model = { foo: { bar: 'baz' }, bah: 11 }; + let dom = this.compile('')(scope); + scope.$digest(); + + expect(dom.find('input').length).toBe(2); + expect(dom.find('textarea').length).toBe(2); + + expect(dom.find('input').get(0).value).toBe('foo'); + expect(dom.find('textarea').get(0).value).toBe(JSON.stringify({ bar: 'baz' }, null, 2)); + expect(dom.find('input').get(1).value).toBe('bah'); + expect(dom.find('textarea').get(1).value).toBe('11'); + }); + + describe('adding new entries', function () { + it('creates a new row in the table, but does not synchronize to model', function () { + scope.model = {}; + let dom = this.compile('')(scope); + scope.$digest(); + dom.find('button').click(); + expect(dom.find('tbody tr').length).toBe(1); + expect(dom.find('input').length).toBe(1); + expect(dom.find('textarea').length).toBe(1); + }); + + it('does not flag multiple new rows without keys as having duplicate keys', function () { + scope.model = {}; + let dom = this.compile('')(scope); + scope.$digest(); + dom.find('button').click(); + dom.find('button').click(); + + expect(dom.find('tbody tr').length).toBe(2); + expect(dom.find('input').length).toBe(2); + expect(dom.find('textarea').length).toBe(2); + + expect(dom.find('.error-message').length).toBe(0); + }); + }); + + describe('removing entries', function () { + it('removes the entry when the trash can is clicked', function () { + scope.model = { foo: { bar: 'baz' } }; + let dom = this.compile('')(scope); + scope.$digest(); + + expect(dom.find('input').length).toBe(1); + expect(dom.find('textarea').length).toBe(1); + + dom.find('a').click(); + + expect(dom.find('tbody tr').length).toBe(0); + expect(dom.find('input').length).toBe(0); + expect(dom.find('textarea').length).toBe(0); + expect(scope.model.foo).toBeUndefined(); + }); + }); + + describe('duplicate key handling', function () { + it('provides a warning when a duplicate key is entered', function () { + scope.model = { a: { bar: 'baz' }, b: '2' }; + let dom = this.compile('')(scope); + scope.$digest(); + + $(dom.find('input')[1]).val('a').trigger('input'); + scope.$digest(); + + expect(dom.find('.error-message').length).toBe(1); + }); + }); +}); diff --git a/packages/google/src/serverGroup/configure/serverGroupCommandBuilder.service.js b/packages/google/src/serverGroup/configure/serverGroupCommandBuilder.service.js index 3ef9e98e9ec..7df1608c79b 100644 --- a/packages/google/src/serverGroup/configure/serverGroupCommandBuilder.service.js +++ b/packages/google/src/serverGroup/configure/serverGroupCommandBuilder.service.js @@ -295,6 +295,12 @@ angular } } + function populatePartnerMetadata(instanceTemplatePartnerMetadata, command) { + if (instanceTemplatePartnerMetadata) { + Object.assign(command.partnerMetadata, instanceTemplatePartnerMetadata); + } + } + function populateLabels(instanceTemplateLabels, command) { if (instanceTemplateLabels) { Object.assign(command.labels, instanceTemplateLabels); @@ -374,6 +380,7 @@ angular tags: [], labels: {}, resourceManagerTags: {}, + partnerMetadata: {}, enableSecureBoot: false, enableVtpm: false, enableIntegrityMonitoring: false, @@ -453,6 +460,7 @@ angular tags: [], labels: {}, resourceManagerTags: {}, + partnerMetadata: {}, availabilityZones: [], enableSecureBoot: serverGroup.enableSecureBoot, enableVtpm: serverGroup.enableVtpm, @@ -589,6 +597,9 @@ angular const resourceManagerTags = extendedCommand.resourceManagerTags; populateResourceManagerTags(resourceManagerTags, extendedCommand); + const partnerMetadata = extendedCommand.partnerMetadata; + populatePartnerMetadata(partnerMetadata, extendedCommand); + return extendedCommand; }); }); diff --git a/packages/google/src/serverGroup/configure/wizard/advancedSettings/advancedSettings.directive.html b/packages/google/src/serverGroup/configure/wizard/advancedSettings/advancedSettings.directive.html index 53c505a0200..6c0bd71d94b 100644 --- a/packages/google/src/serverGroup/configure/wizard/advancedSettings/advancedSettings.directive.html +++ b/packages/google/src/serverGroup/configure/wizard/advancedSettings/advancedSettings.directive.html @@ -94,6 +94,17 @@ +
+
+ Partner Metadata + +
+ +
Shielded VMs diff --git a/packages/kubernetes/src/help/kubernetes.help.ts b/packages/kubernetes/src/help/kubernetes.help.ts index 3b2ceb15311..59b0c54e84e 100644 --- a/packages/kubernetes/src/help/kubernetes.help.ts +++ b/packages/kubernetes/src/help/kubernetes.help.ts @@ -211,6 +211,8 @@ const helpContents: { [key: string]: string } = { 'These artifacts must be present in the context for this stage to successfully complete. Artifacts specified will be bound to the deployed manifest.', 'kubernetes.manifest.skipExpressionEvaluation': '

Skip SpEL expression evaluation of the manifest artifact in this stage. Can be paired with the "Evaluate SpEL expressions in overrides at bake time" option in the Bake Manifest stage when baking a third-party manifest artifact with expressions not meant for Spinnaker to evaluate as SpEL.

', + 'kubernetes.manifest.skipSpecTemplateLabels': ` +

Skip applying labels to a manifest's .spec.template.metadata.labels.

`, 'kubernetes.manifest.undoRollout.revisionsBack': `

How many revisions to rollback from the current active revision. This is not a hard-coded revision to rollout.

For example: If you specify "1", and this stage executes, the prior revision will be active upon success.

diff --git a/packages/kubernetes/src/pipelines/stages/deployManifest/DeployManifestStageForm.tsx b/packages/kubernetes/src/pipelines/stages/deployManifest/DeployManifestStageForm.tsx index c818185097d..66a30239c8f 100644 --- a/packages/kubernetes/src/pipelines/stages/deployManifest/DeployManifestStageForm.tsx +++ b/packages/kubernetes/src/pipelines/stages/deployManifest/DeployManifestStageForm.tsx @@ -34,6 +34,7 @@ interface IDeployManifestStageConfigFormProps { interface IDeployManifestStageConfigFormState { rawManifest: string; overrideNamespace: boolean; + skipSpecTemplateLabels: boolean; } export class DeployManifestStageForm extends React.Component< @@ -55,6 +56,7 @@ export class DeployManifestStageForm extends React.Component< this.state = { rawManifest: !isEmpty(manifests) && isTextManifest ? yamlDocumentsToString(manifests) : '', overrideNamespace: get(stage, 'namespaceOverride', '') !== '', + skipSpecTemplateLabels: get(stage, 'skipSpecTemplateLabels', false), }; } @@ -141,6 +143,12 @@ export class DeployManifestStageForm extends React.Component< /> )} + + this.props.formik.setFieldValue('skipSpecTemplateLabels', e.target.checked)} + /> +

Manifest Configuration