Merge pull request #27 from hashicorp/chore-projects-crud
Chore projects crud
This commit is contained in:
59 files changed
+1714
-730
No files matched your search
@@ -0,0 +1,40 @@
|
||||
import RESTAdapter from '@ember-data/adapter/rest';
|
||||
import config from 'ember-get-config';
|
||||
import { get } from '@ember/object';
|
||||
|
||||
export default class ApplicationAdapter extends RESTAdapter {
|
||||
// =attributes
|
||||
|
||||
/**
|
||||
* Sets host to the `api.host` string in the application's config,
|
||||
* if set.
|
||||
* @type {string}
|
||||
*/
|
||||
host = get(config, 'api.host');
|
||||
|
||||
/**
|
||||
* Sets namespace to the `api.namespace` string in the application's config,
|
||||
* if set.
|
||||
* @type {string}
|
||||
*/
|
||||
namespace = get(config, 'api.namespace');
|
||||
|
||||
// =methods
|
||||
|
||||
/**
|
||||
* Overrides default ajax method by rewritting PUT requests as PATCH.
|
||||
* PATCH is the request method used by our API for updates.
|
||||
*
|
||||
* @override
|
||||
* @method ajax
|
||||
* @private
|
||||
* @param {String} url
|
||||
* @param {String} type The request type GET, POST, PUT, DELETE etc.
|
||||
* @param {Object} options
|
||||
* @return {Promise} promise
|
||||
*/
|
||||
ajax(url, type, options) {
|
||||
const method = type === 'PUT' ? 'PATCH' : type;
|
||||
return super.ajax(url, method, options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import ApplicationAdapter from './application';
|
||||
import ScopeAdapterMixin from '../mixins/scope-adapter';
|
||||
|
||||
export default class ProjectAdapter extends ApplicationAdapter.extend(
|
||||
ScopeAdapterMixin
|
||||
) {
|
||||
// =attributes
|
||||
|
||||
/**
|
||||
* Project is scoped to org only, technically.
|
||||
* @type {boolean}
|
||||
*/
|
||||
includeProject = false;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import BaseModel from '../../models/base';
|
||||
import { attr } from '@ember-data/model';
|
||||
|
||||
/**
|
||||
* Project contains all fields related to a Project resource
|
||||
*/
|
||||
export default class GeneratedProjectModel extends BaseModel {
|
||||
// =attributes
|
||||
|
||||
@attr('string', {
|
||||
description: 'Optional name for identification purposes',
|
||||
})
|
||||
name;
|
||||
|
||||
@attr('string', {
|
||||
description: 'Optional user-set description for identification purposes',
|
||||
})
|
||||
description;
|
||||
|
||||
@attr('date', {
|
||||
description: 'The time this resource was created\nOutput only.',
|
||||
readOnly: true,
|
||||
})
|
||||
created_time;
|
||||
|
||||
@attr('date', {
|
||||
description: 'The time this resource was last updated\nOutput only.',
|
||||
readOnly: true,
|
||||
})
|
||||
updated_time;
|
||||
|
||||
@attr('boolean', {
|
||||
description: 'Whether the resource is disabled',
|
||||
})
|
||||
disabled;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import Model from '@ember-data/model';
|
||||
import { computed } from '@ember/object';
|
||||
|
||||
export default class BaseModel extends Model {
|
||||
// =attributes
|
||||
|
||||
/**
|
||||
* @type {boolean}
|
||||
*/
|
||||
@computed('hasDirtyAttributes', 'isSaving')
|
||||
get canSave() {
|
||||
return this.hasDirtyAttributes && !this.isSaving;
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {boolean}
|
||||
*/
|
||||
@computed('canSave')
|
||||
get cannotSave() {
|
||||
return !this.canSave;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import GeneratedProjectModel from '../generated/models/project';
|
||||
|
||||
export default class ProjectModel extends GeneratedProjectModel {}
|
||||
@@ -0,0 +1,123 @@
|
||||
import RESTSerializer from '@ember-data/serializer/rest';
|
||||
import { underscore } from '@ember/string';
|
||||
import { copy } from 'ember-copy';
|
||||
|
||||
/**
|
||||
* Manages serialization/normalization of data to/from the API.
|
||||
*/
|
||||
export default class ApplicationSerializer extends RESTSerializer {
|
||||
// =methods
|
||||
|
||||
/**
|
||||
* Generates an underscored key for the attribute.
|
||||
* @param {string} attr
|
||||
* @return {string}
|
||||
*/
|
||||
keyForAttribute(attr) {
|
||||
return underscore(attr);
|
||||
}
|
||||
|
||||
/**
|
||||
* If an attribute is annotated as readOnly in the model, don't serialize it.
|
||||
* 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, { options }) {
|
||||
let value = super.serializeAttribute(...arguments);
|
||||
// Do not serialize read-only attributes.
|
||||
if (options.readOnly) delete json[key];
|
||||
// Do not serialize `disabled` fields.
|
||||
// TODO: disabled is temporarily disabled
|
||||
if (key === 'disabled') delete json[key];
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* In our API, request payloads are unrooted. But Ember Data roots request
|
||||
* payloads for this adapter by default. Instead of assigned a root key
|
||||
* on the outgoing hash, we copy the serialized attributes into it.
|
||||
*
|
||||
* @override
|
||||
* @method serializeIntoHash
|
||||
* @param {Object} hash
|
||||
* @param {Model} typeClass
|
||||
* @param {Snapshot} snapshot
|
||||
* @param {Object} options
|
||||
*/
|
||||
serializeIntoHash(hash, typeClass, snapshot, options) {
|
||||
const serialized = this.serialize(snapshot, options);
|
||||
Object.assign(hash, serialized);
|
||||
}
|
||||
|
||||
/**
|
||||
* In our API, array payloads are always rooted under the same key `items`.
|
||||
* Ember Data normally expects them to be rooted under their model name,
|
||||
* e.g. `{hostCatalogs: […]}`. This method makes this transformation to
|
||||
* accommodate Ember Data.
|
||||
*
|
||||
* @override
|
||||
* @param {object} store
|
||||
* @param {object} primaryModelClass
|
||||
* @param {object} payload
|
||||
* @param {?string} id
|
||||
* @param {string} requestType
|
||||
* @return {object}
|
||||
*/
|
||||
normalizeArrayResponse(store, primaryModelClass, payload, id, requestType) {
|
||||
// Setup a new payload data structure.
|
||||
const transformedPayload = {};
|
||||
// Find the Ember-data-expected root key name.
|
||||
const payloadKey = this.payloadKeyFromModelName(
|
||||
primaryModelClass.modelName
|
||||
);
|
||||
// Copy the data rooted under `items` in the existing payload into the new
|
||||
// payload under the expected root key name.
|
||||
transformedPayload[payloadKey] = copy(payload.items, true);
|
||||
// Return the result of normalizing the transformed payload.
|
||||
return super.normalizeArrayResponse(
|
||||
store,
|
||||
primaryModelClass,
|
||||
transformedPayload,
|
||||
id,
|
||||
requestType
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* In our API, singluar resources are _unrooted_, whereas Ember Data expects
|
||||
* them to be rooted under their model name, e.g. `{project: {…}}`.
|
||||
* This method makes this transformation to accommodate Ember Data.
|
||||
*
|
||||
* @method normalizeSingleResponse
|
||||
* @param {Store} store
|
||||
* @param {Model} primaryModelClass
|
||||
* @param {Object} payload
|
||||
* @param {String|Number} id
|
||||
* @param {String} requestType
|
||||
* @return {Object} JSON-API Document
|
||||
*/
|
||||
normalizeSingleResponse(store, primaryModelClass, payload, id, requestType) {
|
||||
// Setup a new payload data structure.
|
||||
const transformedPayload = {};
|
||||
// Find the Ember-data-expected root key name.
|
||||
const payloadKey = this.payloadKeyFromModelName(
|
||||
primaryModelClass.modelName
|
||||
);
|
||||
// Copy the unrooted payload under the expected root key name.
|
||||
transformedPayload[payloadKey] = copy(payload);
|
||||
// Return the result of normalizing the transformed payload.
|
||||
return super.normalizeSingleResponse(
|
||||
store,
|
||||
primaryModelClass,
|
||||
transformedPayload,
|
||||
id,
|
||||
requestType
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from 'api/adapters/application';
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from 'api/adapters/project';
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from 'api/models/base';
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from 'api/models/project';
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from 'api/serializers/application';
|
||||
@@ -38,9 +38,11 @@
|
||||
"ember-cli-sri": "^2.1.1",
|
||||
"ember-cli-template-lint": "^1.0.0-beta.3",
|
||||
"ember-cli-uglify": "^3.0.0",
|
||||
"ember-copy": "^1.0.0",
|
||||
"ember-data": "^3.16.0",
|
||||
"ember-disable-prototype-extensions": "^1.1.3",
|
||||
"ember-export-application-global": "^2.0.1",
|
||||
"ember-get-config": "^0.2.4",
|
||||
"ember-load-initializers": "^2.1.1",
|
||||
"ember-maybe-import-regenerator": "^0.1.6",
|
||||
"ember-qunit": "^4.6.0",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { inject as service } from '@ember/service';
|
||||
export default class ApplicationRoute extends Route {
|
||||
@service scope;
|
||||
|
||||
afterModel() {
|
||||
//console.log(this.scope);
|
||||
model() {
|
||||
this.store.findAll('project');
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,11 @@ module.exports = function (environment) {
|
||||
// Here you can pass flags/options to your application instance
|
||||
// when it is created
|
||||
},
|
||||
|
||||
api: {
|
||||
host: '',
|
||||
namespace: 'v1',
|
||||
},
|
||||
};
|
||||
|
||||
if (environment === 'development') {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
import config from 'ember-get-config';
|
||||
import RESTAdapter from '@ember-data/adapter/rest';
|
||||
|
||||
module('Unit | Adapter | application', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
test('its namespace is equal to the configured namespace', function (assert) {
|
||||
assert.expect(2);
|
||||
const adapter = this.owner.lookup('adapter:application');
|
||||
assert.ok(adapter.namespace);
|
||||
assert.equal(adapter.namespace, config.api.namespace);
|
||||
});
|
||||
|
||||
test('it generates correct URL prefixes', function (assert) {
|
||||
assert.expect(1);
|
||||
const adapter = this.owner.lookup('adapter:application');
|
||||
assert.equal(adapter.urlPrefix(), config.api.namespace);
|
||||
});
|
||||
|
||||
test('it rewrites PUT to PATCH, but leaves others unchanged', function (assert) {
|
||||
assert.expect(1);
|
||||
const adapter = this.owner.lookup('adapter:application');
|
||||
// TODO this is icky, should be changed to a spy or stub
|
||||
RESTAdapter.prototype.ajax = (url, type) => {
|
||||
assert.equal(type, 'PATCH');
|
||||
};
|
||||
adapter.ajax('/', 'PUT');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
import config from 'ember-get-config';
|
||||
|
||||
module('Unit | Adapter | project', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
test('it generates URL prefixes without project scope', function (assert) {
|
||||
assert.expect(1);
|
||||
const service = this.owner.lookup('service:scope');
|
||||
service.org = { id: 1 };
|
||||
service.project = { id: 2 };
|
||||
const adapter = this.owner.lookup('adapter:project');
|
||||
assert.equal(adapter.urlPrefix(), `${config.api.namespace}/orgs/1`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
|
||||
module('Unit | Model | base', 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('base', {});
|
||||
assert.ok(model);
|
||||
});
|
||||
|
||||
test('it has canSave and cannotSave attributes', function (assert) {
|
||||
assert.expect(8);
|
||||
const store = this.owner.lookup('service:store');
|
||||
store.push({
|
||||
data: {
|
||||
id: '1',
|
||||
type: 'project',
|
||||
attributes: {},
|
||||
},
|
||||
});
|
||||
const model = store.peekRecord('project', '1');
|
||||
assert.equal(model.canSave, false);
|
||||
assert.equal(model.cannotSave, true);
|
||||
// Should be able to save if dirty
|
||||
model.name = 'Project';
|
||||
assert.equal(model.hasDirtyAttributes, true);
|
||||
assert.equal(model.canSave, true);
|
||||
assert.equal(model.cannotSave, false);
|
||||
// Should not be able to save while currently saving
|
||||
model.transitionTo('updated.inFlight');
|
||||
assert.equal(model.isSaving, true);
|
||||
assert.equal(model.canSave, false);
|
||||
assert.equal(model.cannotSave, true);
|
||||
model.transitionTo('loaded');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
|
||||
module('Unit | Model | project', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
test('it exists', function (assert) {
|
||||
let store = this.owner.lookup('service:store');
|
||||
let model = store.createRecord('project', {});
|
||||
assert.ok(model);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
|
||||
module('Unit | Serializer | application', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
test('it exists', function (assert) {
|
||||
assert.expect(1);
|
||||
const store = this.owner.lookup('service:store');
|
||||
const serializer = store.serializerFor('application');
|
||||
assert.ok(serializer);
|
||||
});
|
||||
|
||||
test('it serializes records', function (assert) {
|
||||
assert.expect(1);
|
||||
const store = this.owner.lookup('service:store');
|
||||
const record = store.createRecord('project', {
|
||||
name: 'Project',
|
||||
description: 'Description',
|
||||
});
|
||||
const serializedRecord = record.serialize();
|
||||
assert.deepEqual(serializedRecord, {
|
||||
name: 'Project',
|
||||
description: 'Description',
|
||||
//disabled: false, // TODO: disabled is temporarily disabled
|
||||
});
|
||||
});
|
||||
|
||||
test('it normalizes array records from an `items` root key', function (assert) {
|
||||
assert.expect(1);
|
||||
const store = this.owner.lookup('service:store');
|
||||
const serializer = store.serializerFor('project');
|
||||
const projectModelClass = store.createRecord('project').constructor;
|
||||
const payload = {
|
||||
items: [
|
||||
{ id: 1, name: 'Project 1' },
|
||||
{ id: 2, name: 'Project 2' },
|
||||
{ id: 3, name: 'Project 3' },
|
||||
],
|
||||
};
|
||||
const normalizedArray = serializer.normalizeArrayResponse(
|
||||
store,
|
||||
projectModelClass,
|
||||
payload
|
||||
);
|
||||
assert.deepEqual(normalizedArray, {
|
||||
included: [],
|
||||
data: [
|
||||
{
|
||||
id: '1',
|
||||
type: 'project',
|
||||
attributes: { name: 'Project 1' },
|
||||
relationships: {},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'project',
|
||||
attributes: { name: 'Project 2' },
|
||||
relationships: {},
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'project',
|
||||
attributes: { name: 'Project 3' },
|
||||
relationships: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('it normalizes single, unrooted records', function (assert) {
|
||||
assert.expect(1);
|
||||
const store = this.owner.lookup('service:store');
|
||||
const serializer = store.serializerFor('project');
|
||||
const projectModelClass = store.createRecord('project').constructor;
|
||||
const payload = {
|
||||
id: '1',
|
||||
name: 'Project 1',
|
||||
};
|
||||
const normalized = serializer.normalizeSingleResponse(
|
||||
store,
|
||||
projectModelClass,
|
||||
payload
|
||||
);
|
||||
assert.deepEqual(normalized, {
|
||||
included: [],
|
||||
data: {
|
||||
id: '1',
|
||||
type: 'project',
|
||||
attributes: { name: 'Project 1' },
|
||||
relationships: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,4 +18,5 @@ import layout from '../../../templates/components/rose/form/input';
|
||||
*/
|
||||
export default Component.extend({
|
||||
layout,
|
||||
tagName: ''
|
||||
});
|
||||
@@ -18,4 +18,5 @@ import layout from '../../../templates/components/rose/form/textarea';
|
||||
*/
|
||||
export default Component.extend({
|
||||
layout,
|
||||
tagName: ''
|
||||
});
|
||||
@@ -72,6 +72,7 @@ These environment variables may be used to customized the build.
|
||||
| Variable | Default Value | Description |
|
||||
| -------- | ------------- | ----------- |
|
||||
| `APP_NAME` | Application Name | The user-facing name of the application, appearing in titles, etc. |
|
||||
| `API_HOST` | | The host of the API, if different than UI (e.g. https://example.net:1234). |
|
||||
|
||||
### Running Tests
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<form
|
||||
...attributes
|
||||
novalidate
|
||||
method="post"
|
||||
{{on "submit" @submit}}>
|
||||
|
||||
<Rose::Form::Input
|
||||
@id="name"
|
||||
name="name"
|
||||
@type="name"
|
||||
@value={{@model.name}}
|
||||
@label={{t "form.label.name"}}
|
||||
@error={{false}}
|
||||
disabled={{@model.isSaving}}
|
||||
readonly={{false}}
|
||||
/>
|
||||
|
||||
<Rose::Form::Textarea
|
||||
@id="description"
|
||||
name="description"
|
||||
@type="description"
|
||||
@value={{@model.description}}
|
||||
@label={{t "form.label.description"}}
|
||||
@error={{false}}
|
||||
disabled={{@model.isSaving}}
|
||||
readonly={{false}}
|
||||
/>
|
||||
|
||||
<section class="form-actions">
|
||||
<Rose::Button
|
||||
@disabled={{@model.cannotSave}}
|
||||
@submit={{true}}
|
||||
@style="primary"
|
||||
>
|
||||
{{t 'actions.save'}}
|
||||
</Rose::Button>
|
||||
|
||||
<Rose::Button
|
||||
@disabled={{@model.cannotSave}}
|
||||
@style="secondary"
|
||||
{{on "click" @cancel}}
|
||||
>
|
||||
{{t 'actions.cancel'}}
|
||||
</Rose::Button>
|
||||
</section>
|
||||
</form>
|
||||
+11
-1
@@ -6,4 +6,14 @@ export default class Router extends EmberRouter {
|
||||
rootURL = config.rootURL;
|
||||
}
|
||||
|
||||
Router.map(function () {});
|
||||
Router.map(function () {
|
||||
this.route('orgs', function () {
|
||||
this.route('org', { path: ':org_id' }, function () {
|
||||
this.route('projects', function () {
|
||||
this.route('project', { path: ':project_id' });
|
||||
this.route('new');
|
||||
});
|
||||
});
|
||||
});
|
||||
this.route('projects');
|
||||
});
|
||||
Whitespace-only changes.
@@ -0,0 +1,6 @@
|
||||
import Route from '@ember/routing/route';
|
||||
|
||||
/**
|
||||
* Entry route for the application.
|
||||
*/
|
||||
export default class ApplicationRoute extends Route {}
|
||||
@@ -0,0 +1,3 @@
|
||||
import Route from '@ember/routing/route';
|
||||
|
||||
export default class OrgsRoute extends Route {}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
export default class OrgsOrgRoute extends Route {
|
||||
// =services
|
||||
|
||||
@service scope;
|
||||
|
||||
// =methods
|
||||
|
||||
/**
|
||||
* Returns a mock organization object containing the specified ID.
|
||||
* TODO: load a real org instance from the API.
|
||||
* @param {object} params
|
||||
* @return {object}
|
||||
*/
|
||||
model({ org_id: id }) {
|
||||
return { id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the org to the scope.
|
||||
* @param {object} org
|
||||
*/
|
||||
afterModel(org) {
|
||||
this.scope.org = org;
|
||||
return super.afterModel(...arguments);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import Route from '@ember/routing/route';
|
||||
|
||||
export default class OrgsOrgProjectsRoute extends Route {
|
||||
// =methods
|
||||
|
||||
/**
|
||||
* Returns all projects from the store.
|
||||
* @return {Promise[ProjectModel]}
|
||||
*/
|
||||
async model() {
|
||||
try {
|
||||
return await this.store.findAll('project');
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import Route from '@ember/routing/route';
|
||||
|
||||
export default class OrgsOrgProjectsIndexRoute extends Route {}
|
||||
@@ -0,0 +1,45 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
export default class OrgsOrgProjectsNewRoute extends Route {
|
||||
// =methods
|
||||
|
||||
/**
|
||||
* Returns a new unsaved project.
|
||||
* @return {Promise{ProjectModel}}
|
||||
*/
|
||||
model() {
|
||||
return this.store.createRecord('project');
|
||||
}
|
||||
|
||||
// =actions
|
||||
|
||||
/**
|
||||
* Rollback changes on project (which destroys unsaved instances)
|
||||
* and redirects to projects index.
|
||||
* @param {ProjectModel} project
|
||||
*/
|
||||
@action
|
||||
cancel(project) {
|
||||
project.rollbackAttributes();
|
||||
this.transitionTo('orgs.org.projects');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle save project.
|
||||
* @param {ProjectModel} project
|
||||
* @param {Event} e
|
||||
*/
|
||||
@action
|
||||
async save(project, e) {
|
||||
// Prevent default behavior, since this was trigger from a form submission.
|
||||
e.preventDefault();
|
||||
try {
|
||||
await project.save();
|
||||
this.transitionTo('orgs.org.projects.project', project);
|
||||
} catch (e) {
|
||||
// TODO: error handling
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
export default class OrgsOrgProjectsProjectRoute extends Route {
|
||||
// =services
|
||||
|
||||
@service scope;
|
||||
|
||||
// =methods
|
||||
|
||||
/**
|
||||
* Returns a project by id.
|
||||
* @param {object} params
|
||||
* @return {Promise{ProjectModel}}
|
||||
*/
|
||||
model({ project_id: id }) {
|
||||
return this.store.findRecord('project', id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the project to the scope.
|
||||
* @param {ProjectModel} project
|
||||
*/
|
||||
afterModel(project) {
|
||||
this.scope.project = project;
|
||||
return super.afterModel(...arguments);
|
||||
}
|
||||
|
||||
// =actions
|
||||
|
||||
/**
|
||||
* Rollback changes on project.
|
||||
* @param {ProjectModel} project
|
||||
*/
|
||||
@action
|
||||
cancel(project) {
|
||||
project.rollbackAttributes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle save project.
|
||||
* @param {ProjectModel} project
|
||||
* @param {Event} e
|
||||
*/
|
||||
@action
|
||||
async save(project, e) {
|
||||
// Prevent default behavior, since this was trigger from a form submission.
|
||||
e.preventDefault();
|
||||
try {
|
||||
await project.save();
|
||||
} catch (e) {
|
||||
// TODO: error handling
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the project and redirects to index.
|
||||
* @param {ProjectModel} project
|
||||
*/
|
||||
@action
|
||||
async delete(project) {
|
||||
try {
|
||||
await project.destroyRecord();
|
||||
this.transitionTo('orgs.org.projects');
|
||||
} catch (e) {
|
||||
// TODO: error handling
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1,74 @@
|
||||
@import 'rose';
|
||||
|
||||
.projects-overview {
|
||||
margin: 0 10px;
|
||||
|
||||
.projects {
|
||||
display: flex;
|
||||
padding: 20px 0;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.project {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-bottom: 10px;
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
text-transform: capitalize;
|
||||
color: #000;
|
||||
cursor: pointer;
|
||||
padding: 20px 30px 20px 20px;
|
||||
border: 1px solid #bac1cc;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.project-new-link {
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
padding: 10px;
|
||||
color: #ffffff;
|
||||
text-shadow: 0px 1px 1px rgba(0, 0, 0, 0.25);
|
||||
background: #1563ff;
|
||||
border: 1px solid #082766;
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
|
||||
border-radius: 2px;
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 70px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.project-overview,
|
||||
.project-new {
|
||||
margin: 0 10px;
|
||||
.breadcrumbs,
|
||||
.breadcrumbs a {
|
||||
color: #6f7682;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.project-new-form {
|
||||
.form-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
position: relative;
|
||||
float: right;
|
||||
|
||||
button {
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
<HeadLayout />
|
||||
|
||||
<Rose::Header as |header|>
|
||||
<header.brand />
|
||||
</Rose::Header>
|
||||
|
||||
{{title (app-name)}}
|
||||
|
||||
{{outlet}}
|
||||
@@ -0,0 +1 @@
|
||||
{{outlet}}
|
||||
@@ -0,0 +1 @@
|
||||
{{outlet}}
|
||||
@@ -0,0 +1 @@
|
||||
{{outlet}}
|
||||
@@ -0,0 +1,15 @@
|
||||
<div class="projects-overview">
|
||||
<h2>{{t "resources.projects"}}</h2>
|
||||
|
||||
<LinkTo @route="orgs.org.projects.new" class="project-new-link">{{t "titles.new"}}</LinkTo>
|
||||
|
||||
<ul class="projects">
|
||||
{{#each @model as |project|}}
|
||||
<li class="project">
|
||||
<LinkTo @route="orgs.org.projects.project" @model={{project}}>
|
||||
{{project.name}}
|
||||
</LinkTo>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
<div class="project-new">
|
||||
<div class="breadcrumbs">
|
||||
<LinkTo @route='orgs.org.projects'>{{t "resources.projects"}}</LinkTo>
|
||||
>
|
||||
<LinkTo @route='orgs.org.projects.new'>{{t "titles.new"}}</LinkTo>
|
||||
</div>
|
||||
|
||||
<h2>{{t "titles.new"}}</h2>
|
||||
|
||||
<Form::Project
|
||||
@model={{@model}}
|
||||
@submit={{route-action "save" @model}}
|
||||
@cancel={{route-action "cancel" @model}}
|
||||
class="project-new-form"
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,24 @@
|
||||
<div class="project-overview">
|
||||
<div class="breadcrumbs">
|
||||
<LinkTo @route='orgs.org.projects'>{{t "resources.projects"}}</LinkTo>
|
||||
>
|
||||
<LinkTo @route='orgs.org.projects.project'>{{@model.name}}</LinkTo>
|
||||
</div>
|
||||
|
||||
<h2>{{@model.name}}</h2>
|
||||
|
||||
<Form::Project
|
||||
@model={{@model}}
|
||||
@submit={{route-action "save" @model}}
|
||||
@cancel={{route-action "cancel" @model}}
|
||||
class="project-new-form"
|
||||
/>
|
||||
|
||||
<Rose::Button
|
||||
@style="warning"
|
||||
@disabled={{@model.canSave}}
|
||||
{{on "click" (route-action "delete" @model)}}
|
||||
>
|
||||
{{t 'actions.delete'}}
|
||||
</Rose::Button>
|
||||
</div>
|
||||
@@ -1,6 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
const APP_NAME = process.env.APP_NAME || 'Application Name';
|
||||
const API_HOST = process.env.API_HOST || '';
|
||||
|
||||
module.exports = function (environment) {
|
||||
let ENV = {
|
||||
@@ -25,6 +26,11 @@ module.exports = function (environment) {
|
||||
// when it is created
|
||||
},
|
||||
|
||||
api: {
|
||||
host: API_HOST,
|
||||
namespace: 'v1',
|
||||
},
|
||||
|
||||
appName: APP_NAME,
|
||||
|
||||
contentSecurityPolicyHeader: 'Content-Security-Policy',
|
||||
@@ -38,8 +44,8 @@ module.exports = function (environment) {
|
||||
'img-src': ["'self'"],
|
||||
'style-src': ["'self'"],
|
||||
'media-src': ["'self'"],
|
||||
'manifest-src': ["'self'"]
|
||||
}
|
||||
'manifest-src': ["'self'"],
|
||||
},
|
||||
};
|
||||
|
||||
// Unsafe policy is necessary in development and test environments, but should
|
||||
@@ -60,13 +66,18 @@ module.exports = function (environment) {
|
||||
componentOptions: {
|
||||
axeOptions: {
|
||||
checks: {
|
||||
'color-contrast': { options: { noScroll: true } }
|
||||
}
|
||||
}
|
||||
}
|
||||
'color-contrast': { options: { noScroll: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
enableUnsafeCSP();
|
||||
// TODO: should provide an env var to explicitly add a host to CSP
|
||||
// at build time for any environment (not just development),
|
||||
// rather than automatically include API_HOST. Changes to CSP should
|
||||
// be explicit.
|
||||
if (API_HOST) ENV.contentSecurityPolicy['connect-src'].push(API_HOST);
|
||||
}
|
||||
|
||||
if (environment === 'test') {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import config from '../config/environment';
|
||||
|
||||
export default function() {
|
||||
|
||||
// make this `http://localhost:8080`, for example, if your API is on a different server
|
||||
// this.urlPrefix = '';
|
||||
// make this `/api`, for example, if your API is namespaced
|
||||
this.namespace = config.api.namespace;
|
||||
// delay for each request, automatically set to 0 during testing
|
||||
this.timing = 350;
|
||||
|
||||
/*
|
||||
Shorthand cheatsheet:
|
||||
|
||||
this.get('/posts');
|
||||
this.post('/posts');
|
||||
this.get('/posts/:id');
|
||||
this.put('/posts/:id'); // or this.patch
|
||||
this.del('/posts/:id');
|
||||
|
||||
https://www.ember-cli-mirage.com/docs/route-handlers/shorthands
|
||||
*/
|
||||
|
||||
if (config.api.host) this.passthrough(`${config.api.host}/**`);
|
||||
this.passthrough();
|
||||
|
||||
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');
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import factory from '../generated/factories/project';
|
||||
|
||||
export default factory.extend();
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Factory } from 'ember-cli-mirage';
|
||||
import { random, date } from 'faker';
|
||||
|
||||
/**
|
||||
* GeneratedProjectModelFactory
|
||||
* Project contains all fields related to a Project resource
|
||||
*/
|
||||
export default Factory.extend({
|
||||
|
||||
name: () => random.words(),
|
||||
description: () => random.words(),
|
||||
created_time: () => date.recent(),
|
||||
updated_time: () => date.recent(),
|
||||
disabled: () => random.boolean(),
|
||||
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
export default function(server) {
|
||||
|
||||
server.createList('project', 3);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { RestSerializer } from 'ember-cli-mirage';
|
||||
import { underscore } from '@ember/string';
|
||||
|
||||
/**
|
||||
* Manages serialization/normalization of data into and out of the mock API.
|
||||
*/
|
||||
export default RestSerializer.extend({
|
||||
|
||||
// =attributes
|
||||
|
||||
/**
|
||||
* False to disable automatically rooting payloads under a key.
|
||||
* This is handled by the `serialize()` method, see below.
|
||||
* @type {boolean}
|
||||
*/
|
||||
root: false,
|
||||
|
||||
/**
|
||||
* @type {boolean}
|
||||
*/
|
||||
embed: true,
|
||||
|
||||
// =methods
|
||||
|
||||
/**
|
||||
* Generates an underscored key for the attribute.
|
||||
* @param {string} attr
|
||||
* @return {string}
|
||||
*/
|
||||
keyForAttribute(attr) {
|
||||
return underscore(attr);
|
||||
},
|
||||
|
||||
/**
|
||||
* Serialize items into a root key `items`.
|
||||
* @return {object}
|
||||
*/
|
||||
serialize() {
|
||||
let json = RestSerializer.prototype.serialize.apply(this, arguments);
|
||||
// If array, root it under a standard `items` key
|
||||
if (json.length) json = {items: json};
|
||||
return json;
|
||||
},
|
||||
|
||||
/**
|
||||
* Adds a root to the payload, since `root: false` doesn't appear to affect
|
||||
* the normalize method.
|
||||
* @param {object} json
|
||||
* @return {object}
|
||||
*/
|
||||
normalize(json) {
|
||||
let newJSON = json;
|
||||
if (this.modelName) {
|
||||
newJSON = {};
|
||||
newJSON[this.modelName] = json;
|
||||
}
|
||||
const value = RestSerializer.prototype.normalize.apply(this, [newJSON]);
|
||||
return value;
|
||||
}
|
||||
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import ApplicationSerializer from './application';
|
||||
|
||||
export default ApplicationSerializer.extend({
|
||||
modelName: 'project'
|
||||
});
|
||||
@@ -47,6 +47,7 @@
|
||||
"ember-cli-eslint": "^5.1.0",
|
||||
"ember-cli-htmlbars": "^4.2.2",
|
||||
"ember-cli-inject-live-reload": "^2.0.2",
|
||||
"ember-cli-mirage": "^1.1.6",
|
||||
"ember-cli-sass": "^10.0.1",
|
||||
"ember-cli-sri": "^2.1.1",
|
||||
"ember-cli-template-lint": "^1.0.0-beta.3",
|
||||
@@ -62,11 +63,13 @@
|
||||
"ember-page-title": "^5.2.1",
|
||||
"ember-qunit": "^4.6.0",
|
||||
"ember-resolver": "^7.0.0",
|
||||
"ember-route-action-helper": "^2.0.8",
|
||||
"ember-source": "~3.16.0",
|
||||
"ember-welcome-page": "^4.0.0",
|
||||
"emberx-select": "github:adopted-ember-addons/emberx-select#bc5f774",
|
||||
"eslint-plugin-ember": "^7.7.2",
|
||||
"eslint-plugin-node": "^11.0.0",
|
||||
"faker": "^4.1.0",
|
||||
"loader.js": "^4.7.0",
|
||||
"prettier": "^2.0.5",
|
||||
"qunit-dom": "^1.0.0",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render, find } from '@ember/test-helpers';
|
||||
import { hbs } from 'ember-cli-htmlbars';
|
||||
|
||||
module('Integration | Component | form/project', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test('it renders', async function (assert) {
|
||||
// Set any properties with this.set('myProperty', 'value');
|
||||
// Handle any actions with this.set('myAction', function(val) { ... });
|
||||
this.submit = () => {};
|
||||
this.cancel = () => {};
|
||||
|
||||
await render(hbs`
|
||||
<Form::Project
|
||||
@submit={{this.submit}}
|
||||
@cancel={{this.cancel}}
|
||||
/>`);
|
||||
|
||||
assert.ok(find('form'));
|
||||
});
|
||||
});
|
||||
@@ -3,10 +3,10 @@ import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render } from '@ember/test-helpers';
|
||||
import { hbs } from 'ember-cli-htmlbars';
|
||||
|
||||
module('Integration | Helper | app-name', function(hooks) {
|
||||
module('Integration | Helper | app-name', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test('it renders', async function(assert) {
|
||||
test('it renders', async function (assert) {
|
||||
await render(hbs`{{app-name}}`);
|
||||
assert.equal(this.element.textContent.trim(), 'Application Name');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
|
||||
module('Unit | Route | application', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
test('it exists', function (assert) {
|
||||
let route = this.owner.lookup('route:application');
|
||||
assert.ok(route);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
|
||||
module('Unit | Route | orgs', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
test('it exists', function (assert) {
|
||||
let route = this.owner.lookup('route:orgs');
|
||||
assert.ok(route);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
|
||||
module('Unit | Route | orgs/org', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
test('it exists', function (assert) {
|
||||
let route = this.owner.lookup('route:orgs/org');
|
||||
assert.ok(route);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
|
||||
module('Unit | Route | orgs/org/projects', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
test('it exists', function (assert) {
|
||||
let route = this.owner.lookup('route:orgs/org/projects');
|
||||
assert.ok(route);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
|
||||
module('Unit | Route | orgs/org/projects/index', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
test('it exists', function (assert) {
|
||||
let route = this.owner.lookup('route:orgs/org/projects/index');
|
||||
assert.ok(route);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
|
||||
module('Unit | Route | orgs/org/projects/new', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
test('it exists', function (assert) {
|
||||
let route = this.owner.lookup('route:orgs/org/projects/new');
|
||||
assert.ok(route);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
|
||||
module('Unit | Route | orgs/org/projects/project', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
test('it exists', function (assert) {
|
||||
let route = this.owner.lookup('route:orgs/org/projects/project');
|
||||
assert.ok(route);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,12 @@
|
||||
app:
|
||||
name: 'Application Name'
|
||||
example:
|
||||
text: 'Example'
|
||||
actions:
|
||||
cancel: Cancel
|
||||
delete: Delete
|
||||
save: Save
|
||||
resources:
|
||||
projects: Projects
|
||||
titles:
|
||||
new: New
|
||||
form:
|
||||
label:
|
||||
name: Name
|
||||
description: Description
|
||||
Reference in new issue
Block a user