UI - dynamic related capabilities (#7013)

* lay groundwork for application serializer to setup capabilities relationships

* add api path util and tests, and attach-capabilites fn

* make attach-capabilities work with array responses, add tests
This commit is contained in:
Matthew Irish 2019-06-28 16:07:45 -05:00 committed by GitHub
parent 2fcac90052
commit 887e2febf8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 316 additions and 4 deletions

View File

@ -38,7 +38,12 @@ export default ApplicationAdapter.extend({
},
query(store, type, query) {
return this.ajax(this.urlForQuery(query, type.modelName), 'GET');
return this.ajax(this.urlForQuery(query, type.modelName), 'GET').then(resp => {
// remove pagination query items here
const { size, page, responsePath, pageFilter, ...modelAttrs } = query;
resp._requestQuery = modelAttrs;
return resp;
});
},
queryRecord(store, type, query) {

View File

@ -0,0 +1,82 @@
import DS from 'ember-data';
import { assert, debug } from '@ember/debug';
import { typeOf } from '@ember/utils';
import { isArray } from '@ember/array';
const { belongsTo } = DS;
/*
*
* attachCapabilities
*
* @param modelClass = An Ember Data model class
* @param capabilities - an Object whose keys will added to the model class as related 'capabilities' models
* and whose values should be functions that return the id of the related capabilites model
*
* definition of capabilities be done shorthand with the apiPath tagged template funtion
*
*
* @usage
*
* let Model = DS.Model.extend({
* backend: attr(),
* scope: attr(),
* });
*
* export default attachCapabilities(Model, {
* updatePath: apiPath`${'backend'}/scope/${'scope'}/role/${'id'}`,
* });
*
*/
export default function attachCapabilities(modelClass, capabilities) {
let capabilityKeys = Object.keys(capabilities);
let newRelationships = capabilityKeys.reduce((ret, key) => {
ret[key] = belongsTo('capabilities');
return ret;
}, {});
debug(`adding new relationships: ${capabilityKeys.join(', ')} to ${modelClass.toString()}`);
modelClass.reopen(newRelationships);
modelClass.reopenClass({
// relatedCapabilities is called in the application serializer's
// normalizeResponse hook to add the capabilities relationships to the
// JSON-API document used by Ember Data
relatedCapabilities(jsonAPIDoc) {
let { data, included } = jsonAPIDoc;
if (!data) {
data = jsonAPIDoc;
}
if (isArray(data)) {
let newData = data.map(this.relatedCapabilities);
return {
data: newData,
included,
};
}
let context = {
id: data.id,
...data.attributes,
};
for (let newCapability of capabilityKeys) {
let templateFn = capabilities[newCapability];
let type = typeOf(templateFn);
assert(`expected value of ${newCapability} to be a function but found ${type}.`, type === 'function');
data.relationships[newCapability] = {
data: {
type: 'capabilities',
id: templateFn(context),
},
};
}
if (included) {
return {
data,
included,
};
} else {
return data;
}
},
});
return modelClass;
}

View File

@ -2,9 +2,11 @@ import DS from 'ember-data';
import { computed } from '@ember/object';
import { expandAttributeMeta } from 'vault/utils/field-to-attrs';
import fieldToAttrs from 'vault/utils/field-to-attrs';
import apiPath from 'vault/utils/api-path';
import attachCapabilities from 'vault/lib/attach-capabilities';
const { attr } = DS;
export default DS.Model.extend({
const Model = DS.Model.extend({
useOpenAPI: true,
backend: attr({ readOnly: true }),
scope: attr({ readOnly: true }),
@ -24,3 +26,7 @@ export default DS.Model.extend({
return expandAttributeMeta(this, fields);
}),
});
export default attachCapabilities(Model, {
updatePath: apiPath`${'backend'}/scope/${'scope'}/role/${'id'}`,
});

View File

@ -15,7 +15,14 @@ export default DS.JSONSerializer.extend({
return key;
}
let pk = this.get('primaryKey') || 'id';
return { [pk]: key };
let model = { [pk]: key };
// if we've added a in the adapter, we want
// attach it to the individual models
if (payload._requestQuery) {
model = { ...model, ...payload._requestQuery };
delete payload._requestQuery;
}
return model;
});
return models;
}
@ -40,7 +47,11 @@ export default DS.JSONSerializer.extend({
if (id && !responseJSON.id) {
responseJSON.id = id;
}
return this._super(store, primaryModelClass, responseJSON, id, requestType);
let jsonAPIRepresentation = this._super(store, primaryModelClass, responseJSON, id, requestType);
if (primaryModelClass.relatedCapabilities) {
jsonAPIRepresentation = primaryModelClass.relatedCapabilities(jsonAPIRepresentation);
}
return jsonAPIRepresentation;
},
serializeAttribute(snapshot, json, key, attributes) {

24
ui/app/utils/api-path.js Normal file
View File

@ -0,0 +1,24 @@
import { assert } from '@ember/debug';
// This is a tagged template function that will
// replace placeholders in the form of 'id' with the value from the passed context
//
// usage:
// let fn = apiPath`foo/bar/${'id'}`;
// let output = fn({id: 'an-id'});
// output will result in 'foo/bar/an-id';
export default function apiPath(strings, ...keys) {
return function(data) {
let dict = data || {};
let result = [strings[0]];
assert(
`Expected ${keys.length} keys in apiPath context, only recieved ${Object.keys(data).length}`,
keys.length === Object.keys(data).length
);
keys.forEach((key, i) => {
result.push(dict[key], strings[i + 1]);
});
return result.join('');
};
}

View File

@ -0,0 +1,161 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import attachCapabilities from 'vault/lib/attach-capabilities';
import apiPath from 'vault/utils/api-path';
import { get } from '@ember/object';
let MODEL_TYPE = 'test-form-model';
module('Unit | lib | attach capabilities', function(hooks) {
setupTest(hooks);
test('it attaches passed capabilities', function(assert) {
let mc = this.owner.lookup('service:store').modelFor(MODEL_TYPE);
mc = attachCapabilities(mc, {
updatePath: apiPath`update/{'id'}`,
deletePath: apiPath`delete/{'id'}`,
});
let relationship = get(mc, 'relationshipsByName').get('updatePath');
assert.equal(relationship.key, 'updatePath', 'has updatePath relationship');
assert.equal(relationship.kind, 'belongsTo', 'kind of relationship is belongsTo');
assert.equal(relationship.type, 'capabilities', 'updatePath is a related capabilities model');
relationship = get(mc, 'relationshipsByName').get('deletePath');
assert.equal(relationship.key, 'deletePath', 'has deletePath relationship');
assert.equal(relationship.kind, 'belongsTo', 'kind of relationship is belongsTo');
assert.equal(relationship.type, 'capabilities', 'deletePath is a related capabilities model');
});
test('it adds a static method to the model class', function(assert) {
let mc = this.owner.lookup('service:store').modelFor(MODEL_TYPE);
mc = attachCapabilities(mc, {
updatePath: apiPath`update/{'id'}`,
deletePath: apiPath`delete/{'id'}`,
});
assert.ok(
!!mc.relatedCapabilities && typeof mc.relatedCapabilities === 'function',
'model class now has a relatedCapabilities static function'
);
});
test('calling static method with single response JSON-API document adds expected relationships', function(assert) {
let mc = this.owner.lookup('service:store').modelFor(MODEL_TYPE);
mc = attachCapabilities(mc, {
updatePath: apiPath`update/${'id'}`,
deletePath: apiPath`delete/${'id'}`,
});
let jsonAPIDocSingle = {
data: {
id: 'test',
type: MODEL_TYPE,
attributes: {},
relationships: {},
},
included: [],
};
let expected = {
data: {
id: 'test',
type: MODEL_TYPE,
attributes: {},
relationships: {
updatePath: {
data: {
type: 'capabilities',
id: 'update/test',
},
},
deletePath: {
data: {
type: 'capabilities',
id: 'delete/test',
},
},
},
},
included: [],
};
mc.relatedCapabilities(jsonAPIDocSingle);
assert.equal(
Object.keys(jsonAPIDocSingle.data.relationships).length,
2,
'document now has 2 relationships'
);
assert.deepEqual(jsonAPIDocSingle, expected, 'has the exected new document structure');
});
test('calling static method with an arrary response JSON-API document adds expected relationships', function(assert) {
let mc = this.owner.lookup('service:store').modelFor(MODEL_TYPE);
mc = attachCapabilities(mc, {
updatePath: apiPath`update/${'id'}`,
deletePath: apiPath`delete/${'id'}`,
});
let jsonAPIDocSingle = {
data: [
{
id: 'test',
type: MODEL_TYPE,
attributes: {},
relationships: {},
},
{
id: 'foo',
type: MODEL_TYPE,
attributes: {},
relationships: {},
},
],
included: [],
};
let expected = {
data: [
{
id: 'test',
type: MODEL_TYPE,
attributes: {},
relationships: {
updatePath: {
data: {
type: 'capabilities',
id: 'update/test',
},
},
deletePath: {
data: {
type: 'capabilities',
id: 'delete/test',
},
},
},
},
{
id: 'foo',
type: MODEL_TYPE,
attributes: {},
relationships: {
updatePath: {
data: {
type: 'capabilities',
id: 'update/foo',
},
},
deletePath: {
data: {
type: 'capabilities',
id: 'delete/foo',
},
},
},
},
],
included: [],
};
mc.relatedCapabilities(jsonAPIDocSingle);
assert.deepEqual(jsonAPIDocSingle, expected, 'has the exected new document structure');
});
});

View File

@ -0,0 +1,23 @@
import apiPath from 'vault/utils/api-path';
import { module, test } from 'qunit';
module('Unit | Util | api path', function() {
test('it returns a function', function(assert) {
let ret = apiPath`foo`;
assert.ok(typeof ret === 'function');
});
test('it iterpolates strings from passed context object', function(assert) {
let ret = apiPath`foo/${'one'}/${'two'}`;
let result = ret({ one: 1, two: 2 });
assert.equal(result, 'foo/1/2', 'returns the expected string');
});
test('it throws when the key is not found in the context', function(assert) {
let ret = apiPath`foo/${'one'}/${'two'}`;
assert.throws(() => {
ret({ one: 1 });
}, /Error: Assertion Failed: Expected 2 keys in apiPath context, only recieved 1/);
});
});