0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-03-18 02:21:47 -05:00

Refactored facebook/twitter URL inputs

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

Twitter/facebook URL validation doesn't follow our typical validation and was duplicated across multiple screens making the controllers unnecessarily complex.

- extracted url input fields and their validation into separate components
- uses tracked scratch values so that the input field values can reset to the saved value on save
  - twitter/facebook URL inputs are different to our other inputs because invalid values won't prevent saving, instead they are reset to their previous value on save
- added `this.validate()` call after a successful save in `settings` service so the service and underlying model validations are both in sync (fixes validation error sticking around after saving with invalid twitter/facebook values that have been reset)
This commit is contained in:
Kevin Ansfield 2022-10-04 17:32:49 +01:00
parent 5106643871
commit c77c150745
10 changed files with 234 additions and 272 deletions

View file

@ -887,3 +887,17 @@ remove|ember-template-lint|no-action|125|88|125|88|ea7c6e414cc202b6e4049eae4f60e
remove|ember-template-lint|no-action|129|35|129|35|ea7c6e414cc202b6e4049eae4f60eb0829bd3ff0|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|136|42|136|42|7a2a7a1b2159d811b310a9b89817108ddff9df88|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|140|35|140|35|7a2a7a1b2159d811b310a9b89817108ddff9df88|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|361|43|361|43|1f32c13702a8b46410e9e10f9988fa2e0df41968|1662681600000|1673053200000|1678237200000|app/templates/settings/general.hbs
remove|ember-template-lint|no-action|362|47|362|47|69b8d823159491abd67494eba728616ac0a928a5|1662681600000|1673053200000|1678237200000|app/templates/settings/general.hbs
remove|ember-template-lint|no-action|374|43|374|43|226e659be4c2c10c0e5fbebb40f712f3016c9b90|1662681600000|1673053200000|1678237200000|app/templates/settings/general.hbs
remove|ember-template-lint|no-action|375|47|375|47|d746c5532ce38d23d8537195a3afa6ce7235f577|1662681600000|1673053200000|1678237200000|app/templates/settings/general.hbs
remove|ember-template-lint|no-passed-in-event-handlers|361|36|361|36|c14e20b2b090706f9af9fa416c4300c06e206894|1662681600000|1673053200000|1678237200000|app/templates/settings/general.hbs
remove|ember-template-lint|no-passed-in-event-handlers|374|36|374|36|e286f50c92a17a31581207e4c65054f146cbd533|1662681600000|1673053200000|1678237200000|app/templates/settings/general.hbs
remove|ember-template-lint|no-action|122|82|122|82|e2e24a0e1efe19ed982cc8140248570c03eee7b9|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|262|39|262|39|1f32c13702a8b46410e9e10f9988fa2e0df41968|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|263|43|263|43|69b8d823159491abd67494eba728616ac0a928a5|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|279|39|279|39|226e659be4c2c10c0e5fbebb40f712f3016c9b90|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
remove|ember-template-lint|no-action|280|43|280|43|d746c5532ce38d23d8537195a3afa6ce7235f577|1662681600000|1673053200000|1678237200000|app/templates/settings/staff/user.hbs
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

View file

@ -0,0 +1,10 @@
<input
type="url"
class="gh-input"
placeholder="https://www.facebook.com/username"
value={{this.value}}
{{on "input" this.setScratchValue}}
{{on "blur" this.setFacebookUrl}}
data-test-facebook-input
...attributes
/>

View file

