Migrated staff user screen to Ember Octane patterns (#15532)

refs https://github.com/TryGhost/Ghost/issues/14101

- migrated staff user controller to native class syntax
- removed use of `{{action}}` helper
- moved from custom components to native `<input>` and `<textarea>` for form fields
  - added `{{select-on-click}}` modifier to cover the `<GhTextingInput @selectOnClick>` option behaviour for any input element
- added `submitForm()` test helper that finds closest `form` element and trigger's a `submit` event on it simulating <kbd>Enter</kbd> being pressed whilst a field has focus
This commit is contained in:
Kevin Ansfield 2022-10-05 12:05:31 +01:00 committed by GitHub
parent b96ff6ae4a
commit 524b23c182
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 368 additions and 272 deletions

View File

@ -901,3 +901,33 @@ remove|ember-template-lint|no-action|280|43|280|43|d746c5532ce38d23d8537195a3afa
remove|ember-template-lint|no-action|398|106|398|106|d2028f094665a3702bbea21159b1c12237c94bc9|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-passed-in-event-handlers|262|32|262|32|c14e20b2b090706f9af9fa416c4300c06e206894|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-passed-in-event-handlers|279|32|279|32|e286f50c92a17a31581207e4c65054f146cbd533|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|155|39|155|39|3da2270da5495c84c2b069564f2163b109d0e73e|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|156|43|156|43|4cd04fee6bbe85afe19501cd2f393c567e2f465e|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|175|39|175|39|66cebfc8448eced0bccf3faa57b4af0cd633e65e|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|176|43|176|43|3a3cdadac5b48ea65a6b10f0bb9319bed6d9ed79|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|196|43|196|43|eaee037b0dc6f3a8903f73c20098dfd77cba770c|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|197|47|197|47|b435e7cd7f48e3edb3214adc9c171181805500e3|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|230|39|230|39|5340ffcfe14bc0696c786cb698dc7436ff3e7b2a|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|231|43|231|43|cbc552ad67fd7382c02ac946a3de0fbe7dbb8509|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|246|39|246|39|6c405ec87c2cbc8470edc3bcba2ab93bf20e5c17|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|247|43|247|43|b26a1d1b4ba0bbe491099aeb78875160e2e417d6|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|292|39|292|39|61d6401bd1e43f082ab6fa49c83002d2e3faa886|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|293|43|293|43|eb4de7d8a9578a4b960da7d9b7679f3b1d67f886|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|409|47|409|47|4661ca0fecfa23048521845c78a5a7cfce28609f|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|411|50|411|50|6403bc3b28507827968bd1fcd428d36d63b53511|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|426|43|426|43|c262ddbe8aa83b429a32392155869d7a068a5c3e|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|428|46|428|46|6403bc3b28507827968bd1fcd428d36d63b53511|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|441|43|441|43|08c167af90156f5bce0a15fe0153ba0c222297b6|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|443|46|443|46|6403bc3b28507827968bd1fcd428d36d63b53511|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|476|62|476|62|38bfdf3e4ec8c79d195e80a48c2f343757845cab|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-passed-in-event-handlers|155|32|155|32|7f1d3292c273e623c95015be04a3fc5bac131480|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-passed-in-event-handlers|175|32|175|32|fed655605208b290a18b9f7da51a6cf7b40c0e9a|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-passed-in-event-handlers|196|36|196|36|881cf5dcdbe984cb7b4d5a2ddde4e111147b3817|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-passed-in-event-handlers|230|32|230|32|23912c3130690f1fb910732f42bc87922e3bc317|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-passed-in-event-handlers|246|32|246|32|50e244fdb1572202192f659fe4052f18bea92552|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-passed-in-event-handlers|292|32|292|32|b6eefc591c761dcae13b0de50e074f5aac8f1d80|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-passed-in-event-handlers|409|40|409|40|f9ad8f1af9275247b94cbab1de26e9f9d2218da5|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-passed-in-event-handlers|426|36|426|36|2a5f6ae5ef152f0c0d953cfbaf810fdf0d08e996|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-passed-in-event-handlers|441|36|441|36|c9d092bb6707073e3f11bdc393d1c1361e5f87c4|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-potential-path-strings|470|53|470|53|5c3a010b4844cd530c312ef2aa32de64b8a43db1|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-valueless-arguments|468|44|468|44|5d042f1f94738e82cc1b0c5c008cece383beaa30|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs

View File

@ -6,65 +6,81 @@ import SuspendUserModal from '../../../components/settings/staff/modals/suspend-
import TransferOwnershipModal from '../../../components/settings/staff/modals/transfer-ownership';
import UnsuspendUserModal from '../../../components/settings/staff/modals/unsuspend-user';
import UploadImageModal from '../../../components/settings/staff/modals/upload-image';
import boundOneWay from 'ghost-admin/utils/bound-one-way';
import copyTextToClipboard from 'ghost-admin/utils/copy-text-to-clipboard';
import isNumber from 'ghost-admin/utils/isNumber';
import windowProxy from 'ghost-admin/utils/window-proxy';
import {TrackedObject} from 'tracked-built-ins';
import {action, computed} from '@ember/object';
import {alias, and, not, or, readOnly} from '@ember/object/computed';
import {action} from '@ember/object';
import {run} from '@ember/runloop';
import {inject as service} from '@ember/service';
import {task, taskGroup, timeout} from 'ember-concurrency';
import {tracked} from '@glimmer/tracking';
export default Controller.extend({
ajax: service(),
config: service(),
ghostPaths: service(),
membersUtils: service(),
modals: service(),
notifications: service(),
session: service(),
slugGenerator: service(),
utils: service(),
export default class UserController extends Controller {
@service ajax;
@service config;
@service ghostPaths;
@service membersUtils;
@service modals;
@service notifications;
@service session;
@service slugGenerator;
@service utils;
personalToken: null,
personalTokenRegenerated: false,
dirtyAttributes: false,
@tracked dirtyAttributes = false;
@tracked personalToken = null;
@tracked personalTokenRegenerated = false;
@tracked scratchValues = new TrackedObject();
@tracked slugValue = null; // not set directly on model to avoid URL changing before save
init() {
this._super(...arguments);
this.clearScratchValues();
},
get user() {
return this.model;
}
scratchValues: tracked(),
get currentUser() {
return this.session.user;
}
saveHandlers: taskGroup().enqueue(),
get isOwnProfile() {
return this.currentUser.id === this.user.id;
}
user: alias('model'),
currentUser: alias('session.user'),
get isAdminUserOnOwnProfile() {
return this.currentUser.isAdminOnly && this.isOwnProfile;
}
email: readOnly('user.email'),
slugValue: boundOneWay('user.slug'),
get isAdminUserOnOwnerProfile() {
return this.currentUser.isAdminOnly && this.user.isOwnerOnly;
}
canChangeEmail: not('isAdminUserOnOwnerProfile'),
canChangePassword: not('isAdminUserOnOwnerProfile'),
canToggleMemberAlerts: or('currentUser.isOwnerOnly', 'isAdminUserOnOwnProfile'),
isAdminUserOnOwnProfile: and('currentUser.isAdminOnly', 'isOwnProfile'),
canMakeOwner: and('currentUser.isOwnerOnly', 'isNotOwnProfile', 'user.isAdminOnly', 'isNotSuspended'),
isAdminUserOnOwnerProfile: and('currentUser.isAdminOnly', 'user.isOwnerOnly'),
isNotOwnersProfile: not('user.isOwnerOnly'),
isNotSuspended: not('user.isSuspended'),
rolesDropdownIsVisible: and('currentUser.isAdmin', 'isNotOwnProfile', 'isNotOwnersProfile'),
userActionsAreVisible: or('deleteUserActionIsVisible', 'canMakeOwner'),
get canChangeEmail() {
return !this.isAdminUserOnOwnerProfile;
}
isNotOwnProfile: not('isOwnProfile'),
isOwnProfile: computed('user.id', 'currentUser.id', function () {
return this.get('user.id') === this.get('currentUser.id');
}),
get canChangePassword() {
return !this.isAdminUserOnOwnerProfile;
}
deleteUserActionIsVisible: computed('currentUser.{isAdmin,isEditor}', 'user.{isOwnerOnly,isAuthorOrContributor}', 'isOwnProfile', function () {
get canMakeOwner() {
return this.currentUser.isOwnerOnly
&& !this.isOwnProfile
&& this.user.isAdminOnly
&& !this.user.isSuspended;
}
get canToggleMemberAlerts() {
return this.currentUser.isOwnerOnly || this.isAdminUserOnOwnProfile;
}
get rolesDropdownIsVisible() {
return this.currentUser.isAdmin && !this.isOwnProfile && !this.user.isOwnerOnly;
}
get userActionsAreVisible() {
return this.deleteUserActionIsVisible || this.canMakeOwner;
}
get deleteUserActionIsVisible() {
// users can't delete themselves
if (this.isOwnProfile) {
return false;
@ -72,137 +88,129 @@ export default Controller.extend({
if (
// owners/admins can delete any non-owner user
(this.currentUser.get('isAdmin') && !this.user.isOwnerOnly) ||
(this.currentUser.isAdmin && !this.user.isOwnerOnly) ||
// editors can delete any author or contributor
(this.currentUser.get('isEditor') && this.user.isAuthorOrContributor)
(this.currentUser.isEditor && this.user.isAuthorOrContributor)
) {
return true;
}
return false;
}),
}
coverTitle: computed('user.name', function () {
return `${this.get('user.name')}'s Cover Image`;
}),
@action
setModelProperty(property, event) {
const value = event.target.value;
this.user[property] = value;
}
roles: computed(function () {
return this.store.query('role', {permissions: 'assign'});
}),
@action
validateModelProperty(property) {
this.user.validate({property});
}
actions: {
// TODO: remove those mutation actions once we have better
// inline validations that auto-clear errors on input
updatePassword(password) {
this.set('user.password', password);
this.get('user.hasValidated').removeObject('password');
this.get('user.errors').remove('password');
},
@action
clearModelErrors(property) {
this.user.hasValidated.removeObject(property);
this.user.errors.remove(property);
}
updateNewPassword(password) {
this.set('user.newPassword', password);
this.get('user.hasValidated').removeObject('newPassword');
this.get('user.errors').remove('newPassword');
},
@action
setSlugValue(event) {
this.slugValue = event.target.value;
}
updateNe2Password(password) {
this.set('user.ne2Password', password);
this.get('user.hasValidated').removeObject('ne2Password');
this.get('user.errors').remove('ne2Password');
}
},
@action
async deleteUser() {
await this.modals.open(DeleteUserModal, {
user: this.model
});
}
deleteUser: action(async function () {
if (this.deleteUserActionIsVisible) {
await this.modals.open(DeleteUserModal, {
user: this.model
});
}
}),
@action
async suspendUser() {
await this.modals.open(SuspendUserModal, {
user: this.model,
saveTask: this.saveTask
});
}
suspendUser: action(async function () {
if (this.deleteUserActionIsVisible) {
await this.modals.open(SuspendUserModal, {
user: this.model,
saveTask: this.save
});
}
}),
@action
async unsuspendUser() {
await this.modals.open(UnsuspendUserModal, {
user: this.model,
saveTask: this.saveTask
});
}
unsuspendUser: action(async function () {
if (this.deleteUserActionIsVisible) {
await this.modals.open(UnsuspendUserModal, {
user: this.model,
saveTask: this.save
});
}
}),
@action
async transferOwnership() {
await this.modals.open(TransferOwnershipModal, {
user: this.model
});
}
transferOwnership: action(async function () {
if (this.canMakeOwner) {
await this.modals.open(TransferOwnershipModal, {
user: this.model
});
}
}),
regenerateStaffToken: action(async function () {
@action
async regenerateStaffToken() {
const apiToken = await this.modals.open(RegenerateStaffTokenModal);
if (apiToken) {
this.set('personalToken', apiToken);
this.set('personalTokenRegenerated', true);
this.personalToken = apiToken;
this.personalTokenRegenerated = true;
}
}),
}
selectRole: action(async function () {
@action
async selectRole() {
const newRole = await this.modals.open(SelectRoleModal, {
currentRole: this.model.role
});
if (newRole) {
this.user.role = newRole;
this.set('dirtyAttributes', true);
this.dirtyAttributes = true;
}
}),
}
changeCoverImage: action(async function () {
@action
async changeCoverImage() {
await this.modals.open(UploadImageModal, {
model: this.model,
modelProperty: 'coverImage'
});
}),
}
changeProfileImage: action(async function () {
@action
async changeProfileImage() {
await this.modals.open(UploadImageModal, {
model: this.model,
modelProperty: 'profileImage'
});
}),
}
setScratchValue: action(function (property, value) {
@action
setScratchValue(property, value) {
this.scratchValues[property] = value;
}),
}
clearScratchValues() {
this.scratchValues = new TrackedObject();
},
reset: action(function () {
@action
reset() {
this.user.rollbackAttributes();
this.user.password = '';
this.user.newPassword = '';
this.user.ne2Password = '';
this.set('slugValue', this.user.slug);
this.set('dirtyAttributes', false);
this.slugValue = this.user.slug;
this.dirtyAttributes = false;
this.clearScratchValues();
}),
}
toggleCommentNotifications: action(function (event) {
@action
toggleCommentNotifications(event) {
this.user.commentNotifications = event.target.checked;
}),
}
toggleMemberEmailAlerts: action(function (type, event) {
@action
toggleMemberEmailAlerts(type, event) {
if (type === 'free-signup') {
this.user.freeMemberSignupNotification = event.target.checked;
} else if (type === 'paid-started') {
@ -210,17 +218,21 @@ export default Controller.extend({
} else if (type === 'paid-canceled') {
this.user.paidSubscriptionCanceledNotification = event.target.checked;
}
}),
}
updateSlug: task(function* (newSlug) {
let slug = this.get('user.slug');
@taskGroup({enqueue: true}) saveHandlers;
@task({group: 'saveHandlers'})
*updateSlugTask(event) {
let newSlug = event.target.value;
let slug = this.user.slug;
newSlug = newSlug || slug;
newSlug = newSlug.trim();
// Ignore unchanged slugs or candidate slugs that are empty
if (!newSlug || slug === newSlug) {
this.set('slugValue', slug);
this.slugValue = slug;
return true;
}
@ -247,26 +259,27 @@ export default Controller.extend({
// for the incrementor then the existing slug should be used
if (isNumber(check) && check > 0) {
if (slug === slugTokens.join('-') && serverSlug !== newSlug) {
this.set('slugValue', slug);
this.slugValue = slug;
return true;
}
}
this.set('slugValue', serverSlug);
this.set('dirtyAttributes', true);
this.slugValue = serverSlug;
this.dirtyAttributes = true;
return true;
}).group('saveHandlers'),
}
save: task(function* () {
@task({group: 'saveHandlers'})
*saveTask() {
let user = this.user;
let slugValue = this.slugValue;
let slugChanged;
if (user.get('slug') !== slugValue) {
if (user.slug !== slugValue) {
slugChanged = true;
user.set('slug', slugValue);
user.slug = slugValue;
}
try {
@ -286,7 +299,7 @@ export default Controller.extend({
windowProxy.replaceState({path: newPath}, '', newPath);
}
this.set('dirtyAttributes', false);
this.dirtyAttributes = false;
this.notifications.closeAlerts('user.update');
return user;
@ -297,11 +310,37 @@ export default Controller.extend({
this.notifications.showAPIError(error, {key: 'user.update'});
}
}
}).group('saveHandlers'),
}
saveViaKeyboard: action(function (event) {
@task
*saveNewPasswordTask() {
yield this.user.saveNewPasswordTask.perform();
document.querySelector('#password-reset')?.reset();
}
@action
submitPasswordForm(event) {
event.preventDefault();
this._blurAndTrigger(() => this.saveNewPasswordTask.perform());
}
@action
saveViaKeyboard(event) {
event.preventDefault();
this._blurAndTrigger(() => this.saveTask.perform());
}
@task
*copyContentKeyTask() {
copyTextToClipboard(this.personalToken);
yield timeout(this.isTesting ? 50 : 3000);
}
clearScratchValues() {
this.scratchValues = new TrackedObject();
}
_blurAndTrigger(fn) {
// trigger any set-on-blur actions
const focusedElement = document.activeElement;
focusedElement?.blur();
@ -309,12 +348,7 @@ export default Controller.extend({
// schedule save for when set-on-blur actions have finished
run.schedule('actions', this, function () {
focusedElement?.focus();
this.save.perform();
fn();
});
}),
copyContentKey: task(function* () {
copyTextToClipboard(this.personalToken);
yield timeout(this.isTesting ? 50 : 3000);
})
});
}
}

View File

@ -97,8 +97,8 @@ export default BaseModel.extend(ValidationEngine, {
return this.coverImage || defaultPath;
}),
saveNewPassword: task(function* () {
let validation = this.isLoggedIn ? 'ownPasswordChange' : 'passwordChange';
saveNewPasswordTask: task(function* () {
const validation = this.isLoggedIn ? 'ownPasswordChange' : 'passwordChange';
try {
yield this.validate({property: validation});
@ -108,7 +108,7 @@ export default BaseModel.extend(ValidationEngine, {
}
try {
let url = this.get('ghostPaths.url').api('users', 'password');
let url = this.ghostPaths.url.api('users', 'password');
yield this.ajax.put(url, {
data: {
@ -121,16 +121,14 @@ export default BaseModel.extend(ValidationEngine, {
}
});
this.setProperties({
password: '',
newPassword: '',
ne2Password: ''
});
this.password = '';
this.newPassword = '';
this.ne2Password = '';
this.notifications.showNotification('Password updated', {type: 'success', key: 'user.change-password.success'});
// clear errors manually for ne2password because validation
// engine only clears the "validated proeprty"
// engine only clears the "validated property"
// TODO: clean up once we have a better validations library
this.errors.remove('ne2Password');

View File

@ -0,0 +1,17 @@
import Modifier from 'ember-modifier';
import {action} from '@ember/object';
import {registerDestructor} from '@ember/destroyable';
export default class SelectOnClickModifier extends Modifier {
modify(element) {
element.addEventListener('click', this.onClick);
registerDestructor(this, () => {
element.removeEventListener('click', this.onClick);
});
}
@action
onClick(event) {
event.currentTarget.select();
}
}

View File

@ -15,20 +15,20 @@ export default class UserRoute extends AuthenticatedRoute {
const currentUser = this.session.user;
let isOwnProfile = user.get('id') === currentUser.get('id');
let isAuthorOrContributor = currentUser.get('isAuthorOrContributor');
let isEditor = currentUser.get('isEditor');
let isOwnProfile = user.id === currentUser.id;
let isAuthorOrContributor = currentUser.isAuthorOrContributor;
let isEditor = currentUser.isEditor;
if (isAuthorOrContributor && !isOwnProfile) {
this.transitionTo('settings.staff.user', currentUser);
} else if (isEditor && !isOwnProfile && !user.get('isAuthorOrContributor')) {
} else if (isEditor && !isOwnProfile && !user.isAuthorOrContributor) {
this.transitionTo('settings.staff');
}
if (isOwnProfile) {
this.store.queryRecord('api-key', {id: 'me'}).then((apiKey) => {
this.controller.set('personalToken', apiKey.id + ':' + apiKey.secret);
this.controller.set('personalTokenRegenerated', false);
this.controller.personalToken = apiKey.id + ':' + apiKey.secret;
this.controller.personalTokenRegenerated = false;
});
}
}
@ -37,6 +37,11 @@ export default class UserRoute extends AuthenticatedRoute {
return {user_slug: model.get('slug')};
}
setupController(controller, model) {
controller.model = model;
controller.reset();
}
@action
async willTransition(transition) {
if (this.hasConfirmed) {

View File

@ -29,7 +29,7 @@
<section class="view-actions">
{{#if (or this.userActionsAreVisible this.session.user.isAdmin)}}
<span class="dropdown">
<GhDropdownButton @dropdownName="user-actions-menu" @classNames="gh-btn gh-btn-white gh-btn-icon icon-only user-actions-cog" @title="User Actions" data-test-user-actions={{true}}>
<GhDropdownButton @dropdownName="user-actions-menu" @classNames="gh-btn gh-btn-white gh-btn-icon icon-only user-actions-cog" @title="User Actions" data-test-user-actions>
<span>
{{svg-jar "settings"}}
<span class="hidden">User Settings</span>
@ -74,7 +74,7 @@
</span>
{{/if}}
<GhTaskButton @class="gh-btn gh-btn-primary gh-btn-icon" @task={{this.save}} data-test-save-button={{true}} />
<GhTaskButton @class="gh-btn gh-btn-primary gh-btn-icon" @task={{this.saveTask}} data-test-save-button />
</section>
</GhCanvasHeader>
@ -85,7 +85,7 @@
{{!-- <div class="bg-"> --}}
<section>
<div class="gm-main view-container settings-user">
<form id="user-settings-form" class="user-profile" novalidate="novalidate" autocomplete="off" {{on "submit" (perform this.save)}}>
<form id="user-settings-form" class="user-profile" novalidate="novalidate" autocomplete="off" {{on "submit" (perform this.saveTask)}}>
<figure class="user-cover" style={{background-image-style this.user.coverImageUrl}}>
<button type="button" class="gh-btn gh-btn-default user-cover-edit" {{on "click" this.changeCoverImage}}><span>Change cover</span></button>
@ -101,14 +101,15 @@
<GhFormGroup @errors={{this.user.errors}} @hasValidated={{this.user.hasValidated}} @property="name" @class="first-form-group">
<label for="user-name">Full name</label>
<GhTextInput
@id="user-name"
@class="user-name"
@autocorrect="off"
@value={{readonly this.user.name}}
@input={{action (mut this.user.name) value="target.value"}}
@focus-out={{action "validate" "name" target=this.user}}
data-test-name-input={{true}}
<input
type="text"
id="user-name"
class="gh-input user-name"
autocorrect="off"
value={{this.user.name}}
{{on "input" (fn this.setModelProperty "name")}}
{{on "blur" (fn this.validateModelProperty "name")}}
data-test-name-input
/>
{{#if this.user.errors.name}}
<GhErrorMessage @errors={{this.user.errors}} @property="name" data-test-error="user-name" />
@ -119,16 +120,16 @@
<GhFormGroup @errors={{this.user.errors}} @hasValidated={{this.user.hasValidated}} @property="slug">
<label for="user-slug">Slug</label>
<GhTextInput
@class="user-name"
@id="user-slug"
@name="user"
@selectOnClick="true"
@autocorrect="off"
@value={{readonly this.slugValue}}
@input={{action (mut this.slugValue) value="target.value"}}
@focus-out={{action (perform this.updateSlug this.slugValue)}}
data-test-slug-input={{true}}
<input
type="text"
id="user-slug"
class="gh-input user-slug"
autocorrect="off"
value={{this.slugValue}}
{{on "input" this.setSlugValue}}
{{on "blur" (perform this.updateSlugTask)}}
{{select-on-click}}
data-test-slug-input
/>
<p><GhBlogUrl />/author/{{this.slugValue}}</p>
<GhErrorMessage @errors={{this.user.errors}} @property="slug" data-test-error="user-slug" />
@ -138,18 +139,18 @@
<label for="user-email">Email</label>
{{!-- Administrators only see text of Owner's email address but not input --}}
{{#if this.canChangeEmail}}
<GhTextInput
@type="email"
@id="user-email"
@name="email"
@placeholder="jamie@example.com"
@autocapitalize="off"
@autocorrect="off"
@autocomplete="off"
@value={{readonly this.user.email}}
@input={{action (mut this.user.email) value="target.value"}}
@focus-out={{action "validate" "email" target=this.user}}
data-test-email-input={{true}}
<input
type="email"
id="user-email"
class="gh-input"
placeholder="jamie@example.com"
autocapitalize="off"
autocorrect="off"
autocomplete="off"
value={{this.user.email}}
{{on "input" (fn this.setModelProperty "email")}}
{{on "blur" (fn this.validateModelProperty "email")}}
data-test-email-input
/>
<GhErrorMessage @errors={{this.user.errors}} @property="email" data-test-error="user-email" />
{{else}}
@ -168,28 +169,33 @@
<GhFormGroup @errors={{this.user.errors}} @hasValidated={{this.user.hasValidated}} @property="location">
<label for="user-location">Location</label>
<GhTextInput
@id="user-location"
@value={{readonly this.user.location}}
@input={{action (mut this.user.location) value="target.value"}}
@focus-out={{action "validate" "location" target=this.user}}
data-test-location-input={{true}} />
<input
type="text"
id="user-location"
class="gh-input"
value={{this.user.location}}
{{on "input" (fn this.setModelProperty "location")}}
{{on "blur" (fn this.validateModelProperty "location")}}
data-test-location-input
/>
<GhErrorMessage @errors={{this.user.errors}} @property="location" data-test-error="user-location" />
<p>Where in the world do you live?</p>
</GhFormGroup>
<GhFormGroup @errors={{this.user.errors}} @hasValidated={{this.user.hasValidated}} @property="website">
<label for="user-website">Website</label>
<GhTextInput
@type="url"
@id="user-website"
@autocapitalize="off"
@autocorrect="off"
@autocomplete="off"
@value={{readonly this.user.website}}
@input={{action (mut this.user.website) value="target.value"}}
@focus-out={{action "validate" "website" target=this.user}}
data-test-website-input={{true}} />
<input
type="url"
id="user-website"
class="gh-input"
autocapitalize="off"
autocorrect="off"
autocomplete="off"
value={{this.user.website}}
{{on "input" (fn this.setModelProperty "website")}}
{{on "blur" (fn this.validateModelProperty "website")}}
data-test-website-input
/>
<GhErrorMessage @errors={{this.user.errors}} @property="website" data-test-error="user-website" />
<p>Have a website or blog other than this one? Link it!</p>
</GhFormGroup>
@ -222,13 +228,13 @@
<GhFormGroup @errors={{this.user.errors}} @hasValidated={{this.user.hasValidated}} @property="bio" @class="bio-container">
<label for="user-bio">Bio</label>
<GhTextarea
@id="user-bio"
@value={{readonly this.user.bio}}
@input={{action (mut this.user.bio) value="target.value"}}
@focus-out={{action "validate" "bio" target=this.user}}
data-test-bio-input={{true}}
/>
<textarea
id="user-bio"
class="gh-input"
{{on "input" (fn this.setModelProperty "bio")}}
{{on "blur" (fn this.validateModelProperty "bio")}}
data-test-bio-input
>{{this.user.bio}}</textarea>
<GhErrorMessage @errors={{this.user.errors}} @property="bio" data-test-error="user-bio" />
<p>
Recommended: <strong>200</strong> characters.
@ -331,22 +337,21 @@
{{!-- If an administrator is viewing Owner's profile then hide inputs for change password --}}
{{#if this.canChangePassword}}
<form id="password-reset" class="user-profile" novalidate="novalidate" autocomplete="off" {{on "submit" (perform this.user.saveNewPassword)}}>
<form id="password-reset" class="user-profile" novalidate="novalidate" autocomplete="off" {{on "submit" this.submitPasswordForm}}>
<div class="pa5">
<fieldset class="user-details-form">
{{#if this.isOwnProfile}}
<GhFormGroup @errors={{this.user.errors}} @hasValidated={{this.user.hasValidated}} @property="password">
<label for="user-password-old">Old password</label>
<GhTextInput
@type="password"
@id="user-password-old"
@autocomplete="current-password"
@value={{readonly this.user.password}}
@input={{action "updatePassword" value="target.value"}}
@keyEvents={{hash
Enter=(action (perform this.user.saveNewPassword))
}}
data-test-old-pass-input={{true}}
<input
type="password"
id="user-password-old"
class="gh-input"
autocomplete="current-password"
value={{this.user.password}}
{{on "input" (fn this.setModelProperty "password")}}
{{on "input" (fn this.clearModelErrors "password")}}
data-test-old-pass-input
/>
<GhErrorMessage @errors={{this.user.errors}} @property="password" data-test-error="user-old-pass" />
</GhFormGroup>
@ -354,37 +359,35 @@
<GhFormGroup @errors={{this.user.errors}} @hasValidated={{this.user.hasValidated}} @property="newPassword">
<label for="user-password-new">New password</label>
<GhTextInput
@value={{readonly this.user.newPassword}}
@type="password"
@autocomplete="new-password"
@id="user-password-new"
@input={{action "updateNewPassword" value="target.value"}}
@keyEvents={{hash
Enter=(action (perform this.user.saveNewPassword))
}}
data-test-new-pass-input={{true}}
<input
type="password"
id="user-password-new"
class="gh-input"
autocomplete="new-password"
value={{this.user.newPassword}}
{{on "input" (fn this.setModelProperty "newPassword")}}
{{on "input" (fn this.clearModelErrors "newPassword")}}
data-test-new-pass-input
/>
<GhErrorMessage @errors={{this.user.errors}} @property="newPassword" data-test-error="user-new-pass" />
</GhFormGroup>
<GhFormGroup @errors={{this.user.errors}} @hasValidated={{this.user.hasValidated}} @property="ne2Password">
<label for="user-new-password-verification">Verify password</label>
<GhTextInput
@value={{readonly this.user.ne2Password}}
@type="password"
@id="user-new-password-verification"
@input={{action "updateNe2Password" value="target.value"}}
@keyEvents={{hash
Enter=(action (perform this.user.saveNewPassword))
}}
data-test-ne2-pass-input={{true}}
<input
type="password"
id="user-new-password-verification"
class="gh-input"
value={{this.user.ne2Password}}
{{on "input" (fn this.setModelProperty "ne2Password")}}
{{on "input" (fn this.clearModelErrors "ne2Password")}}
data-test-ne2-pass-input
/>
<GhErrorMessage @errors={{this.user.errors}} @property="ne2Password" data-test-error="user-ne2-pass" />
</GhFormGroup>
<div class="form-group">
<GhTaskButton @buttonText="Change Password" @idleClass="gh-btn-red" @class="gh-btn gh-btn-icon button-change-password" @task={{this.user.saveNewPassword}} data-test-save-pw-button="true" />
<GhTaskButton @buttonText="Change Password" @idleClass="gh-btn-red" @class="gh-btn gh-btn-icon button-change-password" @task={{this.saveNewPasswordTask}} data-test-save-pw-button="true" />
</div>
</fieldset>
</div>
@ -397,20 +400,21 @@
<fieldset class="user-details-form">
<GhFormGroup>
<label for="personal-token">Staff access token</label>
<div class="relative flex items-center {{unless this.copyContentKey.isRunning "hide-child-instant"}}">
<GhTextInput
@id="personal-token"
@value={{readonly this.personalToken}}
@readonly
@type="text"
onclick="this.select()"
/>
<div class="relative flex items-center {{unless this.copyContentKeyTask.isRunning "hide-child-instant"}}">
<input
type="text"
id="personal-token"
class="gh-input"
value={{this.personalToken}}
readonly
{{select-on-click}}
/>
<div class="app-api-personal-token-buttons child">
<button type="button" class="app-button-regenerate" {{on "click" this.regenerateStaffToken}} data-tooltip="Regenerate">
{{svg-jar "reload" class="w4 h4 stroke-midgrey"}}
</button>
<button type="button" {{action (perform this.copyContentKey)}} class="app-button-copy">
{{#if this.copyContentKey.isRunning}}
<button type="button" class="app-button-copy" {{on "click" (perform this.copyContentKeyTask)}}>
{{#if this.copyContentKeyTask.isRunning}}
{{svg-jar "check-circle" class="w3 v-mid mr2 stroke-white"}} Copied
{{else}}
Copy

View File

@ -12,8 +12,7 @@ import {
find,
findAll,
focus,
triggerEvent,
triggerKeyEvent
triggerEvent
} from '@ember/test-helpers';
import {enableLabsFlag} from '../helpers/labs-flag';
import {enableMembers} from '../helpers/members';
@ -22,6 +21,7 @@ import {expect} from 'chai';
import {keyDown} from 'ember-keyboard/test-support/test-helpers';
import {setupApplicationTest} from 'ember-mocha';
import {setupMirage} from 'ember-cli-mirage/test-support';
import {submitForm} from '../helpers/forms';
import {visit} from '../helpers/visit';
describe('Acceptance: Staff', function () {
@ -456,7 +456,8 @@ describe('Acceptance: Staff', function () {
});
describe('existing user', function () {
let user, newLocation, originalReplaceState;
let user, originalReplaceState;
let newLocation; // eslint-disable-line
beforeEach(function () {
user = this.server.create('user', {
@ -511,7 +512,7 @@ describe('Acceptance: Staff', function () {
// Save changes
await click('[data-test-save-button]');
// Since we reset save status so there's no on-screen indication
// Since we reset save button's status there's no on-screen indication
// that we've had a save, check the request was fired instead
let [lastRequest] = this.server.pretender.handledRequests.slice(-1);
let params = JSON.parse(lastRequest.requestBody);
@ -519,19 +520,16 @@ describe('Acceptance: Staff', function () {
expect(params.users[0].name).to.equal('Test User');
// CMD-S shortcut works
await fillIn('[data-test-slug-input]', 'New Slug');
await fillIn('[data-test-name-input]', 'New Name');
await keyDown('cmd+s');
// Since we reset save status so there's no on-screen indication
// Since we reset save button's status there's no on-screen indication
// that we've had a save, check the request was fired instead
[lastRequest] = this.server.pretender.handledRequests.slice(-1);
params = JSON.parse(lastRequest.requestBody);
// slug should have been correctly slugified before save
expect(params.users[0].slug).to.equal('new-slug');
// check that the history state has been updated
expect(newLocation).to.equal('new-slug');
// name should have been correctly set before save (we set on blur)
expect(params.users[0].name, 'saved name').to.equal('New Name');
await fillIn('[data-test-slug-input]', 'white space');
await blur('[data-test-slug-input]');
@ -703,7 +701,7 @@ describe('Acceptance: Staff', function () {
await fillIn('[data-test-ne2-pass-input]', 'notlong');
// enter key triggers action
await triggerKeyEvent('[data-test-new-pass-input]', 'keyup', 13);
await submitForm('[data-test-new-pass-input]');
expect(
find('[data-test-new-pass-input]').closest('.form-group'),
@ -720,7 +718,7 @@ describe('Acceptance: Staff', function () {
await fillIn('[data-test-ne2-pass-input]', 'ghostisawesome');
// enter key triggers action
await triggerKeyEvent('#user-password-new', 'keyup', 13);
await submitForm('#user-password-new');
expect(
find('#user-password-new').closest('.form-group'),
@ -742,7 +740,7 @@ describe('Acceptance: Staff', function () {
).to.not.have.class('error');
// enter key triggers action
await triggerKeyEvent('[data-test-new-pass-input]', 'keyup', 13);
await submitForm('[data-test-new-pass-input]');
expect(
find('[data-test-ne2-pass-input]').closest('.form-group'),

View File

@ -0,0 +1,10 @@
import {find, triggerEvent} from '@ember/test-helpers';
export async function submitForm(elementOrSelector) {
if (typeof elementOrSelector === 'string') {
elementOrSelector = find(elementOrSelector);
}
const form = elementOrSelector.closest('form');
await triggerEvent(form, 'submit');
}