diff --git a/addons/api/addon/adapters/application.js b/addons/api/addon/adapters/application.js index 04a9ebe6f..1c4149511 100644 --- a/addons/api/addon/adapters/application.js +++ b/addons/api/addon/adapters/application.js @@ -263,14 +263,36 @@ export default class ApplicationAdapter extends RESTAdapter.extend( source: { pointer: '/data' }, }; // Normalize field-specific errors, if any. - const errors = fieldErrors.map((error) => ({ - detail: error.description, - // Ember Data cannot handle errors on nested attributes, so these are - // flattened (e.g. `attributes.foobar` becomes `attributes_foobar`). - // To capture these errors, add read-only fields on the model - // corresponding to the underscored field name. - source: { pointer: `/data/attributes/${error.name.replace('.', '_')}` }, - })); + const errors = fieldErrors.flatMap((error) => { + const newValue = [ + { + detail: error.description, + // Ember Data cannot handle errors on nested attributes, so these are + // flattened (e.g. `attributes.foobar` becomes `attributes_foobar`). + // To capture these errors, add read-only fields on the model + // corresponding to the underscored field name. + source: { + pointer: `/data/attributes/${error.name.replace('.', '_')}`, + }, + }, + ]; + if (error.name.includes('attributes.')) { + newValue.push({ + detail: error.description, + // Going forward, nested attributes will be hoisted. Thus "attributes." + // must be stripped from the error name key. Until all models are + // transitioned away from model fragments, however, both this and the + // prior error key mappings are necessary. + source: { + pointer: `/data/attributes/${error.name.replace( + 'attributes.', + '' + )}`, + }, + }); + } + return newValue; + }); // Return a list of JSON API errors rooted under the `errors` key. return { errors: [baseError, ...errors], diff --git a/addons/api/addon/generated/models/host-catalog.js b/addons/api/addon/generated/models/host-catalog.js index 5f4569e6a..f1f78db34 100644 --- a/addons/api/addon/generated/models/host-catalog.js +++ b/addons/api/addon/generated/models/host-catalog.js @@ -48,4 +48,10 @@ export default class GeneratedHostCatalogModel extends BaseModel { description: 'Current version number of this resource.', }) version; + + @attr({ + description: 'Plugin information for this resource.', + readOnly: true, + }) + plugin; } diff --git a/addons/api/addon/models/fragment-target-attributes.js b/addons/api/addon/models/fragment-target-attributes.js deleted file mode 100644 index 44f1a94aa..000000000 --- a/addons/api/addon/models/fragment-target-attributes.js +++ /dev/null @@ -1,9 +0,0 @@ -import Fragment from 'ember-data-model-fragments/fragment'; -import { attr } from '@ember-data/model'; - -export default class FragmentTargetAttributesModel extends Fragment { - @attr('number', { - description: 'The default port a target should use if present.', - }) - default_port; -} diff --git a/addons/api/addon/models/host-catalog.js b/addons/api/addon/models/host-catalog.js index f9ae96493..8a0a64bd5 100644 --- a/addons/api/addon/models/host-catalog.js +++ b/addons/api/addon/models/host-catalog.js @@ -2,9 +2,54 @@ import GeneratedHostCatalogModel from '../generated/models/host-catalog'; export default class HostCatalogModel extends GeneratedHostCatalogModel { /** + * Returns if the host-catalog is static type or not. * @type {boolean} */ get isStatic() { return this.type === 'static'; } + + /** + * Returns if the host-catalog is a plugin or not. + * @type {boolean} + */ + get isPlugin() { + return this.type === 'plugin'; + } + + /** + * Returns if host-catalog plugin is AWS or not + * @type {boolean} + */ + get isAWS() { + return this.compositeType === 'aws'; + } + + /** + * Return if host-catalog plugin is Azure or not. + * @type {boolean} + */ + get isAzure() { + return this.compositeType === 'azure'; + } + + /** If host-catalog is a plugins returns its name, + * otherwise returns the host-catalog type + * @type {string} + */ + get compositeType() { + return this.isPlugin ? this.plugin.name : this.type; + } + + /** + * Sets type, if type is different than static, sets plugin name to type + */ + set compositeType(type) { + if (type === 'static') { + this.type = 'static'; + } else { + this.type = 'plugin'; + this.plugin = { name: type }; + } + } } diff --git a/addons/api/addon/models/target.js b/addons/api/addon/models/target.js index 9b36a5749..65baaffea 100644 --- a/addons/api/addon/models/target.js +++ b/addons/api/addon/models/target.js @@ -1,15 +1,16 @@ import GeneratedTargetModel from '../generated/models/target'; -import { fragment, fragmentArray } from 'ember-data-model-fragments/attributes'; +import { attr } from '@ember-data/model'; +import { fragmentArray } from 'ember-data-model-fragments/attributes'; import { computed } from '@ember/object'; export default class TargetModel extends GeneratedTargetModel { // =attributes - /** - * Attributes of this resource, if any, represented as a JSON fragment. - * @type {FragmentTargetAttributesModel} - */ - @fragment('fragment-target-attributes', { defaultValue: {} }) attributes; + @attr('number', { + isNestedAttribute: true, + description: 'The default port a target should use if present.', + }) + default_port; /** * @type {[FragmentHostSourceModel]} diff --git a/addons/api/addon/serializers/application.js b/addons/api/addon/serializers/application.js index 08b59eebc..cb61f1a34 100644 --- a/addons/api/addon/serializers/application.js +++ b/addons/api/addon/serializers/application.js @@ -50,6 +50,12 @@ export default class ApplicationSerializer extends RESTSerializer { // Do not serialize `disabled` fields. // TODO: disabled is temporarily disabled if (key === 'disabled') delete json[key]; + // Push nested attributes down into the attributes key + if (options.isNestedAttribute && json[key] !== undefined) { + if (!json.attributes) json.attributes = {}; + json.attributes[key] = json[key]; + delete json[key]; + } return value; } @@ -195,9 +201,37 @@ export default class ApplicationSerializer extends RESTSerializer { * @return {Object} */ normalize(typeClass, hash) { - const normalizedHash = copy(hash, true); + let normalizedHash = copy(hash, true); const scopeID = get(normalizedHash, 'scope.id'); if (scopeID) normalizedHash.scope.scope_id = scopeID; + normalizedHash = this.normalizeNestedAttributes(typeClass, normalizedHash); return super.normalize(typeClass, normalizedHash); } + + /** + * Certain typed resources in the API have type-specific fields nested under + * an `attributes` key in the payload + * (e.g. `attributes: { fieldName: '123' }`). Since Ember Data lacks + * first-class support for nested attributes, these fields must be hoisted up + * into the main body of the payload. + * + * In order to annotate a field as being a nested attribute, add the key name + * to the `nestedAttributes` field on the serializer: + * `@attr('string', { isNestedAttribute: true }) fieldName;` + * + * @param {Model} typeClass + * @param {Object} hash + * @return {Object} + */ + normalizeNestedAttributes(typeClass, hash) { + const normalizedHash = copy(hash, true); + typeClass.attributes.forEach((attribute) => { + const { isNestedAttribute } = attribute.options; + const attributeValue = normalizedHash.attributes?.[attribute.name]; + if (isNestedAttribute && attributeValue) { + normalizedHash[attribute.name] = attributeValue; + } + }); + return normalizedHash; + } } diff --git a/addons/api/app/models/fragment-target-attributes.js b/addons/api/app/models/fragment-target-attributes.js deleted file mode 100644 index 41eb7d88f..000000000 --- a/addons/api/app/models/fragment-target-attributes.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from 'api/models/fragment-target-attributes'; diff --git a/addons/api/mirage/generated/factories/host-catalog.js b/addons/api/mirage/generated/factories/host-catalog.js index 1b2bb8af6..fa8135226 100644 --- a/addons/api/mirage/generated/factories/host-catalog.js +++ b/addons/api/mirage/generated/factories/host-catalog.js @@ -11,4 +11,13 @@ export default Factory.extend({ created_time: () => date.recent(), updated_time: () => date.recent(), disabled: () => datatype.boolean(), + plugin: function (i) { + if (this.type == 'plugin') { + return { + id: `pl_${datatype.hexaDecimal(5)}`, + name: i % 2 ? 'aws' : 'azure', + description: random.words(), + }; + } + }, }); diff --git a/addons/api/tests/unit/models/host-catalog-test.js b/addons/api/tests/unit/models/host-catalog-test.js index 48b21051d..7a8a77147 100644 --- a/addons/api/tests/unit/models/host-catalog-test.js +++ b/addons/api/tests/unit/models/host-catalog-test.js @@ -19,4 +19,74 @@ module('Unit | Model | host catalog', function (hooks) { assert.true(modelA.isStatic); assert.false(modelB.isStatic); }); + + test('it has isPlugin function and returns the expected values', async function (assert) { + assert.expect(3); + const store = this.owner.lookup('service:store'); + const modelPlugin = store.createRecord('host-catalog', { type: 'plugin' }); + const modelStatic = store.createRecord('host-catalog', { type: 'static' }); + assert.equal(typeof modelPlugin.isPlugin, 'boolean'); + assert.true(modelPlugin.isPlugin); + assert.false(modelStatic.isPlugin); + }); + + test('it has isAWS function and returns the expected values', async function (assert) { + assert.expect(3); + const store = this.owner.lookup('service:store'); + const modelAws = store.createRecord('host-catalog', { + type: 'plugin', + plugin: { name: 'aws' }, + }); + const modelRandom = store.createRecord('host-catalog', { + plugin: { name: 'random' }, + }); + assert.equal(typeof modelAws.isAWS, 'boolean'); + assert.true(modelAws.isAWS); + assert.false(modelRandom.isAWS); + }); + + test('it has isAzure function and return the expected values', async function (assert) { + assert.expect(3); + const store = this.owner.lookup('service:store'); + const modelAzure = store.createRecord('host-catalog', { + type: 'plugin', + plugin: { name: 'azure' }, + }); + const modelRandom = store.createRecord('host-catalog', { + plugin: { name: 'random' }, + }); + assert.equal(typeof modelAzure.isAzure, 'boolean'); + assert.true(modelAzure.isAzure); + assert.false(modelRandom.isAzure); + }); + + test('get compositeType returns expected values', async function (assert) { + assert.expect(3); + const store = this.owner.lookup('service:store'); + const modelPlugin = store.createRecord('host-catalog', { + type: 'plugin', + plugin: { name: 'Test name' }, + }); + const modelStatic = store.createRecord('host-catalog', { + type: 'static', + }); + assert.equal(typeof modelPlugin.compositeType, 'string'); + assert.equal(modelPlugin.compositeType, 'Test name'); + assert.equal(modelStatic.compositeType, 'static'); + }); + + test('set compositeType sets expected values', async function (assert) { + assert.expect(3); + const store = this.owner.lookup('service:store'); + const modelPlugin = store.createRecord('host-catalog', { + compositeType: 'aws', + }); + const modelStatic = store.createRecord('host-catalog', { + compositeType: 'static', + }); + + assert.equal(modelPlugin.type, 'plugin'); + assert.equal(modelPlugin.plugin.name, 'aws'); + assert.equal(modelStatic.type, 'static'); + }); }); diff --git a/addons/api/tests/unit/serializers/target-test.js b/addons/api/tests/unit/serializers/target-test.js index 0c1bd089c..d384e2cee 100644 --- a/addons/api/tests/unit/serializers/target-test.js +++ b/addons/api/tests/unit/serializers/target-test.js @@ -20,6 +20,7 @@ module('Unit | Serializer | target', function (hooks) { scope_id: 'org_1', type: 'org', }, + default_port: 1234, version: 1, type: 'tcp', }); @@ -35,7 +36,7 @@ module('Unit | Serializer | target', function (hooks) { session_max_seconds: 28800, session_connection_limit: 1, attributes: { - default_port: null, + default_port: 1234, }, }); }); @@ -123,6 +124,40 @@ module('Unit | Serializer | target', function (hooks) { }); }); + test('it normalizes records with nested attributes', function (assert) { + assert.expect(1); + const store = this.owner.lookup('service:store'); + const serializer = store.serializerFor('target'); + const targetModelClass = store.createRecord('target').constructor; + const payload = { + id: '1', + name: 'Target 1', + attributes: { + default_port: 1234, + }, + }; + const normalized = serializer.normalizeSingleResponse( + store, + targetModelClass, + payload + ); + assert.deepEqual(normalized, { + included: [], + data: { + id: '1', + type: 'target', + attributes: { + application_credential_source_ids: [], + authorized_actions: [], + host_sources: [], + name: 'Target 1', + default_port: 1234, + }, + relationships: {}, + }, + }); + }); + test('it normalizes missing host_sources and application_credential_source_ids to empty array', function (assert) { assert.expect(1); const store = this.owner.lookup('service:store'); diff --git a/addons/rose/addon/components/rose/form/radio/radio/index.hbs b/addons/rose/addon/components/rose/form/radio/radio/index.hbs index d38bc3680..edecc3ba8 100644 --- a/addons/rose/addon/components/rose/form/radio/radio/index.hbs +++ b/addons/rose/addon/components/rose/form/radio/radio/index.hbs @@ -1,4 +1,4 @@ -
{{hostCatalog.description}}
-{{hostCatalog.description}}
+{{hostCatalog.id}}
- {{hostCatalog.id}}
+