Added collections CRUD UI

refs https://github.com/TryGhost/Team/issues/3168

- This is basic scaffolding for collection resources UI in Admin. For the most part it's a copy-paste of tags code with slight modifications to fit the collections usecase
This commit is contained in:
Naz 2023-05-23 12:28:53 +07:00 committed by naz
parent 533681373f
commit cc4ff8c6d4
20 changed files with 548 additions and 0 deletions

View File

@ -0,0 +1,69 @@
<div class="gh-main-section">
<div class="flex justify-between items-center">
<h4 class="gh-main-section-header small bn">Basic settings</h4>
</div>
<section class="gh-main-section-block">
<div class="gh-main-section-content grey columns-1">
<GhFormGroup @errors={{@collection.errors}} @hasValidated={{@collection.hasValidated}} @property="title" class="mr2 flex-auto">
<label for="collection-title">Title</label>
<input type="text" class="gh-input" id="collection-title" name="title" value={{@collection.title}} {{on "input" (pick "target.value"
(fn this.setCollectionProperty "title" ))}} {{on "blur" (fn this.validateCollectionProperty "title" )}}
data-test-input="collection-title" />
<span class="error">
<GhErrorMessage @errors={{@collection.errors}} @property="title" />
</span>
</GhFormGroup>
<GhFormGroup class="no-margin" @errors={{@collection.errors}} @hasValidated={{@collection.hasValidated}}
@property="description">
<label for="collection-description">Description</label>
<textarea id="collection-description" name="description" class="gh-input gh-collection-details-textarea"
{{on "input" (pick "target.value" (fn this.setCollectionProperty "description" ))}} {{on "blur" (fn
this.validateCollectionProperty "description" )}}
data-test-input="collection-description">{{@collection.description}}</textarea>
<GhErrorMessage @errors={{@collection.errors}} @property="description" />
<p>Maximum: <b>500</b> characters. Youve used {{gh-count-down-characters @collection.description 500}}</p>
</GhFormGroup>
<GhFormGroup class="gh-collection-image-uploader no-margin" @errors={{@collection.errors}} @hasValidated={{@collection.hasValidated}} @property="featureImage">
<label for="collection-image">Collection image</label>
<GhImageUploaderWithPreview
@image={{@collection.featureImage}}
@text="Upload collection image"
@allowUnsplash={{true}}
@update={{fn this.setCollectionProperty "featureImage"}}
@remove={{fn this.setCollectionProperty "featureImage" ""}}
/>
</GhFormGroup>
</div>
</section>
<section class="gh-main-section-block">
<div class="gh-main-section-content grey columns-1">
<GhFormGroup class="gh-collection-image-uploader no-margin" @errors={{@collection.errors}} @hasValidated={{@collection.hasValidated}} @property="type">
<label for="collection-image">Collection type</label>
<div class="gh-contentfilter-menu gh-contentfilter-visibility {{if @selectedVisibility.value "gh-contentfilter-selected"}}" data-test-visibility-select="true">
<PowerSelect
@selected={{this.selectedType}}
@options={{this.availableTypes}}
@searchEnabled={{false}}
@onChange={{this.changeType}}
@triggerComponent={{component "gh-power-select/trigger"}}
@triggerClass="gh-contentfilter-menu-trigger"
@dropdownClass="gh-contentfilter-menu-dropdown"
@matchTriggerWidth={{false}}
as |type|
>
{{#if type.name}}{{type.name}}{{else}}<span class="red">Unknown type</span>{{/if}}
</PowerSelect>
{{#if (eq this.selectedType.value 'manual')}}
<p>Add posts to this collection one by one through post settings menu.</p>
{{/if}}
</div>
</GhFormGroup>
</div>
</section>
</div>

View File

@ -0,0 +1,65 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
import {inject} from 'ghost-admin/decorators/inject';
import {inject as service} from '@ember/service';
import {slugify} from '@tryghost/string';
const TYPES = [{
name: 'Manual',
value: 'manual'
}, {
name: 'Automatic',
value: 'automatic'
}];
export default class CollectionForm extends Component {
@service feature;
@service settings;
@inject config;
availableTypes = TYPES;
get selectedType() {
const {collection} = this.args;
return this.availableTypes.findBy('value', collection.type) || {value: '!unknown'};
}
@action
setCollectionProperty(property, newValue) {
const {collection} = this.args;
if (newValue) {
newValue = newValue.trim();
}
// Generate slug based on name for new collection when empty
if (property === 'title' && collection.isNew && !this.hasChangedSlug) {
let slugValue = slugify(newValue);
if (/^#/.test(newValue)) {
slugValue = 'hash-' + slugValue;
}
collection.slug = slugValue;
}
// ensure manual changes of slug don't get reset when changing name
if (property === 'slug') {
this.hasChangedSlug = !!newValue;
}
collection[property] = newValue;
// clear validation message when typing
collection.hasValidated.addObject(property);
}
@action
changeType(type) {
this.setCollectionProperty('type', type.value);
}
@action
validateCollectionProperty(property) {
return this.args.collection.validate({property});
}
}

View File

@ -0,0 +1,19 @@
<div class="modal-content" {{on-key "Enter" (perform this.deleteCollectionTask)}} data-test-modal="confirm-delete-collection">
<header class="modal-header">
<h1>Are you sure you want to delete this collection?</h1>
</header>
<button type="button" class="close"title="Close" {{on "click" @close}}>{{svg-jar "close"}}<span class="hidden">Close</span></button>
<div class="modal-body">
{{#if @data.collection.count.posts}}
<span class="red">This collection is attached to <span data-test-text="posts-count">{{gh-pluralize @data.collection.count.posts "post"}}</span>.</span>
{{/if}}
You're about to delete "<strong>{{@data.collection.title}}</strong>". This is permanent! We warned you, k?
</div>
<div class="modal-footer">
<button class="gh-btn" type="button" {{on "click" @close}} data-test-button="cancel"><span>Cancel</span></button>
<GhTaskButton @buttonText="Delete" @successText="Deleted" @task={{this.deleteCollectionTask}}
@class="gh-btn gh-btn-red gh-btn-icon" data-test-button="confirm" />
</div>
</div>

View File

@ -0,0 +1,29 @@
import Component from '@glimmer/component';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
export default class DeleteCollectionModal extends Component {
@service notifications;
@service router;
@task({drop: true})
*deleteCollectionTask() {
try {
const {collection} = this.args.data;
if (collection.isDeleted) {
return true;
}
yield collection.destroyRecord();
this.notifications.closeAlerts('collection.delete');
this.router.transitionTo('collections');
return true;
} catch (error) {
this.notifications.showAPIError(error, {key: 'collection.delete.failed'});
} finally {
this.args.close();
}
}
}

View File

@ -0,0 +1,32 @@
<li class="gh-list-row gh-collections-list-item" ...attributes>
<LinkTo @route="collection" @model={{@collection}} class="gh-list-data gh-collection-list-title gh-list-cellwidth-70" title="Edit collection">
<h3 class="gh-collection-list-name" data-test-collection-name>
{{@collection.title}}
</h3>
{{#if @collection.description}}
<p class="ma0 pa0 f8 midgrey gh-collection-list-description" data-test-collection-description>
{{@collection.description}}
</p>
{{/if}}
</LinkTo>
<LinkTo @route="collection" @model={{@collection}} class="gh-list-data middarkgrey f8 gh-collection-list-slug gh-list-cellwidth-10" title="Edit collection" data-test-collection-slug>
<span title="{{@collection.slug}}">{{@collection.slug}}</span>
</LinkTo>
{{#if @collection.count.posts}}
<LinkTo @route="posts" @query={{hash type=null author=null collection=@collection.slug order=null}} class="gh-list-data gh-collection-list-posts-count gh-list-cellwidth-10 f8" title={{concat "List posts collectionged with '" @collection.name "'"}}>
<span class="nowrap">{{gh-pluralize @collection.count.posts "post"}}</span>
</LinkTo>
{{else}}
<LinkTo @route="collection" @model={{@collection}} class="gh-list-data gh-collection-list-posts-count gh-list-cellwidth-10" title="Edit collection">
<span class="nowrap f8 midlightgrey">{{gh-pluralize @collection.count.posts "post"}}</span>
</LinkTo>
{{/if}}
<LinkTo @route="collection" @model={{@collection}} class="gh-list-data gh-list-cellwidth-10 gh-list-chevron" title="Edit collection">
<div class="flex items-center justify-end w-100 h-100">
<span class="nr2">{{svg-jar "arrow-right" class="w6 h6 fill-midgrey pa1"}}</span>
</div>
</LinkTo>
</li>

View File

@ -94,6 +94,9 @@
{{#if this.showTagsNavigation}}
<li><LinkTo @route="tags" @current-when="tags tag tag.new" data-test-nav="tags">{{svg-jar "tag"}}Tags</LinkTo></li>
{{/if}}
{{#if (and (gh-user-can-admin this.session.user) (feature "collections"))}}
<li><LinkTo @route="collections" @current-when="collections collection collection.new" data-test-nav="collections">{{svg-jar "collections-bookmark"}}Collections</LinkTo></li>
{{/if}}
{{#if (gh-user-can-admin this.session.user)}}
<li class="relative">
{{#if (eq this.router.currentRouteName "members.index")}}

View File

@ -0,0 +1,43 @@
import Controller from '@ember/controller';
import DeleteCollectionModal from '../components/collections/delete-collection-modal';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
export default class CollectionController extends Controller {
@service modals;
@service notifications;
@service router;
get collection() {
return this.model;
}
@action
confirmDeleteCollection() {
return this.modals.open(DeleteCollectionModal, {
collection: this.model
});
}
@task({drop: true})
*saveTask() {
let {collection} = this;
try {
if (collection.get('errors').length !== 0) {
return;
}
yield collection.save();
// replace 'new' route with 'collection' route
this.replaceRoute('collection', collection);
return collection;
} catch (error) {
if (error) {
this.notifications.showAPIError(error, {key: 'collection.save'});
}
}
}
}

View File

@ -0,0 +1,38 @@
import Controller from '@ember/controller';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
import {tracked} from '@glimmer/tracking';
export default class CollectionsController extends Controller {
@service router;
queryParams = ['type'];
@tracked type = 'public';
get collections() {
return this.model;
}
get filteredCollections() {
return this.collections.filter((collection) => {
return (!collection.isNew);
});
}
get sortedCollections() {
return this.filteredCollections.sort((collectionA, collectionB) => {
// ignorePunctuation means the # in internal collection names is ignored
return collectionA.title.localeCompare(collectionB.title, undefined, {ignorePunctuation: true});
});
}
@action
changeType(type) {
this.type = type;
}
@action
newCollection() {
this.router.transitionTo('collection.new');
}
}

View File

@ -1,5 +1,6 @@
// TODO: remove usage of Ember Data's private `Errors` class when refactoring validations
// eslint-disable-next-line
import CollectionValidator from 'ghost-admin/validators/collection';
import CustomViewValidator from 'ghost-admin/validators/custom-view';
import DS from 'ember-data'; // eslint-disable-line
import IntegrationValidator from 'ghost-admin/validators/integration';
@ -67,6 +68,7 @@ export default Mixin.create({
signin: SigninValidator,
signup: SignupValidator,
tag: TagSettingsValidator,
collection: CollectionValidator,
user: UserValidator,
member: MemberValidator,
integration: IntegrationValidator,

View File

@ -0,0 +1,21 @@
import Model, {attr} from '@ember-data/model';
import ValidationEngine from 'ghost-admin/mixins/validation-engine';
import {inject as service} from '@ember/service';
export default Model.extend(ValidationEngine, {
validationType: 'collection',
title: attr('string'),
slug: attr('string'),
description: attr('string'),
type: attr('string', {defaultValue: 'manual'}),
filter: attr('string'),
featureImage: attr('string'),
createdAtUTC: attr('moment-utc'),
updatedAtUTC: attr('moment-utc'),
createdBy: attr('number'),
updatedBy: attr('number'),
count: attr('raw'),
feature: service()
});

View File

@ -50,6 +50,10 @@ Router.map(function () {
this.route('tag.new', {path: '/tags/new'});
this.route('tag', {path: '/tags/:tag_slug'});
this.route('collections');
this.route('collection.new', {path: '/collection/new'});
this.route('collection', {path: '/collection/:collection_slug'});
this.route('settings-x');
this.route('settings');
this.route('settings.general', {path: '/settings/general'});

View File

@ -0,0 +1,72 @@
import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
import ConfirmUnsavedChangesModal from '../components/modals/confirm-unsaved-changes';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
export default class CollectionRoute extends AuthenticatedRoute {
@service modals;
@service router;
@service session;
beforeModel() {
super.beforeModel(...arguments);
if (this.session.user.isAuthorOrContributor) {
return this.transitionTo('home');
}
}
model(params) {
this._requiresBackgroundRefresh = false;
if (params.collection_slug) {
return this.store.queryRecord('collection', {slug: params.collection_slug});
} else {
return this.store.createRecord('collection');
}
}
serialize(collection) {
return {collection_slug: collection.get('collection')};
}
@action
async willTransition(transition) {
if (this.hasConfirmed) {
return true;
}
transition.abort();
// wait for any existing confirm modal to be closed before allowing transition
if (this.confirmModal) {
return;
}
if (this.controller.saveTask?.isRunning) {
await this.controller.saveTask.last;
}
const shouldLeave = await this.confirmUnsavedChanges();
if (shouldLeave) {
this.controller.model.rollbackAttributes();
this.hasConfirmed = true;
return transition.retry();
}
}
async confirmUnsavedChanges() {
if (this.controller.model?.hasDirtyAttributes) {
this.confirmModal = this.modals
.open(ConfirmUnsavedChangesModal)
.finally(() => {
this.confirmModal = null;
});
return this.confirmModal;
}
return true;
}
}

View File

@ -0,0 +1,6 @@
import CollectionRoute from '../collection';
export default class NewRoute extends CollectionRoute {
controllerName = 'collection';
templateName = 'collection';
}

View File

@ -0,0 +1,31 @@
import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
export default class CollectionsRoute extends AuthenticatedRoute {
// authors aren't allowed to manage tags
beforeModel() {
super.beforeModel(...arguments);
if (this.session.user.isAuthorOrContributor) {
return this.transitionTo('home');
}
}
// set model to a live array so all collections are shown and created/deleted collections
// are automatically added/removed. Also load all collections in the background,
// pausing to show the loading spinner if no collections have been loaded yet
model() {
let promise = this.store.query('collection', {limit: 'all', include: 'count.posts'});
let collections = this.store.peekAll('collection');
if (this.store.peekAll('collection').get('length') === 0) {
return promise.then(() => collections);
} else {
return collections;
}
}
buildRouteInfoMetadata() {
return {
titleToken: 'Collections'
};
}
}

View File

@ -0,0 +1,37 @@
<section class="gh-canvas">
<form class="mb15">
<GhCanvasHeader class="gh-canvas-header">
<div class="flex flex-column">
<div class="gh-canvas-breadcrumb">
<LinkTo @route="collections" data-test-link="collections-back">
Collections
</LinkTo>
{{svg-jar "arrow-right-small"}} {{if this.collection.isNew "New collection" "Edit collection"}}
</div>
<h2 class="gh-canvas-title" data-test-screen-title>
{{if this.collection.isNew "New collection" this.collection.title}}
</h2>
</div>
<section class="view-actions">
<GhTaskButton
@task={{this.saveTask}}
@type="button"
@class="gh-btn gh-btn-primary gh-btn-icon"
@data-test-button="save"
{{on-key "cmd+s"}}
/>
</section>
</GhCanvasHeader>
<Collections::CollectionForm @collection={{this.model}} />
</form>
{{#unless this.collection.isNew}}
<div>
<button type="button" class="gh-btn gh-btn-red gh-btn-icon" {{on "click" this.confirmDeleteCollection}} data-test-button="delete-collection">
<span>Delete collection</span>
</button>
</div>
{{/unless}}
</section>

View File

@ -0,0 +1,34 @@
<section class="gh-canvas" {{on-key "c" this.newCollection}}>
<GhCanvasHeader class="gh-canvas-header sticky">
<h2 class="gh-canvas-title" data-test-screen-title>Collections</h2>
<section class="view-actions">
<LinkTo @route="collection.new" class="gh-btn gh-btn-primary"><span>New collection</span></LinkTo>
</section>
</GhCanvasHeader>
<section class="view-container content-list">
<ol class="collections-list gh-list {{unless this.sortedCollections "no-posts"}}">
{{#if this.sortedCollections}}
<li class="gh-list-row header">
<div class="gh-list-header gh-list-cellwidth-70">Collection</div>
<div class="gh-list-header gh-list-cellwidth-10"></div>
</li>
<VerticalCollection @items={{this.sortedCollections}} @key="id" @containerSelector=".gh-main" @estimateHeight={{60}} @bufferSize={{20}} as |collection|>
<Collections::ListItem @collection={{collection}} data-test-collection={{collection.id}} />
</VerticalCollection>
{{else}}
<li class="no-posts-box">
<div class="no-posts">
{{svg-jar "collections-placeholder" class="gh-collections-placeholder"}}
<h4>Start organizing your content.</h4>
<LinkTo @route="collection.new" class="gh-btn gh-btn-green">
<span>Create a new collection</span>
</LinkTo>
</div>
</li>
{{/if}}
</ol>
</section>
</section>
{{outlet}}

View File

@ -0,0 +1,18 @@
import BaseValidator from './base';
import {isBlank} from '@ember/utils';
export default BaseValidator.create({
properties: ['title'],
name(model) {
let title = model.title;
let hasValidated = model.hasValidated;
if (isBlank(title)) {
model.errors.add('title', 'Please enter a title.');
this.invalidate();
}
hasValidated.addObject('title');
}
});

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 10l-2.5-1.5L15 12V4h5v8z"/></svg>

After

Width:  |  Height:  |  Size: 238 B

View File

@ -0,0 +1,12 @@
import {describe, it} from 'mocha';
import {expect} from 'chai';
import {setupTest} from 'ember-mocha';
describe('Unit | Route | collection', function () {
setupTest();
it('exists', function () {
let route = this.owner.lookup('route:collection');
expect(route).to.be.ok;
});
});

View File

@ -0,0 +1,12 @@
import {describe, it} from 'mocha';
import {expect} from 'chai';
import {setupTest} from 'ember-mocha';
describe('Unit | Route | collections', function () {
setupTest();
it('exists', function () {
let route = this.owner.lookup('route:collections');
expect(route).to.be.ok;
});
});