Merge remote-tracking branch 'origin/develop' into add-manage-button-to-edit-form

This commit is contained in:
Susmitha Girumala committed 2020-08-11 19:26:42 -04:00
commit 77c678601b
28 files changed
+544 -41

No files matched your search

+21
View File
@@ -1,4 +1,5 @@
import RESTAdapter from '@ember-data/adapter/rest';
import { serializeIntoHash } from '@ember-data/adapter/-private';
import AdapterBuildURLMixin from '../mixins/adapter-build-url';
import config from 'ember-get-config';
import { get, getWithDefault } from '@ember/object';
@@ -143,6 +144,26 @@ export default class ApplicationAdapter extends RESTAdapter.extend(
return super.query(...arguments).then(prenormalizeArrayResponse);
}
/**
* Our API expects update calls with a custom method suffix
* (e.g. `:set-grants`) to be made with POST instead of PUT/PATCH.
* If this method is called with a custom method via `adapterOptions.method`,
* the API request will be made with POST. All other update requests are
* made as normal.
* @override
* @param {Store} store
* @param {Model} type
* @param {Snapshot} snapshot
* @return {Promise} promise
*/
updateRecord(store, type, snapshot) {
const method = get(snapshot, 'adapterOptions.method');
const data = serializeIntoHash(store, type, snapshot, {});
const id = snapshot.id;
const url = this.buildURL(type.modelName, id, snapshot, 'updateRecord');
return this.ajax(url, method ? 'POST' : 'PUT', { data });
}
/**
* Overrides default ajax method by rewritting PUT requests as PATCH.
* PATCH is the request method used by our API for updates.
@@ -33,4 +33,10 @@ export default class GeneratedRoleModel extends BaseModel {
description: 'Whether the resource is disabled',
})
disabled;
@attr('number', {
description: 'Current version number of this resource.',
})
version;
}
@@ -0,0 +1,16 @@
import Fragment from 'ember-data-model-fragments/fragment';
import { attr } from '@ember-data/model';
/**
* Represents an item in an array of strings.
*/
export default class FragmentStringModel extends Fragment {
// =attributes
@attr('string', {
description: 'The value of the string.'
})
value;
}
+28 -1
View File
@@ -1,3 +1,30 @@
import GeneratedRoleModel from '../generated/models/role';
import { fragmentArray } from 'ember-data-model-fragments/attributes';
export default class RoleModel extends GeneratedRoleModel {}
export default class RoleModel extends GeneratedRoleModel {
// =attributes
/**
* Grants is read-only _most_ under normal circumstances. But grants can
* be persisted via a dedicated call to `saveGrants()`.
*/
@fragmentArray('fragment-string', {readOnly: true}) grants;
// =methods
/**
* Saves the `grants` array on the role via the `set-grants` method.
* See serializer and adapter for more information.
* @return {Promise}
*/
saveGrants() {
return this.save({
adapterOptions: {
method: 'set-grants',
serializeGrants: true
}
});
}
}
+52
View File
@@ -0,0 +1,52 @@
import ApplicationSerializer from './application';
import { get } from '@ember/object';
export default class RoleSerializer extends ApplicationSerializer {
// =methods
/**
* If `adapterOptions.serializeGrants` is true, the serialization should
* include **only grants** and the version. Normally, grants are not
* serialized.
* @override
* @param {Snapshot} snapshot
* @return {object}
*/
serialize(snapshot) {
const serializeGrants = get(snapshot, 'adapterOptions.serializeGrants');
let serialized = super.serialize(...arguments);
if (serializeGrants) serialized = this.serializeWithGrants(snapshot);
return serialized;
}
/**
* Returns a payload containing only the grants array.
* @param {Snapshot} snapshot
* @return {object}
*/
serializeWithGrants(snapshot) {
return {
version: snapshot.attr('version'),
grant_strings: snapshot.attr('grants').map(grant => grant.attr('value'))
};
}
/**
* Converts string arrays to object arrays for use with `fragment-string`.
* E.g. `"foo"` -> `{value: "foo"}`
* @override
* @param {Model} typeClass
* @param {Object} hash
* @return {Object}
*/
normalize(modelClass, resourceHash) {
// TODO: can fragment string normalization be handled generically?
if (resourceHash.grant_strings) {
resourceHash.grants =
resourceHash.grant_strings.map(value => ({ value }));
}
return super.normalize(modelClass, resourceHash);
}
}
+1
View File
@@ -0,0 +1 @@
export { default } from 'api/models/fragment-string';
+1
View File
@@ -0,0 +1 @@
export { default } from 'api/serializers/role';
@@ -112,6 +112,23 @@ module('Unit | Adapter | application', function (hooks) {
adapter.ajax('/', 'PUT');
});
test('it rewrites PUT/PATCH to POST when `adapterOptions.method` is passed to `updateRecord`', function (assert) {
assert.expect(1);
const adapter = this.owner.lookup('adapter:application');
const store = this.owner.lookup('service:store');
const snapshot = store.createRecord('role', {})._createSnapshot();
snapshot.adapterOptions = {
method: 'custom-method'
}
// TODO this is icky, should be changed to a spy or stub
const originalAjax = RESTAdapter.prototype.ajax;
RESTAdapter.prototype.ajax = (url, type) => {
assert.equal(type, 'POST');
RESTAdapter.prototype.ajax = originalAjax;
};
adapter.updateRecord(store, {modelName: 'user'}, snapshot);
});
test('it prenormalizes "empty" responses into a form that the fetch-manager will not reject', async function (assert) {
assert.expect(1);
const adapter = this.owner.lookup('adapter:application');
@@ -0,0 +1,13 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Model | fragment string', function(hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function(assert) {
let store = this.owner.lookup('service:store');
let model = store.createRecord('fragment-string', {});
assert.ok(model);
});
});
+32
View File
@@ -1,12 +1,44 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
module('Unit | Model | role', function (hooks) {
setupTest(hooks);
setupMirage(hooks);
test('it exists', function(assert) {
let store = this.owner.lookup('service:store');
let model = store.createRecord('role', {});
assert.ok(model);
});
test('it has a `saveGrants` method that targets a specific POST API endpoint and serialization', async function (assert) {
assert.expect(1);
this.server.post('/v1/scopes/o_1/roles/123abc:set-grants', (schema, request) => {
const body = JSON.parse(request.requestBody);
assert.deepEqual(body, {
grant_strings: ['grant1', 'grant2'],
version: 1
});
});
const store = this.owner.lookup('service:store');
store.push({
data: {
id: '123abc',
type: 'role',
attributes: {
name: 'Role',
description: 'Description',
grants: [{value: 'grant1'}, {value: 'grant2'}],
version: 1,
scope: {
scope_id: 'o_1',
type: 'scope'
}
}
}
});
const model = store.peekRecord('role', '123abc');
await model.saveGrants();
});
});
@@ -0,0 +1,77 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Serializer | role', function(hooks) {
setupTest(hooks);
test('it serializes roles normally, without grants', function (assert) {
assert.expect(1);
const store = this.owner.lookup('service:store');
const serializer = store.serializerFor('role');
const record = store.createRecord('role', {
name: 'User',
description: 'Description',
grants: [{value: 'foo'}, {value: 'bar'}],
version: 1
});
const snapshot = record._createSnapshot();
snapshot.adapterOptions = {};
const serializedRecord = serializer.serialize(snapshot);
assert.deepEqual(serializedRecord, {
name: 'User',
description: 'Description',
version: 1
});
});
test('it serializes only grants when `adapterOptions.serializeGrants` is true', function (assert) {
assert.expect(1);
const store = this.owner.lookup('service:store');
const serializer = store.serializerFor('role');
const record = store.createRecord('role', {
name: 'User',
description: 'Description',
grants: [{value: 'foo'}, {value: 'bar'}],
version: 1
});
const snapshot = record._createSnapshot();
snapshot.adapterOptions = {
serializeGrants: true
};
const serializedRecord = serializer.serialize(snapshot);
assert.deepEqual(serializedRecord, {
grant_strings: ['foo', 'bar'],
version: 1
});
});
test('it normalizes records with array fields', function (assert) {
assert.expect(1);
const store = this.owner.lookup('service:store');
const serializer = store.serializerFor('role');
const roleModelClass = store.createRecord('role').constructor;
const payload = {
id: '1',
name: 'Role 1',
grant_strings: ['*', '*']
};
const normalized = serializer.normalizeSingleResponse(
store,
roleModelClass,
payload
);
assert.deepEqual(normalized, {
included: [],
data: {
id: '1',
type: 'role',
attributes: {
name: 'Role 1',
grants: [{value: '*'}, {value: '*'}],
},
relationships: {},
},
});
});
});
+3 -3
View File
@@ -49,19 +49,19 @@ export default class PasswordAuthenticator extends BaseAuthenticator {
*
* @override
* @param {object} creds
* @param {string} creds.identification is sent to server as `name`
* @param {string} creds.identification is sent to server as `login_name`
* @param {string} creds.password
* @param {boolean} requestCookies request cookie tokens (default `true`)
* @return {Promise}
*/
async authenticate(
{ identification: name, password },
{ identification: login_name, password },
requestCookies = true,
options
) {
const body = JSON.stringify({
token_type: requestCookies ? 'cookie' : null,
credentials: { name, password },
credentials: { login_name, password },
});
const authEndpointURL = this.buildAuthEndpointURL(options);
const response = await fetch(authEndpointURL, { method: 'post', body });
@@ -41,7 +41,7 @@ module('Unit | Authenticator | password', function (hooks) {
assert.deepEqual(json, {
token_type: 'cookie',
credentials: {
name: 'foo',
login_name: 'foo',
password: 'bar',
},
});
@@ -2,6 +2,10 @@
@use '../../variables/typography';
@use '../../utilities/type';
.rose-list-key-value {
margin-bottom: sizing.rems(m);
}
.rose-list-key-value-item {
display: flex;
border-bottom: 1px solid var(--ui-gray-subtler-3);
@@ -3,8 +3,9 @@
novalidate
method="post"
class="rose-form"
autocomplete="off"
{{on "submit" this.handleSubmit}}
>
>
{{yield (hash
radioGroup=(component 'rose/form/radio/group' disabled=@disabled)
checkbox=(component 'rose/form/checkbox' disabled=@disabled)
@@ -0,0 +1,91 @@
<Rose::Form
@onSubmit={{fn this.createGrant @addGrant}}
@cancel={{@cancel}}
@disabled={{@model.isSaving}}
as |form|
>
<Rose::List::KeyValue as |list|>
{{!-- Add grant field (only one per form) --}}
<list.item as |item|>
<form.input
@contextual={{true}}
@name="grant"
@type="text"
@value={{this.newGrantString}}
as |input|>
<item.key>
<input.label>{{t "titles.new-grant"}}</input.label>
</item.key>
<item.cell>
<input.field />
</item.cell>
<item.cell>
<Rose::Button
@submit={{true}}
@disabled={{this.cannotSave}}
@style="secondary"
title={{t "actions.add"}}
>
{{t "actions.add"}}
</Rose::Button>
</item.cell>
</form.input>
</list.item>
</Rose::List::KeyValue>
</Rose::Form>
<Rose::Form
@onSubmit={{@submit}}
@cancel={{@cancel}}
@disabled={{@model.isSaving}}
@error={{@model.errors.base}}
as |form|
>
{{!--
{{#if @model.errors.grants}}
<input.errors as |errors|>
{{#each @model.errors.grants as |error|}}
<errors.message>{{error.message}}</errors.message>
{{/each}}
</input.errors>
{{/if}}
--}}
<Rose::List::KeyValue as |list|>
{{#each @model.grants as |grant|}}
<list.item as |item|>
<form.input
@contextual={{true}}
@name="grant"
@type="text"
@value={{grant.value}}
@error={{@model.errors.grants}}
as |input|>
<item.key>
<input.label>{{t "form.label.grant"}}</input.label>
</item.key>
<item.cell>
<input.field />
</item.cell>
</form.input>
<item.cell>
<Rose::Button
@style="secondary"
@iconOnly="trash"
title={{t "actions.remove"}}
{{on "click" (fn @removeGrant grant)}}
>
{{t "actions.remove"}}
</Rose::Button>
</item.cell>
</list.item>
{{/each}}
</Rose::List::KeyValue>
<form.actions
@disabled={{if @model.cannotSave @model.cannotSave}}
@submitText={{t "actions.save"}}
@cancelText={{t "actions.cancel"}} />
</Rose::Form>
@@ -0,0 +1,37 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { computed, action } from '@ember/object';
export default class FormRoleGrantsComponent extends Component {
// =attributes
/**
* @type {string}
*/
@tracked newGrantString = '';
/**
* True if the grant string field is empty, false otherwise. This is used
* to disable the submit button.
* @return {boolean}
*/
@computed('newGrantString')
get cannotSave() {
return !this.newGrantString;
}
// =actions
/**
* Calls the passed function with the grant string as an argument and then
* clears the value of the grant string field.
* `@addGrant` should be passed by the context calling this component.
* @param {Function} addGrantFn
*/
@action
createGrant(addGrantFn) {
addGrantFn(this.newGrantString);
this.newGrantString = '';
}
}
+3 -1
View File
@@ -21,7 +21,9 @@ Router.map(function () {
this.route('new');
});
this.route('roles', function () {
this.route('role', { path: ':role_id' }, function () {});
this.route('role', { path: ':role_id' }, function () {
this.route('grants');
});
this.route('new');
});
this.route('projects', function() {
@@ -0,0 +1,54 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
export default class ScopesScopeRolesRoleGrantsRoute extends Route {
// =services
@service intl;
@service notify;
// =methods
/**
* Adds a new grant to the role at the beginning of the grants list.
* Grant creation is not immediately permanent; users may rollback the change
* via "cancel" or commit it via "save".
* @param {RoleModel} role
* @param {string} grantString
*/
@action
addGrant(role, grantString) {
role.grants.unshiftObject({ value: grantString });
}
/**
* Removes a grant from the role. Grant removal is not immediately permanent;
* users may rollback the change via "cancel" or commit it via "save".
* @param {RoleModel} role
* @param {FragmentString} grant
*/
@action
removeGrant(role, grant) {
role.grants.removeFragment(grant);
}
/**
* Save an role in current scope.
* @param {RoleModel} role
*/
@action
async save(role) {
try {
await role.saveGrants();
this.notify.success(
this.intl.t('notify.save-success')
);
} catch (error) {
// TODO: replace with translated strings
this.notify.error(error.message, { closeAfter: null });
}
}
}
@@ -1,3 +1,28 @@
{{title @model.displayName}}
{{outlet}}
<Rose::Layout::Page as |page|>
<page.breadcrumbs>
<BreadCrumbs />
</page.breadcrumbs>
<page.header>
<h2>{{t "resources.role"}}</h2>
</page.header>
<page.navigation>
<Rose::Nav::Tabs as |nav|>
<nav.link @route='scopes.scope.roles.role.index'>
{{t 'titles.settings'}}
</nav.link>
<nav.link @route='scopes.scope.roles.role.grants'>
{{t 'titles.grants'}}
</nav.link>
</Rose::Nav::Tabs>
</page.navigation>
<page.body>
{{outlet}}
</page.body>
</Rose::Layout::Page>
@@ -0,0 +1,9 @@
{{title (t 'titles.grants')}}
<Form::Role::Grants
@model={{@model}}
@submit={{route-action "save" @model}}
@cancel={{route-action "cancel" @model}}
@addGrant={{route-action "addGrant" @model}}
@removeGrant={{route-action "removeGrant" @model}}
/>
@@ -1,31 +1,6 @@
<Rose::Layout::Page as |page|>
<Form::Role
@model={{@model}}
@submit={{route-action "save" @model}}
@cancel={{route-action "cancel" @model}}
/>
<page.breadcrumbs>
<BreadCrumbs />
</page.breadcrumbs>
<page.header>
<h2>{{t "resources.role"}}</h2>
</page.header>
<page.actions>
<Rose::Dropdown @text="{{t 'form.label.manage'}}" @alignRight={{true}} as |dropdown|>
<dropdown.button
@style="warning"
@disabled={{@model.canSave}}
{{on "click" (route-action "delete" @model)}}
>
{{t 'actions.delete-role'}}
</dropdown.button>
</Rose::Dropdown>
</page.actions>
<page.body>
<Form::Role
@model={{@model}}
@submit={{route-action "save" @model}}
@cancel={{route-action "cancel" @model}}
/>
</page.body>
</Rose::Layout::Page>
+7
View File
@@ -64,6 +64,13 @@ export default function() {
this.get('/scopes/:scope_id/roles/:id');
this.patch('/scopes/:scope_id/roles/:id');
this.del('/scopes/:scope_id/roles/:id');
this.post('/scopes/:scope_id/roles/:id', function ({ roles }, request) {
const attrs = this.normalizedRequestAttrs();
const id = request.params.id.split(':')[0];
const role = roles.find(id);
attrs.id = id;
return role.update(attrs);
});
// IAM: Groups
this.get('/scopes/:scope_id/groups');
+9 -1
View File
@@ -1,3 +1,11 @@
import factory from '../generated/factories/role';
export default factory.extend();
export default factory.extend({
// eslint-disable-next-line ember/avoid-leaking-state-in-ember-objects
grant_strings: [
'id=*;action=*',
'id=*;type=host-catalog;actions=create,read'
],
});
+2 -2
View File
@@ -12,7 +12,7 @@ import { Response } from 'miragejs';
// */
// export default function authenticateHandler(schema, request) {
// const payload = JSON.parse(request.requestBody);
// if (payload.credentials.name === 'error') {
// if (payload.credentials.login_name === 'error') {
// return new Response(400);
// } else {
// const cookieName = config.auth.passwordCookieName;
@@ -25,7 +25,7 @@ import { Response } from 'miragejs';
export function authHandler({ scopes }, request) {
const payload = JSON.parse(request.requestBody);
if (payload.credentials.name === 'error') {
if (payload.credentials.login_name === 'error') {
return new Response(400);
} else {
const scopeAttrs =
+12
View File
@@ -217,4 +217,16 @@ module('Acceptance | roles', function (hooks) {
'Displays primary error message.'
);
});
test('grants can be updated and saved or canceled', async function (assert) {
assert.expect(0);
});
test('grants can be removed and saved or canceled', async function (assert) {
assert.expect(0);
});
test('new grants can be added and saved or canceled', async function (assert) {
assert.expect(0);
});
});
@@ -0,0 +1,11 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Route | scopes/scope/roles/role/grants', function(hooks) {
setupTest(hooks);
test('it exists', function(assert) {
let route = this.owner.lookup('route:scopes/scope/roles/role/grants');
assert.ok(route);
});
});
+4
View File
@@ -1,4 +1,5 @@
actions:
add: Add
cancel: Cancel
delete: Delete
dismiss: Dismiss
@@ -47,6 +48,8 @@ titles:
users-welcome: Welcome to Users
roles-welcome: Welcome to Roles
groups-welcome: Welcome to Groups
grants: Permissions
new-grant: New Grant
descriptions:
host-catalogs: Overview of collections of hosts and host sets.
empty-set: There are no items to display yet. You may be able to add items or try back later.
@@ -69,6 +72,7 @@ form:
identification: Username
password: Password
manage: Manage
grant: Grant
errors:
titles:
unauthenticated-error: You are not signed in