Skip to content

Commit

Permalink
Merge pull request #435 from aaxelb/emb-302--email-modals
Browse files Browse the repository at this point in the history
[EMB-302][EMB-227] Modal to verify "add email"/"merge user" requests
  • Loading branch information
jamescdavis committed Nov 12, 2018
2 parents e225452 + 6ef18cd commit ce58d02
Show file tree
Hide file tree
Showing 35 changed files with 630 additions and 103 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Expand Up @@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).

## [Unreleased]
### Added
- Models:
- `user-email`
### Changed
- Components:
- `node-navbar` - Choose links to display with the same logic as legacy
Expand All @@ -16,6 +19,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
- renamed `currentUserIsAdmin` computed property to `userHasAdminPermission`
- Tests:
- improved integration tests for `node-navbar` component
- Adapters:
- Added `parentRelationship` property to `osf-adapter`. Allows creating records at nested endpoints.
- Routes:
- Add email verification modal to application template

## [18.1.2] - 2018-11-05
- Engines:
Expand Down
114 changes: 78 additions & 36 deletions app/adapters/osf-adapter.ts
@@ -1,4 +1,5 @@
import { service } from '@ember-decorators/service';
import { assert } from '@ember/debug';
import { underscore } from '@ember/string';
import DS from 'ember-data';
import config from 'ember-get-config';
Expand Down Expand Up @@ -39,9 +40,29 @@ enum RequestType {
* @extends DS.JSONAPIAdapter
* @uses GenericDataAdapterMixin
*/
export default class OsfAdapter extends JSONAPIAdapter.extend({
host,
namespace,
export default class OsfAdapter extends JSONAPIAdapter {
@service session!: Session;
@service currentUser!: CurrentUser;

host = host;
namespace = namespace;

/**
* When an object lives "under" another in the API, set `parentRelationship` to the name of
* the belongsTo that points to the parent object. Requests for creating new children will
* go to the nested route for that relationship.
*
* e.g. If the contributor adapter has `parentRelationship = 'node'`, creating a new contributor
* for node xyz will POST to /v2/nodes/xyz/contributors/
*
* TODO: `OsfAdapter<M extends OsfModel>`, `parentRelationship: RelationshipsFor<M> | null`
*/
parentRelationship: string | null = null;

get headers() {
// Not a computed; evaluate every time in case something changes
return this.currentUser.ajaxHeaders();
}

/**
* Overrides buildQuery method - Allows users to embed resources with findRecord
Expand All @@ -51,11 +72,11 @@ export default class OsfAdapter extends JSONAPIAdapter.extend({
*
* @method buildQuery
*/
buildQuery(this: OsfAdapter, snapshot: DS.Snapshot): object {
buildQuery(snapshot: DS.Snapshot): object {
const { query: adapterOptionsQuery = {} } = (snapshot.adapterOptions || {}) as AdapterOptions;

const query: { include?: any, embed?: any } = {
...this._super(snapshot),
...super.buildQuery(snapshot),
...adapterOptionsQuery,
};

Expand All @@ -64,32 +85,21 @@ export default class OsfAdapter extends JSONAPIAdapter.extend({
embed: query.include,
include: undefined,
};
},
}

buildURL(
this: OsfAdapter,
modelName: string,
modelName: string | undefined,
id: string | null,
snapshot: DS.Snapshot | null,
requestType: string,
query?: {},
): string {
let url: string = this._super(modelName, id, snapshot, requestType);
let url: string = super.buildURL(modelName, id, snapshot, requestType, query);

if (snapshot) {
const { record, adapterOptions } = snapshot;
const opts: AdapterOptions = adapterOptions || {};
if (requestType === 'deleteRecord') {
if (record && record.get('links.delete')) {
url = record.get('links.delete');
} else if (record && record.get('links.self')) {
url = record.get('links.self');
}
} else if (requestType === 'updateRecord' || requestType === 'findRecord') {
if (record && record.get('links.self')) {
url = record.get('links.self');
}
} else if (opts.url) {
url = opts.url; // eslint-disable-line prefer-destructuring
const { adapterOptions }: { adapterOptions?: { url?: string } } = snapshot;
if (adapterOptions && adapterOptions.url) {
url = adapterOptions.url; // eslint-disable-line prefer-destructuring
}
}

Expand All @@ -98,11 +108,51 @@ export default class OsfAdapter extends JSONAPIAdapter.extend({
if (url.lastIndexOf('/') !== url.length - 1) {
url += '/';
}

return url;
},
}

urlForFindRecord(id: string, modelName: string, snapshot: DS.Snapshot): string {
if (snapshot && snapshot.record && snapshot.record.links && snapshot.record.links.self) {
return snapshot.record.links.self;
}
return super.urlForFindRecord(id, modelName, snapshot);
}

urlForCreateRecord(modelName: string, snapshot: DS.Snapshot): string {
const { parentRelationship } = this;
if (!parentRelationship) {
return super.urlForCreateRecord(modelName, snapshot);
}

ajaxOptions(this: OsfAdapter, url: string, type: RequestType, options?: { isBulk?: boolean }): object {
const hash = this._super(url, type, options);
const parentObj = snapshot.record.belongsTo(parentRelationship).value();
const inverseRelation: string = snapshot.record.inverseFor(parentRelationship).name;

assert('To create a nested object, the parent must already be loaded.', parentObj);

const url = parentObj.get(`links.relationships.${inverseRelation}.links.related.href`);

assert(`Couldn't find create link for nested ${modelName}`, url);

return url;
}

urlForUpdateRecord(id: string, modelName: string, snapshot: DS.Snapshot): string {
const { links } = snapshot.record;
if (links && links.self) {
return links.self;
}
return super.urlForUpdateRecord(id, modelName, snapshot);
}

urlForDeleteRecord(id: string, modelName: string, snapshot: DS.Snapshot): string {
const { links } = snapshot.record;
const url = links.delete || links.self;
return url || super.urlForDeleteRecord(id, modelName, snapshot);
}

ajaxOptions(url: string, type: RequestType, options?: { isBulk?: boolean }): object {
const hash: any = super.ajaxOptions(url, type, options);

hash.xhrFields = {
withCredentials: true,
Expand All @@ -113,7 +163,7 @@ export default class OsfAdapter extends JSONAPIAdapter.extend({
}

return hash;
},
}

buildRelationshipURL(snapshot: DS.Snapshot, relationship: string): string {
const links = !!relationship && snapshot.record.get(`relationshipLinks.${underscore(relationship)}.links`);
Expand All @@ -123,19 +173,11 @@ export default class OsfAdapter extends JSONAPIAdapter.extend({
}

return '';
},
}

pathForType(modelName: string): string {
const underscored: string = underscore(modelName);
return pluralize(underscored);
},
}) {
@service session!: Session;
@service currentUser!: CurrentUser;

get headers() {
// Not a computed; evaluate every time in case something changes
return this.currentUser.ajaxHeaders();
}

handleResponse(
Expand Down
11 changes: 11 additions & 0 deletions app/adapters/user-email.ts
@@ -0,0 +1,11 @@
import OsfAdapter from './osf-adapter';

export default class UserEmailAdapter extends OsfAdapter {
parentRelationship = 'user';
}

declare module 'ember-data' {
interface AdapterRegistry {
'user-email': UserEmailAdapter;
}
}
159 changes: 159 additions & 0 deletions app/application/-components/verify-email-modal/component.ts
@@ -0,0 +1,159 @@
import { action, computed } from '@ember-decorators/object';
import { alias, or } from '@ember-decorators/object/computed';
import { service } from '@ember-decorators/service';
import Component from '@ember/component';
import { task, timeout } from 'ember-concurrency';
import DS from 'ember-data';
import I18n from 'ember-i18n/services/i18n';
import Toast from 'ember-toastr/services/toast';

import UserEmail from 'ember-osf-web/models/user-email';
import CurrentUser from 'ember-osf-web/services/current-user';

interface TranslationKeys {
header: string;
body: string;
verifyButton: string;
denyButton: string;
verifySuccess: string;
denySuccess: string;
verifyError: string;
denyError: string;
}

enum EmailActions {
Verify = 'verify',
Deny = 'deny',
}

type MessageLevel = 'error' | 'info' | 'success' | 'warning';

export default class VerifyEmailModal extends Component.extend({
loadEmailsTask: task(function *(this: VerifyEmailModal) {
const { user } = this.currentUser;
if (user) {
const emails: UserEmail[] = yield user.queryHasMany('emails', {
filter: {
confirmed: true,
verified: false,
},
});
this.set('unverifiedEmails', emails);
}
}),

verifyTask: task(function *(this: VerifyEmailModal, emailAction: EmailActions) {
const { userEmail } = this;
if (!userEmail) {
return;
}

let successKey: keyof TranslationKeys;
let successMessageLevel: MessageLevel;
let errorKey: keyof TranslationKeys;

switch (emailAction) {
case EmailActions.Verify:
userEmail.set('verified', true);
successKey = 'verifySuccess';
successMessageLevel = 'success';
errorKey = 'verifyError';
break;
case EmailActions.Deny:
userEmail.deleteRecord();
successKey = 'denySuccess';
successMessageLevel = 'warning';
errorKey = 'denyError';
break;
default:
throw Error(`Action must be 'verify' or 'deny', got: ${emailAction}`);
}

try {
yield userEmail.save();

if (this.unverifiedEmails) {
this.unverifiedEmails.shiftObject();
}

this.showMessage(successMessageLevel, successKey, userEmail);

// Close the modal and open another one (if needed) because it's confusing for the text to change in place
this.set('shouldShowModal', false);
yield timeout(300);
this.set('shouldShowModal', true);
} catch (e) {
this.showMessage('error', errorKey, userEmail);
throw e;
}
}).drop(),
}) {
@service currentUser!: CurrentUser;
@service i18n!: I18n;
@service store!: DS.Store;
@service toast!: Toast;

shouldShowModal: boolean = true;
unverifiedEmails?: UserEmail[];

@alias('unverifiedEmails.firstObject')
userEmail?: UserEmail;

@or('verifyTask.isRunning', 'denyTask.isRunning')
disableButtons!: boolean;

@computed('userEmail.isMerge')
get translationKeys(): TranslationKeys {
if (!this.userEmail || !this.userEmail.isMerge) {
return {
header: 'verifyEmail.add.header',
body: 'verifyEmail.add.body',
verifyButton: 'verifyEmail.add.verifyButton',
denyButton: 'verifyEmail.add.denyButton',
verifySuccess: 'verifyEmail.add.verifySuccess',
denySuccess: 'verifyEmail.add.denySuccess',
verifyError: 'verifyEmail.add.verifyError',
denyError: 'verifyEmail.add.denyError',
};
} else {
return {
header: 'verifyEmail.merge.header',
body: 'verifyEmail.merge.body',
verifyButton: 'verifyEmail.merge.verifyButton',
denyButton: 'verifyEmail.merge.denyButton',
verifySuccess: 'verifyEmail.merge.verifySuccess',
denySuccess: 'verifyEmail.merge.denySuccess',
verifyError: 'verifyEmail.merge.verifyError',
denyError: 'verifyEmail.merge.denyError',
};
}
}

constructor(...args: any[]) {
super(...args);
this.loadEmailsTask.perform();
}

showMessage(
level: MessageLevel,
key: keyof TranslationKeys,
userEmail: UserEmail,
) {
this.toast[level](
this.i18n.t(
this.translationKeys[key],
{ email: userEmail.emailAddress },
),
);
}

@action
verify() {
this.verifyTask.perform(EmailActions.Verify);
}

@action
deny() {
this.verifyTask.perform(EmailActions.Deny);
}
}

0 comments on commit ce58d02

Please sign in to comment.