Merge pull request #92 from hashicorp/refactor-scopes-ui

Refactor scopes ui
This commit is contained in:
Randall Morey authored and GitHub committed 2020-07-28 08:39:33 -04:00
commit bfad08637b
49 files changed
+780 -200

No files matched your search

+26 -6
View File
@@ -59,8 +59,13 @@ export default class ApplicationAdapter extends RESTAdapter.extend(
// =methods
/**
* Prepends a scope to the URL prefix, if a scope is passed via the
* snapshot's `adapterOptions.scopeID` field.
* Prepends a scope to the URL prefix. Precedence is given to the ID passed
* via `adapterOptions.scopeID`. If this value isn't found in
* `adapterOptions`, we look to the `scope.scope_id` field on the snapshot,
* which most resources have.
*
* Note: scopes themselves never receive a scope URL prefix.
*
* @override
* @param {string} path
* @param {string} parentURL
@@ -69,12 +74,27 @@ export default class ApplicationAdapter extends RESTAdapter.extend(
* @param {object} snapshot
* @return {string}
*/
urlPrefix(path, parentURL, modelName, id, snapshot={}) {
urlPrefix(path, parentURL, modelName, id, snapshot) {
const prefix = super.urlPrefix(...arguments);
let scopePath = getWithDefault(snapshot, 'adapterOptions.scopeID', '');
const isScope = modelName === 'scope';
let scopePath = '';
let scopeID = '';
if (snapshot) {
// Not all snapshots have `attr` (such as array snapshots),
// so we do this sort of ugly check.
if (snapshot.attr) {
const parentScope = snapshot.attr('scope');
if (parentScope) scopeID = parentScope.attr('scope_id');
}
// Attempt to get scopeID from adapterOptions and fallback on its current
// value if it wasn't passed through options.
scopeID = getWithDefault(snapshot, 'adapterOptions.scopeID', scopeID);
}
// Only non-scope resources need a scope path, since scope resources
// aren't technically "scoped" the same way.
// Ensure a slash is added between prefix + scope path if needed.
if (scopePath && prefix.charAt(prefix.length - 1) !== '/') {
scopePath = `/scopes/${scopePath}`;
if (!isScope && scopeID && prefix.charAt(prefix.length - 1) !== '/') {
scopePath = `/scopes/${scopeID}`;
}
return `${prefix}${scopePath}`;
}
+27
View File
@@ -0,0 +1,27 @@
import ApplicationAdapter from './application';
import { serializeIntoHash } from '@ember-data/adapter/-private';
export default class ScopeAdapter extends ApplicationAdapter {
/**
* Identical to default method except that we pass a query parameter
* indicating the parent scope.
* @override
* @param {Store} store
* @param {Model} type
* @param {Snapshot} snapshot
* @return {Promise} promise
*/
createRecord(store, type, snapshot) {
const parentScopeID = snapshot.attr('scope').attr('scope_id');
let url =
this.buildURL(type.modelName, null, snapshot, 'createRecord');
// TODO this should be generalized into `buildURL`, but currently Ember Data
// doesn't provide a way to manage query parameters. They are passed on
// to the underlying ajax/fetch service.
url = `${url}?scope_id=${parentScopeID}`;
const data = serializeIntoHash(store, type, snapshot);
return this.ajax(url, 'POST', { data });
}
}
-28
View File
@@ -84,32 +84,4 @@ export default class BaseModel extends Model {
return !this.canSave;
}
// =methods
/**
* If this model instance has a related scope, the scope's ID will be passed
* via `adapterOptions.scopeID` automatically. End users may optionally
* pass a custom scope ID to override.
*
* @example
* model.save(); // passes the model's current scope ID
* model.save({adapterOptions: {scopeID: 'global'}}); // override scope ID
*
* End users may specify a _custom method_ via `adapterOptions.method`.
* Custom methods are appended to the record's URL by the adapter.
*
* @example
* // Results in a URL: `/v1/model-name/id:my-custom-method`
* model.save({adapterOptions: {method: 'my-custom-method'}});
*
* @return {Promise}
*/
save(options={adapterOptions: {}}) {
const scopeID = this.scopeID;
if (scopeID && !options.adapterOptions.scopeID) {
options.adapterOptions.scopeID = scopeID;
}
return super.save(options);
}
}
+19 -1
View File
@@ -1,5 +1,6 @@
import RESTSerializer from '@ember-data/serializer/rest';
import { underscore } from '@ember/string';
import { get } from '@ember/object';
import { copy } from 'ember-copy';
/**
@@ -117,7 +118,7 @@ export default class ApplicationSerializer extends RESTSerializer {
primaryModelClass.modelName
);
// Copy the unrooted payload under the expected root key name.
transformedPayload[payloadKey] = copy(payload);
transformedPayload[payloadKey] = copy(payload, true);
// Return the result of normalizing the transformed payload.
return super.normalizeSingleResponse(
store,
@@ -128,4 +129,21 @@ export default class ApplicationSerializer extends RESTSerializer {
);
}
/**
* One consequence of using model fragments to represent the embedded scope
* is that they cannot have `id` fields. We still need to know scope IDs,
* so we copy the `scope.id` value into the `scope.scope_id` field.
* @override
* @see FragmentScope
* @param {Model} typeClass
* @param {Object} hash
* @return {Object}
*/
normalize(typeClass, hash) {
const normalizedHash = copy(hash, true);
const scopeID = get(normalizedHash, 'scope.id');
if (scopeID) normalizedHash.scope.scope_id = scopeID;
return super.normalize(typeClass, normalizedHash);
}
}
-36
View File
@@ -1,36 +0,0 @@
import ApplicationSerializer from './application';
export default class ScopeSerializer extends ApplicationSerializer {
// =attributes
attrs = {
scope: { serialize: true }
};
// =methods
/**
* While most resources do not serialize scope into a request body (scope
* is expressed in the URL), scopes themselves _do serialize scope_ as a
* string field `scope_id`.
* Otherwise delegate to default attribute serializer.
*
* @override
* @method serializeAttribute
* @param {Snapshot} snapshot
* @param {Object} json
* @param {String} key
* @param {Object} attribute
*/
serializeAttribute(snapshot, json, key) {
const value = super.serializeAttribute(...arguments);
const parentScope = snapshot.attr('scope');
if (key === 'scope' && this._canSerialize(key) && parentScope) {
json.scope_id = parentScope.attr('scope_id');
delete json[key];
}
return value;
}
}
+1
View File
@@ -0,0 +1 @@
export { default } from 'api/adapters/scope';
-1
View File
@@ -1 +0,0 @@
export { default } from 'api/serializers/scope';
@@ -29,12 +29,22 @@ module('Unit | Adapter | application', function (hooks) {
const scopeID = 'global';
const adapter = this.owner.lookup('adapter:application');
const prefix = adapter.urlPrefix(null, null, null, null, {
adapterOptions: {scopeID}
adapterOptions: { scopeID }
});
assert.ok(config.api.namespace);
assert.equal(prefix, `v1/scopes/${scopeID}`);
});
test('it does not prefixes URLs with `scopeID` for scope resources', function (assert) {
assert.expect(1);
const scopeID = 'global';
const adapter = this.owner.lookup('adapter:application');
const prefix = adapter.urlPrefix(null, null, 'scope', null, {
adapterOptions: { scopeID }
});
assert.equal(prefix, `v1`);
});
test('it generates correct default URL suffixes', function (assert) {
assert.expect(1);
const adapter = this.owner.lookup('adapter:application');
@@ -46,7 +56,7 @@ module('Unit | Adapter | application', function (hooks) {
const method = 'set-something';
const adapter = this.owner.lookup('adapter:application');
const suffix = adapter.urlSuffix(null, null, {
adapterOptions: {method}
adapterOptions: { method }
});
assert.ok(config.api.namespace);
assert.equal(suffix, ':set-something');
@@ -85,7 +95,9 @@ module('Unit | Adapter | application', function (hooks) {
assert.ok(true, 'Scoped resource URL was requested.');
return {items: []};
});
await store.findAll('group', {adapterOptions: {scopeID: 'p_456'}});
await store.findAll('group', {
adapterOptions: { scopeID: 'p_456' }
});
});
test('it rewrites PUT to PATCH, but leaves others unchanged', function (assert) {
@@ -0,0 +1,23 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
module('Unit | Adapter | scope', function(hooks) {
setupTest(hooks);
setupMirage(hooks);
test('it generates correct createRecord URLs with a scope_id query parameter', function (assert) {
assert.expect(1);
this.server.post('/v1/scopes', (schema, { queryParams }) =>{
assert.equal(queryParams.scope_id, 'o_1');
});
const store = this.owner.lookup('service:store');
const scope = store.createRecord('scope', {
scopeID: 'o_1'
});
const type = scope.constructor;
const snapshot = scope._createSnapshot();
const adapter = this.owner.lookup('adapter:scope');
adapter.createRecord(store, type, snapshot);
});
});
@@ -36,9 +36,9 @@ module('Unit | Serializer | application', function (hooks) {
const userModelClass = store.createRecord('user').constructor;
const payload = {
items: [
{ id: 1, name: 'User 1' },
{ id: 2, name: 'User 2' },
{ id: 3, name: 'User 3' },
{ id: 1, name: 'User 1', scope: { id: 'o_123' } },
{ id: 2, name: 'User 2', scope: { id: 'o_123' } },
{ id: 3, name: 'User 3', scope: { id: 'o_123' } },
],
};
const normalizedArray = serializer.normalizeArrayResponse(
@@ -52,19 +52,19 @@ module('Unit | Serializer | application', function (hooks) {
{
id: '1',
type: 'user',
attributes: { name: 'User 1' },
attributes: { name: 'User 1', scope: { scope_id: 'o_123' } },
relationships: {},
},
{
id: '2',
type: 'user',
attributes: { name: 'User 2' },
attributes: { name: 'User 2', scope: { scope_id: 'o_123' } },
relationships: {},
},
{
id: '3',
type: 'user',
attributes: { name: 'User 3' },
attributes: { name: 'User 3', scope: { scope_id: 'o_123' } },
relationships: {},
},
],
@@ -79,6 +79,7 @@ module('Unit | Serializer | application', function (hooks) {
const payload = {
id: '1',
name: 'User 1',
scope: { id: 'o_123' }
};
const normalized = serializer.normalizeSingleResponse(
store,
@@ -90,7 +91,7 @@ module('Unit | Serializer | application', function (hooks) {
data: {
id: '1',
type: 'user',
attributes: { name: 'User 1' },
attributes: { name: 'User 1', scope: { scope_id: 'o_123' } },
relationships: {},
},
});
@@ -1,32 +0,0 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Serializer | scope', function(hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function(assert) {
let store = this.owner.lookup('service:store');
let serializer = store.serializerFor('scope');
assert.ok(serializer);
});
test('it serializes records', function (assert) {
assert.expect(1);
const store = this.owner.lookup('service:store');
const record = store.createRecord('scope', {
type: 'org',
name: 'User',
description: 'Description',
scopeID: 'global'
});
const serializedRecord = record.serialize();
assert.deepEqual(serializedRecord, {
type: 'org',
name: 'User',
description: 'Description',
scope_id: 'global'
});
});
});
@@ -0,0 +1,19 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
export default class ScopesScopeProjectsController extends Controller {
// =services
@service intl;
// =attributes
/**
* @type {string}
*/
get breadCrumb() {
return this.intl.t('resources.projects');
}
}
@@ -0,0 +1,19 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
export default class ScopesScopeProjectsNewController extends Controller {
// =services
@service intl;
// =attributes
/**
* @type {string}
*/
get breadCrumb() {
return this.intl.t('titles.new');
}
}
@@ -0,0 +1,14 @@
import Controller from '@ember/controller';
export default class ScopesScopeProjectsProjectController extends Controller {
// =attributes
/**
* @type {string}
*/
get breadCrumb() {
return this.model.displayName;
}
}
+11 -1
View File
@@ -7,6 +7,16 @@ export default class Router extends EmberRouter {
}
Router.map(function () {
this.route('scopes', function() {
this.route('scope', { path: ':scope_id' }, function() {
this.route('projects', function() {
this.route('project', { path: ':project_id' }, function() {});
this.route('new');
});
});
});
this.route('orgs', function () {
this.route('org', { path: ':org_id' }, function () {
this.route('projects', function () {
@@ -23,7 +33,7 @@ Router.map(function () {
this.route('user', { path: ':user_id' }, function() {});
this.route('new');
});
this.route('roles', function() {
this.route('role', { path: ':role_id' }, function() {});
this.route('new');
+4
View File
@@ -0,0 +1,4 @@
import Route from '@ember/routing/route';
export default class ScopesRoute extends Route {
}
+28
View File
@@ -0,0 +1,28 @@
import Route from '@ember/routing/route';
export default class ScopesScopeRoute extends Route {
// =methods
/**
* Attempt to load the specified scope from the API. This is allowed
* to fail, since in some cases the user may not have permission to read a
* scope directly, but may have permission to read resources under it.
* If the scope fails to load, we still proceed using a temporary scope object
* consisting of only the specified ID.
* @param {object} params
* @param {string} params.scope_id
* @return {Promise{ScopeModel}}
*/
async model({ scope_id: id }) {
try {
return await this.store.findRecord('scope', id);
} catch {
const maybeExistingScope = this.store.peekRecord('scope', id);
// TODO it's unclear if creating a temporary unsaved scope is reliable...
// we may need to revert to a POJO.
return maybeExistingScope || this.store.createRecord('scope', { id });
}
}
}
@@ -0,0 +1,86 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
export default class ScopesScopeProjectsRoute extends Route {
// =services
@service notify;
// =methods
/**
* Loads all scopes under the current scope.
* @return {Promise{[ScopeModel]}}
*/
async model() {
const { id: scope_id } = this.modelFor('scopes.scope');
return this.store.query('scope', { scope_id });
}
/**
* If arriving here within a verifiable project scope, redirect to its
* parent, which is necessarily an org, in order to list projects.
*
* TODO: this conditional redirect based on the scope type is probably
* necessary for many routes directly under scope, so we may want
* to move this into a mixin.
* @param {ScopeModel}
*/
redirect() {
const { isProject, scopeID } = this.modelFor('scopes.scope');
if (isProject) this.transitionTo('scopes.scope.projects', scopeID);
}
// =actions
/**
* Rollback changes on project.
* @param {ProjectModel} project
*/
@action
cancel(project) {
const { isNew } = project;
project.rollbackAttributes();
if (isNew) this.transitionTo('scopes.scope.projects');
}
/**
* Handle save project.
* @param {ProjectModel} project
* @param {Event} e
*/
@action
async save(project) {
try {
await project.save();
this.refresh();
// TODO: replace with translated strings
this.notify.success('Save succeeded.');
this.transitionTo('scopes.scope.projects.project', project);
} catch (error) {
// TODO: replace with translated strings
this.notify.error(error.message, { closeAfter: null });
}
}
/**
* Deletes the project and redirects to index.
* @param {ProjectModel} project
*/
@action
async delete(project) {
try {
await project.destroyRecord();
this.refresh();
this.transitionTo('scopes.scope.projects');
// TODO: replace with translated strings
this.notify.success('Deleted project succesfully.');
} catch (error) {
// TODO: replace with translated strings
this.notify.error(error.message, { closeAfter: null });
}
}
}
@@ -0,0 +1,4 @@
import Route from '@ember/routing/route';
export default class ScopesScopeProjectsIndexRoute extends Route {
}
@@ -0,0 +1,19 @@
import Route from '@ember/routing/route';
export default class ScopesScopeProjectsNewRoute extends Route {
// =methods
/**
* Creates a new unsaved project scope belonging to the current org scope.
* @return {ScopeModel}
*/
model() {
const { id } = this.modelFor('scopes.scope');
return this.store.createRecord('scope', {
type: 'project',
scopeID: id
});
}
}
@@ -0,0 +1,17 @@
import Route from '@ember/routing/route';
export default class ScopesScopeProjectsProjectRoute extends Route {
// =methods
/**
* Loads a scope by ID (ostensibly a project scope).
* @param {object} params
* @param {string} params.project_id
* @return {ScopeModel}
*/
model({ project_id }) {
return this.store.findRecord('scope', project_id);
}
}
@@ -0,0 +1,4 @@
import Route from '@ember/routing/route';
export default class ScopesScopeProjectsProjectIndexRoute extends Route {
}
+3
View File
@@ -1,3 +1,6 @@
<HeadLayout />
{{title (app-name)}}
<Rose::Layout::Global as |layout|>
<layout.header>
+1
View File
@@ -0,0 +1 @@
{{outlet}}
+37
View File
@@ -0,0 +1,37 @@
<Rose::Layout::Sidebar as |sidebarLayout|>
<sidebarLayout.sidebar>
<Rose::Nav::Sidebar @title={{t "titles.general"}} as |nav|>
<nav.link @route="scopes.scope.projects">
{{t "resources.projects"}}
</nav.link>
</Rose::Nav::Sidebar>
{{outlet "sidebar"}}
{{!--
<Rose::Nav::Sidebar @title={{t "titles.iam"}} as |nav|>
<nav.link @route="orgs.org.users">
{{t "resources.users"}}
</nav.link>
<nav.link @route="orgs.org.groups">
{{t "resources.groups"}}
</nav.link>
<nav.link @route="orgs.org.roles">
{{t "resources.roles"}}
</nav.link>
</Rose::Nav::Sidebar>
--}}
</sidebarLayout.sidebar>
<sidebarLayout.body>
<main>
{{outlet}}
</main>
</sidebarLayout.body>
</Rose::Layout::Sidebar>
@@ -0,0 +1,3 @@
{{title (t "resources.projects")}}
{{outlet}}
@@ -0,0 +1,56 @@
<Rose::Layout::Page as |page|>
<page.breadcrumbs>
<BreadCrumbs />
</page.breadcrumbs>
<page.header>
<h2>{{t "resources.projects"}}</h2>
</page.header>
<page.actions>
{{#if @model}}
<Rose::LinkButton
@route="scopes.scope.projects.new"
@style="primary"
>
{{t 'titles.new'}}
</Rose::LinkButton>
{{/if}}
</page.actions>
<page.body>
{{#if @model}}
<Rose::Cards as |cards|>
{{#each @model as |project|}}
<cards.link
@title={{project.name}}
@subtitle={{project.id}}
@description={{project.description}}
@route="scopes.scope.projects.project"
@model={{project}}
/>
{{/each}}
</Rose::Cards>
{{else}}
<Rose::Layout::Centered>
<Rose::Message @title={{t 'titles.projects-welcome'}} as |message|>
<message.description>
{{t 'descriptions.projects-welcome'}}
</message.description>
<message.link @route="scopes.scope.projects.new">
<Rose::Icon @name="plus-circle-outline" />
{{t 'titles.new'}}
</message.link>
</Rose::Message>
</Rose::Layout::Centered>
{{/if}}
</page.body>
</Rose::Layout::Page>
@@ -0,0 +1,21 @@
{{title (t 'titles.new')}}
<Rose::Layout::Page as |page|>
<page.breadcrumbs>
<BreadCrumbs />
</page.breadcrumbs>
<page.header>
<h2>{{t "titles.new"}}</h2>
</page.header>
<page.body>
<Form::Project
@model={{@model}}
@submit={{route-action "save" @model}}
@cancel={{route-action "cancel" @model}}
/>
</page.body>
</Rose::Layout::Page>
@@ -0,0 +1,3 @@
{{title @model.displayName}}
{{outlet}}
@@ -0,0 +1,35 @@
<Rose::Layout::Page as |page|>
<page.breadcrumbs>
<BreadCrumbs />
</page.breadcrumbs>
<page.header>
<h2>{{t "resources.project"}}</h2>
</page.header>
<page.navigation>
<Rose::Nav::Tabs as |nav|>
<nav.link @route='scopes.scope.projects.project'>
{{t 'titles.details'}}
</nav.link>
</Rose::Nav::Tabs>
</page.navigation>
<page.body>
<Form::Project
@model={{@model}}
@submit={{route-action "save" @model}}
@cancel={{route-action "cancel" @model}}
/>
<Rose::Separator />
<Rose::Button
@style="warning"
@disabled={{@model.canSave}}
{{on "click" (route-action "delete" @model)}}
>
{{t 'actions.delete'}}
</Rose::Button>
</page.body>
</Rose::Layout::Page>
+58 -43
View File
@@ -1,5 +1,5 @@
import config from '../config/environment';
// import { Response } from 'miragejs';
import { Response } from 'miragejs';
export default function() {
@@ -16,66 +16,81 @@ export default function() {
this.passthrough();
// Scope resources
// org
this.get('/orgs');
this.post('/orgs');
this.get('/orgs/:id');
this.patch('/orgs/:id');
this.del('/orgs/:id');
// project
this.get('/orgs/:org_id/projects');
this.post('/orgs/:org_id/projects');
this.get('/orgs/:org_id/projects/:id');
this.patch('/orgs/:org_id/projects/:id');
this.del('/orgs/:org_id/projects/:id');
this.get('/scopes', ({ scopes }, { queryParams: { scope_id } }) => {
// Default parent scope is global
if (!scope_id) scope_id = 'global';
return scopes.where(scope => {
return scope.scope ? scope.scope.id === scope_id : false
});
});
this.post('/scopes', function ({ scopes }, request) {
// Parent scope comes through the payload via `scope_id`, but this needs
// to be expanded to a scope object `scope: {id:..., type:...}` before
// saving, which is the gist of this function.
const attrs = this.normalizedRequestAttrs();
const parentScopeAttrs = this.serialize(scopes.find(request.queryParams.scope_id));
attrs.scope = parentScopeAttrs;
return scopes.create(attrs);
});
// To simulate a possible real-world case, org scopes are not returned,
// but project scopes are.
// TODO this should be expanded to support other scenarios.
this.get('/scopes/:id', ({ scopes }, { params: { id: scopeID } }) => {
const scope = scopes.find(scopeID);
if (scope.type !== 'project') {
return new Response(404, {}, {});
} else {
return scope;
}
});
this.patch('/scopes/:id');
this.del('/scopes/:id');
// Auth & IAM resources
this.get('/orgs/:org_id/auth-methods');
this.post('/orgs/:org_id/auth-methods');
this.get('/orgs/:org_id/auth-methods/:id');
this.patch('/orgs/:org_id/auth-methods/:id');
this.del('/orgs/:org_id/auth-methods/:id');
this.get('/scopes/:scope_id/auth-methods');
this.post('/scopes/:scope_id/auth-methods');
this.get('/scopes/:scope_id/auth-methods/:id');
this.patch('/scopes/:scope_id/auth-methods/:id');
this.del('/scopes/:scope_id/auth-methods/:id');
// IAM : Users
this.get('/orgs/:org_id/users');
this.post('/orgs/:org_id/users');
this.get('/orgs/:org_id/users/:id');
this.patch('/orgs/:org_id/users/:id');
this.del('/orgs/:org_id/users/:id');
this.get('/scopes/:scope_id/users');
this.post('/scopes/:scope_id/users');
this.get('/scopes/:scope_id/users/:id');
this.patch('/scopes/:scope_id/users/:id');
this.del('/scopes/:scope_id/users/:id');
// IAM: Roles
this.get('/orgs/:org_id/roles');
this.post('/orgs/:org_id/roles');
this.get('/orgs/:org_id/roles/:id');
this.patch('/orgs/:org_id/roles/:id');
this.del('/orgs/:org_id/roles/:id');
this.get('/scopes/:scope_id/roles');
this.post('/scopes/:scope_id/roles');
this.get('/scopes/:scope_id/roles/:id');
this.patch('/scopes/:scope_id/roles/:id');
this.del('/scopes/:scope_id/roles/:id');
// group
this.get('/orgs/:org_id/groups');
this.post('/orgs/:org_id/groups');
this.get('/orgs/:org_id/groups/:id');
this.patch('/orgs/:org_id/groups/:id');
this.del('/orgs/:org_id/groups/:id');
this.get('/scopes/:scope_id/groups');
this.post('/scopes/:scope_id/groups');
this.get('/scopes/:scope_id/groups/:id');
this.patch('/scopes/:scope_id/groups/:id');
this.del('/scopes/:scope_id/groups/:id');
// Other resources
// host-catalog
this.get('/orgs/:org_id/projects/:project_id/host-catalogs');
this.post('/orgs/:org_id/projects/:project_id/host-catalogs');
this.get('/orgs/:org_id/projects/:project_id/host-catalogs/:id');
this.patch('/orgs/:org_id/projects/:project_id/host-catalogs/:id');
this.del('/orgs/:org_id/projects/:project_id/host-catalogs/:id');
this.get('/scopes/:scope_id/host-catalogs');
this.post('/scopes/:scope_id/host-catalogs');
this.get('/scopes/:scope_id/host-catalogs/:id');
this.patch('/scopes/:scope_id/host-catalogs/:id');
this.del('/scopes/:scope_id/host-catalogs/:id');
// Uncomment the following line and the Response import above
// Then change the response code to simulate error responses.
// this.get('/orgs/:org_id/projects', () => new Response(505));
// this.get('/scopes/:scope_id/projects', () => new Response(505));
// Update error payload to simulate specific error responses.
// this.get('/orgs/:org_id/projects', () => new Response(505, {}, {
// this.get('/scopes/:scope_id/projects', () => new Response(505, {}, {
// errors: [{
// status: 505,
// message: 'HTTP version not supported.',
+25
View File
@@ -0,0 +1,25 @@
import factory from '../generated/factories/scope';
import { trait } from 'ember-cli-mirage';
export default factory.extend({
type: 'global',
withChildren: trait({
afterCreate(record, server) {
if (record.type === 'global') {
server.createList('scope', 1, {
type: 'org',
scope: { id: record.id, type: record.type }
}, 'withChildren');
}
if (record.type === 'org') {
server.createList('scope', 5, {
type: 'project',
scope: { id: record.id, type: record.type }
});
}
}
})
});
@@ -0,0 +1,15 @@
import { Factory } from 'ember-cli-mirage';
import { random, date } from 'faker';
/**
* GeneratedScopeModelFactory
*/
export default Factory.extend({
name: () => random.words(),
description: () => random.words(),
created_time: () => date.recent(),
updated_time: () => date.recent(),
disabled: () => random.boolean(),
});
+1 -2
View File
@@ -2,8 +2,7 @@ export default function(server) {
// Scope resources
server.createList('org', 3);
server.createList('project', 6);
server.create('scope', null, 'withChildren');
// Auth
+5
View File
@@ -0,0 +1,5 @@
import ApplicationSerializer from './application';
export default ApplicationSerializer.extend({
modelName: 'scope'
});
+1
View File
@@ -54,6 +54,7 @@
"ember-cli-template-lint": "^1.0.0-beta.3",
"ember-cli-uglify": "^3.0.0",
"ember-data": "~3.16.0",
"ember-data-model-fragments": "5.0.0-beta.0",
"ember-exam": "^5.0.1",
"ember-export-application-global": "^2.0.1",
"ember-fetch": "^7.0.0",
+57 -39
View File
@@ -9,41 +9,65 @@ module('Acceptance | projects', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
let org;
let orgID;
let projectsURL;
let newProjectURL;
let existingProject;
let existingProjectURL;
let getProjectScopesCount;
let getFirstProjectScope;
let initialProjectScopesCount;
hooks.beforeEach(function () {
this.server.create('org');
org = this.server.create('scope', {type: 'org'}, 'withChildren');
getProjectScopesCount = () => this.server.schema.scopes.where({type: 'project'}).length;
getFirstProjectScope = () => this.server.schema.scopes.where({type: 'project'}).models[0];
initialProjectScopesCount = getProjectScopesCount();
orgID = org.id;
projectsURL = `/scopes/${orgID}/projects`;
newProjectURL = `/scopes/${orgID}/projects/new`;
existingProject = getFirstProjectScope();
existingProjectURL = `/scopes/${orgID}/projects/${existingProject.id}`;
});
test('visiting projects', async function (assert) {
assert.expect(1);
this.server.createList('project', 1);
await visit('/orgs/1/projects');
await visit(projectsURL);
await a11yAudit();
assert.equal(currentURL(), '/orgs/1/projects');
assert.equal(currentURL(), projectsURL);
});
test('visiting projects within a project scope redirects to the parent org scope', async function (assert) {
assert.expect(2);
const wrongProjectsURL = `/scopes/${existingProject.id}/projects`;
assert.notEqual(wrongProjectsURL, projectsURL);
await visit(wrongProjectsURL);
await a11yAudit();
assert.equal(currentURL(), projectsURL, 'Wrong projects path was redirected to correct path.');
});
test('can create new projects', async function (assert) {
assert.expect(3);
assert.equal(this.server.db.projects.length, 0);
await visit('/orgs/1/projects/new');
assert.expect(1);
await visit(newProjectURL);
await fillIn('[name="name"]', 'random string');
await click('[type="submit"]');
assert.equal(currentURL(), '/orgs/1/projects/1');
assert.equal(this.server.db.projects.length, 1);
assert.equal(getProjectScopesCount(), initialProjectScopesCount + 1);
});
test('can cancel create new projects', async function (assert) {
assert.expect(3);
assert.equal(this.server.db.projects.length, 0);
await visit('/orgs/1/projects/new');
assert.equal(getProjectScopesCount(), initialProjectScopesCount);
await visit(newProjectURL);
await fillIn('[name="name"]', 'random string');
await click('.rose-form-actions [type="button"]');
assert.equal(currentURL(), '/orgs/1/projects');
assert.equal(this.server.db.projects.length, 0);
assert.equal(currentURL(), projectsURL);
assert.equal(getProjectScopesCount(), initialProjectScopesCount);
});
test('saving a new project with invalid fields displays error messages', async function (assert) {
assert.expect(2);
this.server.post('/orgs/:org_id/projects', () => {
this.server.post('/scopes', () => {
return new Response(
400,
{},
@@ -62,8 +86,7 @@ module('Acceptance | projects', function (hooks) {
}
);
});
await visit('/orgs/1/projects/new');
await fillIn('[name="name"]', 'random string');
await visit(newProjectURL);
await click('[type="submit"]');
assert.ok(
find('[role="alert"]').textContent.trim(),
@@ -78,37 +101,34 @@ module('Acceptance | projects', function (hooks) {
});
test('can save changes to existing project', async function (assert) {
assert.expect(2);
this.server.createList('project', 1, {name: 'MyProject'});
await visit('/orgs/1/projects/1');
assert.expect(3);
assert.notEqual(existingProject.name, 'random string');
await visit(existingProjectURL);
await fillIn('[name="name"]', 'random string');
await click('.rose-form-actions [type="submit"]');
assert.equal(currentURL(), '/orgs/1/projects/1');
assert.equal(this.server.db.projects[0].name, 'random string');
assert.equal(currentURL(), existingProjectURL);
assert.equal(getFirstProjectScope().name, 'random string');
});
test('can cancel changes to existing project', async function (assert) {
assert.expect(1);
this.server.createList('project', 1, {name: 'MyProject'});
await visit('/orgs/1/projects/1');
assert.expect(2);
await visit(existingProjectURL);
await fillIn('[name="name"]', 'random string');
await click('.rose-form-actions [type="button"]');
assert.equal(find('[name="name"]').value, 'MyProject');
assert.notEqual(existingProject.name, 'random string');
assert.equal(find('[name="name"]').value, existingProject.name);
});
test('can delete project', async function(assert) {
assert.expect(2);
this.server.createList('project', 1);
assert.equal(this.server.db.projects.length, 1);
await visit('/orgs/1/projects/1');
assert.expect(1);
await visit(existingProjectURL);
await click('.rose-button-warning');
assert.equal(this.server.db.projects.length, 0);
assert.equal(getProjectScopesCount(), initialProjectScopesCount - 1);
});
test('saving an existing project with invalid fields displays error messages', async function (assert) {
assert.expect(2);
this.server.createList('project', 1);
this.server.patch('/orgs/:org_id/projects/:id', () => {
this.server.patch('/scopes/:id', () => {
return new Response(
400,
{},
@@ -127,7 +147,7 @@ module('Acceptance | projects', function (hooks) {
}
);
});
await visit('/orgs/1/projects/1');
await visit(existingProjectURL);
await fillIn('[name="name"]', 'random string');
await click('[type="submit"]');
assert.ok(
@@ -144,8 +164,7 @@ module('Acceptance | projects', function (hooks) {
test('errors are displayed when save project fails', async function (assert) {
assert.expect(1);
this.server.createList('project', 1);
this.server.patch('/orgs/:org_id/projects/:id', () => {
this.server.patch('/scopes/:id', () => {
return new Response(
490,
{},
@@ -156,7 +175,7 @@ module('Acceptance | projects', function (hooks) {
}
);
});
await visit('/orgs/1/projects/1');
await visit(existingProjectURL);
await fillIn('[name="name"]', 'random string');
await click('[type="submit"]');
assert.ok(
@@ -168,8 +187,7 @@ module('Acceptance | projects', function (hooks) {
test('errors are displayed when delete project fails', async function (assert) {
assert.expect(1);
this.server.createList('project', 1);
this.server.del('/orgs/:org_id/projects/:id', () => {
this.server.del('/scopes/:id', () => {
return new Response(
490,
{},
@@ -180,7 +198,7 @@ module('Acceptance | projects', function (hooks) {
}
);
});
await visit('/orgs/1/projects/1');
await visit(existingProjectURL);
await click('.rose-button-warning');
assert.ok(
find('[role="alert"]').textContent.trim(),
View File
Whitespace-only changes.
@@ -0,0 +1,12 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Controller | scopes/scope/projects', function(hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function(assert) {
let controller = this.owner.lookup('controller:scopes/scope/projects');
assert.ok(controller);
});
});
@@ -0,0 +1,12 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Controller | scopes/scope/projects/new', function(hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function(assert) {
let controller = this.owner.lookup('controller:scopes/scope/projects/new');
assert.ok(controller);
});
});
@@ -0,0 +1,12 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Controller | scopes/scope/projects/project', function(hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function(assert) {
let controller = this.owner.lookup('controller:scopes/scope/projects/project');
assert.ok(controller);
});
});
+11
View File
@@ -0,0 +1,11 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Route | scopes', function(hooks) {
setupTest(hooks);
test('it exists', function(assert) {
let route = this.owner.lookup('route:scopes');
assert.ok(route);
});
});
@@ -0,0 +1,11 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Route | scopes/scope', function(hooks) {
setupTest(hooks);
test('it exists', function(assert) {
let route = this.owner.lookup('route:scopes/scope');
assert.ok(route);
});
});
@@ -0,0 +1,11 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Route | scopes/scope/projects', function(hooks) {
setupTest(hooks);
test('it exists', function(assert) {
let route = this.owner.lookup('route:scopes/scope/projects');
assert.ok(route);
});
});
@@ -0,0 +1,11 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Route | scopes/scope/projects/index', function(hooks) {
setupTest(hooks);
test('it exists', function(assert) {
let route = this.owner.lookup('route:scopes/scope/projects/index');
assert.ok(route);
});
});
@@ -0,0 +1,11 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Route | scopes/scope/projects/new', function(hooks) {
setupTest(hooks);
test('it exists', function(assert) {
let route = this.owner.lookup('route:scopes/scope/projects/new');
assert.ok(route);
});
});
@@ -0,0 +1,11 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Route | scopes/scope/projects/project', function(hooks) {
setupTest(hooks);
test('it exists', function(assert) {
let route = this.owner.lookup('route:scopes/scope/projects/project');
assert.ok(route);
});
});
@@ -0,0 +1,11 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Route | scopes/scope/projects/project/index', function(hooks) {
setupTest(hooks);
test('it exists', function(assert) {
let route = this.owner.lookup('route:scopes/scope/projects/project/index');
assert.ok(route);
});
});
+2 -1
View File
@@ -6,7 +6,7 @@ actions:
help: Need help?
resources:
user: User
users: Users
users: Users
role: Role
roles: Roles
group: Group
@@ -18,6 +18,7 @@ resources:
titles:
iam: IAM
new: New
details: Details
general: General
overview: Overview
settings: Settings