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:
parent
533681373f
commit
cc4ff8c6d4
69
ghost/admin/app/components/collections/collection-form.hbs
Normal file
69
ghost/admin/app/components/collections/collection-form.hbs
Normal 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. You’ve 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>
|
65
ghost/admin/app/components/collections/collection-form.js
Normal file
65
ghost/admin/app/components/collections/collection-form.js
Normal 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});
|
||||
}
|
||||
}
|
@ -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>
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
32
ghost/admin/app/components/collections/list-item.hbs
Normal file
32
ghost/admin/app/components/collections/list-item.hbs
Normal 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>
|
@ -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")}}
|
||||
|
43
ghost/admin/app/controllers/collection.js
Normal file
43
ghost/admin/app/controllers/collection.js
Normal 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'});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
38
ghost/admin/app/controllers/collections.js
Normal file
38
ghost/admin/app/controllers/collections.js
Normal 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');
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
21
ghost/admin/app/models/collection.js
Normal file
21
ghost/admin/app/models/collection.js
Normal 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()
|
||||
});
|
@ -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'});
|
||||
|
72
ghost/admin/app/routes/collection.js
Normal file
72
ghost/admin/app/routes/collection.js
Normal 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;
|
||||
}
|
||||
}
|
6
ghost/admin/app/routes/collection/new.js
Normal file
6
ghost/admin/app/routes/collection/new.js
Normal file
@ -0,0 +1,6 @@
|
||||
import CollectionRoute from '../collection';
|
||||
|
||||
export default class NewRoute extends CollectionRoute {
|
||||
controllerName = 'collection';
|
||||
templateName = 'collection';
|
||||
}
|
31
ghost/admin/app/routes/collections.js
Normal file
31
ghost/admin/app/routes/collections.js
Normal 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'
|
||||
};
|
||||
}
|
||||
}
|
37
ghost/admin/app/templates/collection.hbs
Normal file
37
ghost/admin/app/templates/collection.hbs
Normal 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>
|
34
ghost/admin/app/templates/collections.hbs
Normal file
34
ghost/admin/app/templates/collections.hbs
Normal 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}}
|
18
ghost/admin/app/validators/collection.js
Normal file
18
ghost/admin/app/validators/collection.js
Normal 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');
|
||||
}
|
||||
});
|
1
ghost/admin/public/assets/icons/collections-bookmark.svg
Normal file
1
ghost/admin/public/assets/icons/collections-bookmark.svg
Normal 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 |
12
ghost/admin/tests/unit/routes/collection-test.js
Normal file
12
ghost/admin/tests/unit/routes/collection-test.js
Normal 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;
|
||||
});
|
||||
});
|
12
ghost/admin/tests/unit/routes/collections-test.js
Normal file
12
ghost/admin/tests/unit/routes/collections-test.js
Normal 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;
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user