Ghost/ghost/admin/app/services/custom-views.js
Kevin Ansfield c646e78fff Made session.user a synchronous property rather than a promise
no issue

Having `session.user` return a promise made dealing with it in components difficult because you always had to remember it returned a promise rather than a model and had to handle the async behaviour. It also meant that you couldn't use any current user properties directly inside getters which made refactors to Glimmer/Octane idioms harder to reason about.

`session.user` was a cached computed property so it really made no sense for it to be a promise - it was loaded on first access and then always returned instantly but with a fulfilled promise rather than the  underlying model.

Refactoring to a synchronous property that is loaded as part of the authentication flows (we load the current user to check that we're logged in - we may as well make use of that!) means one less thing to be aware of/remember and provides a nicer migration process to Glimmer components. As part of the refactor, the auth flows and pre-load of required data across other services was also simplified to make it easier to find and follow.

- refactored app setup and `session.user`
  - added `session.populateUser()` that fetches a user model from the current user endpoint and sets it on `session.user`
  - removed knowledge of app setup from the `cookie` authenticator and moved it into = `session.postAuthPreparation()`, this means we have the same post-authentication setup no matter which authenticator is used so we have more consistent behaviour in tests which don't use the `cookie` authenticator
  - switched `session` service to native class syntax to get the expected `super()` behaviour
  - updated `handleAuthentication()` so it populate's `session.user` and performs post-auth setup before transitioning (handles sign-in after app load)
  - updated `application` route to remove duplicated knowledge of app preload behaviour that now lives in `session.postAuthPreparation()` (handles already-authed app load)
  - removed out-of-date attempt at pre-loading data from setup controller as that's now handled automatically via `session.handleAuthentication`
- updated app code to not treat `session.user` as a promise
  - predominant usage was router `beforeModel` hooks that transitioned users without valid permissions, this sets us up for an easier removal of the `current-user-settings` mixin in the future
2021-07-08 14:54:31 +01:00

243 lines
6.6 KiB
JavaScript

import EmberObject, {action} from '@ember/object';
import Service from '@ember/service';
import ValidationEngine from 'ghost-admin/mixins/validation-engine';
import {isArray} from '@ember/array';
import {observes} from '@ember-decorators/object';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency-decorators';
import {tracked} from '@glimmer/tracking';
const VIEW_COLORS = [
'midgrey',
'blue',
'green',
'red',
'teal',
'purple',
'yellow',
'orange',
'pink'
];
const CustomView = EmberObject.extend(ValidationEngine, {
validationType: 'customView',
name: '',
route: '',
color: '',
filter: null,
isNew: false,
isDefault: false,
init() {
this._super(...arguments);
if (!this.filter) {
this.filter = {};
}
if (!this.color) {
this.color = VIEW_COLORS[Math.floor(Math.random() * VIEW_COLORS.length)];
}
},
// convert to POJO so we don't store any client-specific objects in any
// stringified JSON settings fields
toJSON() {
return {
name: this.name,
route: this.route,
color: this.color,
filter: this.filter
};
}
});
const DEFAULT_VIEWS = [{
route: 'posts',
name: 'Drafts',
color: 'midgrey',
icon: 'pencil',
filter: {
type: 'draft'
}
}, {
route: 'posts',
name: 'Scheduled',
color: 'midgrey',
icon: 'clockface',
filter: {
type: 'scheduled'
}
}, {
route: 'posts',
name: 'Published',
color: 'midgray',
icon: 'published-post',
filter: {
type: 'published'
}
}].map((view) => {
return CustomView.create(Object.assign({}, view, {isDefault: true}));
});
let isFilterEqual = function (filterA, filterB) {
let aProps = Object.getOwnPropertyNames(filterA);
let bProps = Object.getOwnPropertyNames(filterB);
if (aProps.length !== bProps.length) {
return false;
}
for (let i = 0; i < aProps.length; i++) {
let key = aProps[i];
if (filterA[key] !== filterB[key]) {
return false;
}
}
return true;
};
let isViewEqual = function (viewA, viewB) {
return viewA.route === viewB.route
&& isFilterEqual(viewA.filter, viewB.filter);
};
export default class CustomViewsService extends Service {
@service router;
@service session;
@service settings;
@tracked viewList = [];
@tracked showFormModal = false;
constructor() {
super(...arguments);
this.updateViewList();
}
// eslint-disable-next-line ghost/ember/no-observers
@observes('settings.sharedViews', 'session.{isAuthenticated,user}')
async updateViewList() {
let {settings, session} = this;
// avoid fetching user before authenticated otherwise the 403 can fire
// during authentication and cause errors during setup/signin
if (!session.isAuthenticated || !session.user) {
return;
}
let views = JSON.parse(settings.get('sharedViews') || '[]');
views = isArray(views) ? views : [];
let viewList = [];
// contributors can only see their own draft posts so it doesn't make
// sense to show them default views which change the status/type filter
let user = await session.user;
if (!user.isContributor) {
viewList.push(...DEFAULT_VIEWS);
}
viewList.push(...views.map((view) => {
return CustomView.create(view);
}));
this.viewList = viewList;
}
@action
toggleFormModal() {
this.showFormModal = !this.showFormModal;
}
@task
*saveViewTask(view) {
yield view.validate();
// perform some ad-hoc validation of duplicate names because ValidationEngine doesn't support it
let duplicateView = this.viewList.find((existingView) => {
return existingView.route === view.route
&& existingView.name.trim().toLowerCase() === view.name.trim().toLowerCase()
&& !isFilterEqual(existingView.filter, view.filter);
});
if (duplicateView) {
view.errors.add('name', 'Has already been used');
view.hasValidated.pushObject('name');
view.invalidate();
return false;
}
// remove an older version of the view from our views list
// - we don't allow editing the filter and route+filter combos are unique
// - we create a new instance of a view from an existing one when editing to act as a "scratch" view
let matchingView = this.viewList.find(existingView => isViewEqual(existingView, view));
if (matchingView) {
this.viewList.replace(this.viewList.indexOf(matchingView), 1, [view]);
} else {
this.viewList.push(view);
}
// rebuild the "views" array in our user settings json string
yield this._saveViewSettings();
view.set('isNew', false);
return view;
}
@task
*deleteViewTask(view) {
let matchingView = this.viewList.find(existingView => isViewEqual(existingView, view));
if (matchingView && !matchingView.isDefault) {
this.viewList.removeObject(matchingView);
yield this._saveViewSettings();
return true;
}
}
get availableColors() {
return VIEW_COLORS;
}
get forPosts() {
return this.viewList.filter(view => view.route === 'posts');
}
get forPages() {
return this.viewList.filter(view => view.route === 'pages');
}
get activeView() {
if (!this.router.currentRoute) {
return undefined;
}
return this.findView(this.router.currentRouteName, this.router.currentRoute.queryParams);
}
findView(routeName, queryParams) {
let _routeName = routeName.replace(/_loading$/, '');
return this.viewList.find((view) => {
return view.route === _routeName
&& isFilterEqual(view.filter, queryParams);
});
}
newView() {
return CustomView.create({
isNew: true,
route: this.router.currentRouteName,
filter: this.router.currentRoute.queryParams
});
}
editView() {
return CustomView.create(this.activeView || this.newView());
}
async _saveViewSettings() {
let sharedViews = this.viewList.reject(view => view.isDefault).map(view => view.toJSON());
this.settings.set('sharedViews', JSON.stringify(sharedViews));
return this.settings.save();
}
}