TryGhost/Ghost

View on GitHub
ghost/admin/app/services/custom-views.js

Summary

Maintainability
A
0 mins
Test Coverage
import CustomViewFormModal from '../components/modals/custom-view-form';
import EmberObject, {action} from '@ember/object';
import Service, {inject as service} from '@ember/service';
import ValidationEngine from 'ghost-admin/mixins/validation-engine';
import {isArray} from '@ember/array';
import {task} from 'ember-concurrency';

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: 'pen',
    filter: {
        type: 'draft'
    }
}, {
    route: 'posts',
    name: 'Scheduled',
    color: 'midgrey',
    icon: 'clock',
    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 modals;
    @service router;
    @service session;
    @service settings;

    get viewList() {
        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.sharedViews || '[]');
        views = isArray(views) ? views : [];

        const 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
        if (!session.user.isContributor) {
            viewList.push(...DEFAULT_VIEWS);
        }

        viewList.push(...views.map((view) => {
            return CustomView.create(view);
        }));

        return viewList;
    }

    @task
    *saveViewTask(view) {
        yield view.validate();

        const {viewList} = this;

        // perform some ad-hoc validation of duplicate names because ValidationEngine doesn't support it
        let duplicateView = 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 = viewList.find(existingView => isViewEqual(existingView, view));
        if (matchingView) {
            viewList.replace(viewList.indexOf(matchingView), 1, [view]);
        } else {
            viewList.push(view);
        }

        // rebuild the "views" array in our user settings json string
        yield this._saveViewSettings(viewList);

        view.set('isNew', false);
        return view;
    }

    @task
    *deleteViewTask(view) {
        const {viewList} = this;
        let matchingView = viewList.find(existingView => isViewEqual(existingView, view));
        if (matchingView && !matchingView.isDefault) {
            viewList.removeObject(matchingView);
            yield this._saveViewSettings(viewList);
            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
        });
    }

    @action
    editView() {
        const customView = CustomView.create(this.activeView || this.newView());

        return this.modals.open(CustomViewFormModal, {
            customView
        });
    }

    async _saveViewSettings(viewList) {
        let sharedViews = viewList.reject(view => view.isDefault).map(view => view.toJSON());
        this.settings.sharedViews = JSON.stringify(sharedViews);
        return this.settings.save();
    }
}