@ -0,0 +1,68 @@
import Component from '@glimmer/component';
import validator from 'validator';
import {action} from '@ember/object';
import {get, set} from '@ember/object';
export default class GhFacebookUrlInput extends Component {
// NOTE: `get` and `set` are required when reading/writing model properties
// because we can be dealing with proxy objects such as the settings service
get value() {
const {model, modelProperty, scratchValue} = this.args;
return scratchValue || get(model, modelProperty);
}
@action
setScratchValue(event) {
this.args.setScratchValue?.(event.target.value);
}
@action
setFacebookUrl(event) {
const {model, modelProperty} = this.args;
let newUrl = event.target.value;
// reset errors and validation
model.errors.remove('facebook');
model.hasValidated.removeObject('facebook');
if (!newUrl) {
// Clear out the Facebook url
set(model, modelProperty, null);
this.args.setScratchValue?.(null);
return;
}
try {
// strip any facebook URLs out
newUrl = newUrl.replace(/(https?:\/\/)?(www\.)?facebook\.com/i, '');
// don't allow any non-facebook urls
if (newUrl.match(/^(http|\/\/)/i)) {
throw 'invalid url';
}
// strip leading / if we have one then concat to full facebook URL
newUrl = newUrl.replace(/^\//, '');
newUrl = `https://www.facebook.com/${newUrl}`;
// don't allow URL if it's not valid
if (!validator.isURL(newUrl)) {
throw 'invalid url';
}
set(model, modelProperty, newUrl);
this.args.setScratchValue?.(null);
} catch (e) {
if (e === 'invalid url') {
const message = 'The URL must be in a format like https://www.facebook.com/yourPage';
model.errors.add('facebook', message);
return;
}
throw e;
} finally {
model.hasValidated.pushObject('facebook');
}
}
}

View file

@ -0,0 +1,10 @@
<input
type="url"
class="gh-input"
placeholder="https://twitter.com/username"
value={{this.value}}
{{on "input" this.setScratchValue}}
{{on "blur" this.setTwitterUrl}}
data-test-twitter-input
...attributes
/>

View file

@ -0,0 +1,66 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
import {get, set} from '@ember/object';
export default class GhTwitterUrlInput extends Component {
// NOTE: `get` and `set` are required when reading/writing model properties
// because we can be dealing with proxy objects such as the settings service
get value() {
const {model, modelProperty, scratchValue} = this.args;
return scratchValue || get(model, modelProperty);
}
@action
setScratchValue(event) {
this.args.setScratchValue?.(event.target.value);
}
@action
setTwitterUrl(event) {
const {model, modelProperty} = this.args;
const newUrl = event.target.value;
// reset errors and validation
model.errors.remove(modelProperty);
model.hasValidated.removeObject(modelProperty);
if (!newUrl) {
// Clear out the Twitter url
set(model, modelProperty, '');
this.args.setScratchValue?.(null);
return;
}
if (newUrl.match(/(?:twitter\.com\/)(\S+)/) || newUrl.match(/([a-z\d.]+)/i)) {
let username = [];
if (newUrl.match(/(?:twitter\.com\/)(\S+)/)) {
[, username] = newUrl.match(/(?:twitter\.com\/)(\S+)/);
} else {
[username] = newUrl.match(/([^/]+)\/?$/mi);
}
// check if username starts with http or www and show error if so
if (username.match(/^(http|www)|(\/)/) || !username.match(/^[a-z\d._]{1,15}$/mi)) {
const message = !username.match(/^[a-z\d._]{1,15}$/mi)
? 'Your Username is not a valid Twitter Username'
: 'The URL must be in a format like https://twitter.com/yourUsername';
model.errors.add(modelProperty, message);
model.hasValidated.pushObject(modelProperty);
return;
}
set(model, modelProperty, `https://twitter.com/${username}`);
this.args.setScratchValue?.(null);
model.hasValidated.pushObject(modelProperty);
} else {
const message = 'The URL must be in a format like https://twitter.com/yourUsername';
model.errors.add(modelProperty, message);
model.hasValidated.pushObject(modelProperty);
return;
}
}
}

View file

@ -4,12 +4,13 @@ import {inject as service} from '@ember/service';
/* eslint-disable ghost/ember/alias-model-in-controller */
import Controller from '@ember/controller';
import generatePassword from 'ghost-admin/utils/password-generator';
import validator from 'validator';
import {
IMAGE_EXTENSIONS,
IMAGE_MIME_TYPES
} from 'ghost-admin/components/gh-image-uploader';
import {TrackedObject} from 'tracked-built-ins';
import {task} from 'ember-concurrency';
import {tracked} from '@glimmer/tracking';
function randomPassword() {
let word = generatePassword(6);
@ -28,11 +29,11 @@ export default class GeneralController extends Controller {
@service frontend;
@service ui;
@tracked scratchValues = new TrackedObject();
availableTimezones = null;
imageExtensions = IMAGE_EXTENSIONS;
imageMimeTypes = IMAGE_MIME_TYPES;
_scratchFacebook = null;
_scratchTwitter = null;
@computed('config.blogUrl', 'settings.publicHash')
get privateRSSUrl() {
@ -102,111 +103,12 @@ export default class GeneralController extends Controller {
}
@action
validateFacebookUrl() {
let newUrl = this._scratchFacebook;
let oldUrl = this.get('settings.facebook');
let errMessage = '';
// reset errors and validation
this.get('settings.errors').remove('facebook');
this.get('settings.hasValidated').removeObject('facebook');
if (newUrl === '') {
// Clear out the Facebook url
this.set('settings.facebook', '');
return;
}
// _scratchFacebook will be null unless the user has input something
if (!newUrl) {
newUrl = oldUrl;
}
try {
// strip any facebook URLs out
newUrl = newUrl.replace(/(https?:\/\/)?(www\.)?facebook\.com/i, '');
// don't allow any non-facebook urls
if (newUrl.match(/^(http|\/\/)/i)) {
throw 'invalid url';
}
// strip leading / if we have one then concat to full facebook URL
newUrl = newUrl.replace(/^\//, '');
newUrl = `https://www.facebook.com/${newUrl}`;
// don't allow URL if it's not valid
if (!validator.isURL(newUrl)) {
throw 'invalid url';
}
this.settings.set('facebook', newUrl);
this.settings.notifyPropertyChange('facebook');
} catch (e) {
if (e === 'invalid url') {
errMessage = 'The URL must be in a format like '
+ 'https://www.facebook.com/yourPage';
this.get('settings.errors').add('facebook', errMessage);
return;
}
throw e;
} finally {
this.get('settings.hasValidated').pushObject('facebook');
}
setScratchValue(property, value) {
this.scratchValues[property] = value;
}
@action
validateTwitterUrl() {
let newUrl = this._scratchTwitter;
let oldUrl = this.get('settings.twitter');
let errMessage = '';
// reset errors and validation
this.get('settings.errors').remove('twitter');
this.get('settings.hasValidated').removeObject('twitter');
if (newUrl === '') {
// Clear out the Twitter url
this.set('settings.twitter', '');
return;
}
// _scratchTwitter will be null unless the user has input something
if (!newUrl) {
newUrl = oldUrl;
}
if (newUrl.match(/(?:twitter\.com\/)(\S+)/) || newUrl.match(/([a-z\d.]+)/i)) {
let username = [];
if (newUrl.match(/(?:twitter\.com\/)(\S+)/)) {
[, username] = newUrl.match(/(?:twitter\.com\/)(\S+)/);
} else {
[username] = newUrl.match(/([^/]+)\/?$/mi);
}
// check if username starts with http or www and show error if so
if (username.match(/^(http|www)|(\/)/) || !username.match(/^[a-z\d._]{1,15}$/mi)) {
errMessage = !username.match(/^[a-z\d._]{1,15}$/mi) ? 'Your Username is not a valid Twitter Username' : 'The URL must be in a format like https://twitter.com/yourUsername';
this.get('settings.errors').add('twitter', errMessage);
this.get('settings.hasValidated').pushObject('twitter');
return;
}
newUrl = `https://twitter.com/${username}`;
this.settings.get('hasValidated').pushObject('twitter');
this.settings.set('twitter', newUrl);
this.settings.notifyPropertyChange('twitter');
} else {
errMessage = 'The URL must be in a format like '
+ 'https://twitter.com/yourUsername';
this.get('settings.errors').add('twitter', errMessage);
this.get('settings.hasValidated').pushObject('twitter');
return;
}
clearScratchValues() {
this.scratchValues = new TrackedObject();
}
_deleteTheme() {
@ -221,25 +123,23 @@ export default class GeneralController extends Controller {
});
}
@task(function* () {
@task
*saveTask() {
let notifications = this.notifications;
let config = this.config;
if (this.settings.get('twitter') !== this._scratchTwitter) {
this.send('validateTwitterUrl');
}
if (this.settings.get('facebook') !== this._scratchFacebook) {
this.send('validateFacebookUrl');
}
try {
let changedAttrs = this.settings.changedAttributes();
let settings = yield this.settings.save();
this.clearScratchValues();
config.set('blogTitle', settings.get('title'));
if (changedAttrs.password) {
this.frontend.loginIfNeeded();
}
// this forces the document title to recompute after a blog title change
this.ui.updateDocumentTitle();
@ -250,6 +150,5 @@ export default class GeneralController extends Controller {
}
throw error;
}
})
saveTask;
}
}

View file

@ -9,13 +9,14 @@ import UploadImageModal from '../../../components/settings/staff/modals/upload-i
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 validator from 'validator';
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 {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(),
@ -31,8 +32,13 @@ export default Controller.extend({
personalToken: null,
personalTokenRegenerated: false,
dirtyAttributes: false,
_scratchFacebook: null,
_scratchTwitter: null,
init() {
this._super(...arguments);
this.clearScratchValues();
},
scratchValues: tracked(),
saveHandlers: taskGroup().enqueue(),
@ -85,117 +91,6 @@ export default Controller.extend({
}),
actions: {
validateFacebookUrl() {
let newUrl = this._scratchFacebook;
let oldUrl = this.get('user.facebook');
let errMessage = '';
// reset errors and validation
this.get('user.errors').remove('facebook');
this.get('user.hasValidated').removeObject('facebook');
if (newUrl === '') {
// Clear out the Facebook url
this.set('user.facebook', '');
return;
}
// _scratchFacebook will be null unless the user has input something
if (!newUrl) {
newUrl = oldUrl;
}
try {
// strip any facebook URLs out
newUrl = newUrl.replace(/(https?:\/\/)?(www\.)?facebook\.com/i, '');
// don't allow any non-facebook urls
if (newUrl.match(/^(http|\/\/)/i)) {
throw 'invalid url';
}
// strip leading / if we have one then concat to full facebook URL
newUrl = newUrl.replace(/^\//, '');
newUrl = `https://www.facebook.com/${newUrl}`;
// don't allow URL if it's not valid
if (!validator.isURL(newUrl)) {
throw 'invalid url';
}
this.set('user.facebook', '');
run.schedule('afterRender', this, function () {
this.set('user.facebook', newUrl);
});
} catch (e) {
if (e === 'invalid url') {
errMessage = 'The URL must be in a format like '
+ 'https://www.facebook.com/yourPage';
this.get('user.errors').add('facebook', errMessage);
return;
}
throw e;
} finally {
this.get('user.hasValidated').pushObject('facebook');
}
},
validateTwitterUrl() {
let newUrl = this._scratchTwitter;
let oldUrl = this.get('user.twitter');
let errMessage = '';
// reset errors and validation
this.get('user.errors').remove('twitter');
this.get('user.hasValidated').removeObject('twitter');
if (newUrl === '') {
// Clear out the Twitter url
this.set('user.twitter', '');
return;
}
// _scratchTwitter will be null unless the user has input something
if (!newUrl) {
newUrl = oldUrl;
}
if (newUrl.match(/(?:twitter\.com\/)(\S+)/) || newUrl.match(/([a-z\d.]+)/i)) {
let username = [];
if (newUrl.match(/(?:twitter\.com\/)(\S+)/)) {
[, username] = newUrl.match(/(?:twitter\.com\/)(\S+)/);
} else {
[username] = newUrl.match(/([^/]+)\/?$/mi);
}
// check if username starts with http or www and show error if so
if (username.match(/^(http|www)|(\/)/) || !username.match(/^[a-z\d._]{1,15}$/mi)) {
errMessage = !username.match(/^[a-z\d._]{1,15}$/mi) ? 'Your Username is not a valid Twitter Username' : 'The URL must be in a format like https://twitter.com/yourUsername';
this.get('user.errors').add('twitter', errMessage);
this.get('user.hasValidated').pushObject('twitter');
return;
}
newUrl = `https://twitter.com/${username}`;
this.get('user.hasValidated').pushObject('twitter');
this.set('user.twitter', '');
run.schedule('afterRender', this, function () {
this.set('user.twitter', newUrl);
});
} else {
errMessage = 'The URL must be in a format like '
+ 'https://twitter.com/yourUsername';
this.get('user.errors').add('twitter', errMessage);
this.get('user.hasValidated').pushObject('twitter');
return;
}
},
// TODO: remove those mutation actions once we have better
// inline validations that auto-clear errors on input
updatePassword(password) {
@ -285,6 +180,14 @@ export default Controller.extend({
});
}),
setScratchValue: action(function (property, value) {
this.scratchValues[property] = value;
}),
clearScratchValues() {
this.scratchValues = new TrackedObject();
},
reset: action(function () {
this.user.rollbackAttributes();
this.user.password = '';
@ -292,6 +195,7 @@ export default Controller.extend({
this.user.ne2Password = '';
this.set('slugValue', this.user.slug);
this.set('dirtyAttributes', false);
this.clearScratchValues();
}),
toggleCommentNotifications: action(function (event) {
@ -366,7 +270,9 @@ export default Controller.extend({
}
try {
user = yield user.save({format: false});
user = yield user.save();
this.clearScratchValues();
// If the user's slug has changed, change the URL and replace
// the history so refresh and back button still work

View file

@ -63,6 +63,7 @@ export default class SettingsService extends Service.extend(_ProxyMixin, Validat
}
await settings.save();
await this.validate();
this.set('settledIcon', settings.icon);
return settings;
}

View file

@ -359,30 +359,26 @@
{{#liquid-if this.socialOpen}}
<div class="gh-setting-content-extended">
<GhFormGroup @errors={{this.settings.errors}} @hasValidated={{this.settings.hasValidated}} @property="facebook">
<GhTextInput
@type="url"
@placeholder="https://www.facebook.com/ghost"
@autocorrect="off"
@value={{readonly this.settings.facebook}}
@input={{action (mut this._scratchFacebook) value="target.value"}}
@focus-out={{action "validateFacebookUrl"}}
data-test-facebook-input={{true}}
<GhFacebookUrlInput
aria-labelledby="facebook-url-label"
@scratchValue={{this.scratchValues.facebook}}
@setScratchValue={{fn this.setScratchValue "facebook"}}
@model={{this.settings}}
@modelProperty="facebook"
/>
<GhErrorMessage @errors={{this.settings.errors}} @property="facebook" data-test-facebook-error />
<p>URL of your publication's Facebook Page</p>
<p id="facebook-url-label">URL of your publication's Facebook Page</p>
</GhFormGroup>
<GhFormGroup @errors={{this.settings.errors}} @hasValidated={{this.settings.hasValidated}} @property="twitter">
<GhTextInput
@type="url"
@placeholder="https://twitter.com/ghost"
@autocorrect="off"
@value={{readonly this.settings.twitter}}
@input={{action (mut this._scratchTwitter) value="target.value"}}
@focus-out={{action "validateTwitterUrl"}}
data-test-twitter-input={{true}}
<GhTwitterUrlInput
aria-labelledby="twitter-url-label"
@scratchValue={{this.scratchValues.twitter}}
@setScratchValue={{fn this.setScratchValue "twitter"}}
@model={{this.settings}}
@modelProperty="twitter"
/>
<GhErrorMessage @errors={{this.settings.errors}} @property="twitter" data-test-twitter-error />
<p>URL of your publication's Twitter profile</p>
<p id="twitter-url-label">URL of your publication's Twitter profile</p>
</GhFormGroup>
</div>
{{/liquid-if}}

View file

@ -85,7 +85,7 @@
{{!-- <div class="bg-"> --}}
<section>
<div class="gm-main view-container settings-user">
<form class="user-profile" novalidate="novalidate" autocomplete="off" {{action (perform this.save) on="submit"}}>
<form id="user-settings-form" class="user-profile" novalidate="novalidate" autocomplete="off" {{on "submit" (perform this.save)}}>
<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>
@ -196,16 +196,12 @@
<GhFormGroup @errors={{this.user.errors}} @hasValidated={{this.user.hasValidated}} @property="facebook">
<label for="user-facebook">Facebook profile</label>
<GhTextInput
@type="url"
@placeholder="https://www.facebook.com/username"
@autocorrect="off"
@id="user-facebook"
@name="user[facebook]"
@value={{readonly this.user.facebook}}
@input={{action (mut this._scratchFacebook) value="target.value"}}
@focus-out={{action "validateFacebookUrl"}}
data-test-facebook-input={{true}}
<GhFacebookUrlInput
id="user-facebook"
@scratchValue={{this.scratchValues.facebook}}
@setScratchValue={{fn this.setScratchValue "facebook"}}
@model={{this.user}}
@modelProperty="facebook"
/>
<GhErrorMessage @errors={{this.user.errors}} @property="facebook" data-test-error="user-facebook" />
<p>URL of your personal Facebook Profile</p>
@ -213,16 +209,12 @@
<GhFormGroup @errors={{this.user.errors}} @hasValidated={{this.user.hasValidated}} @property="twitter">
<label for="user-twitter">Twitter profile</label>
<GhTextInput
@type="url"
@placeholder="https://twitter.com/username"
@autocorrect="off"
@id="user-twitter"
@name="user[twitter]"
@value={{readonly this.user.twitter}}
@input={{action (mut this._scratchTwitter) value="target.value"}}
@focus-out={{action "validateTwitterUrl"}}
data-test-twitter-input={{true}}
<GhTwitterUrlInput
id="user-twitter"
@scratchValue={{this.scratchValues.twitter}}
@setScratchValue={{fn this.setScratchValue "twitter"}}
@model={{this.user}}
@modelProperty="twitter"
/>
<GhErrorMessage @errors={{this.user.errors}} @property="twitter" data-test-error="user-twitter" />
<p>URL of your personal Twitter profile</p>
@ -339,7 +331,7 @@
{{!-- 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" {{action (perform this.user.saveNewPassword) on="submit"}}>
<form id="password-reset" class="user-profile" novalidate="novalidate" autocomplete="off" {{on "submit" (perform this.user.saveNewPassword)}}>
<div class="pa5">
<fieldset class="user-details-form">
{{#if this.isOwnProfile}}