0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-04-08 02:52:39 -05:00

Added tag filter support to collections

refs https://github.com/TryGhost/Arch/issues/41

- When an new collection is created the relational "tags" filter is now picked up properly and appropriate posts matching the tag filter are assigned and stored in the collection. Example collection filter that is now supported: `tags:['bacon']`
- Additionally cleaned up returned collection post DTOs, so we return as little data as possible and add only the fields that are needed
This commit is contained in:
Naz 2023-07-18 19:50:18 +08:00 committed by naz
parent 939a8fef33
commit 53f9f954c1
8 changed files with 207 additions and 19 deletions

View file

@ -37,6 +37,7 @@
},
"c8": {
"exclude": [
"src/CollectionPost.ts",
"src/CollectionRepository.ts",
"src/UniqueChecker.ts",
"src/**/*.d.ts",

View file

@ -3,5 +3,5 @@ export type CollectionPost = {
id: string;
featured?: boolean;
published_at?: Date;
tags: string[];
tags?: string[];
};

View file

@ -4,6 +4,7 @@ import ObjectID from 'bson-objectid';
import {Collection} from './Collection';
import {CollectionResourceChangeEvent} from './CollectionResourceChangeEvent';
import {CollectionRepository} from './CollectionRepository';
import {CollectionPost} from './CollectionPost';
import {MethodNotAllowedError, NotFoundError} from '@tryghost/errors';
import {PostDeletedEvent} from './events/PostDeletedEvent';
import {PostAddedEvent} from './events/PostAddedEvent';
@ -41,11 +42,15 @@ type CollectionPostDTO = {
type CollectionPostListItemDTO = {
id: string;
url: string;
slug: string;
title: string;
featured: boolean;
featured_image?: string;
published_at: Date
created_at: Date;
updated_at: Date;
published_at: Date,
tags?: string[];
}
type ManualCollection = {
@ -130,6 +135,21 @@ export class CollectionsService {
};
}
private toCollectionPostDTO(post: any): CollectionPostListItemDTO {
return {
id: post.id,
url: post.url,
slug: post.slug,
title: post.title,
featured: post.featured,
featured_image: post.featured_image,
created_at: post.created_at,
updated_at: post.updated_at,
published_at: post.published_at,
tags: post.tags
};
}
private fromDTO(data: any): any {
const mappedDTO: {[index: string]:any} = {
title: data.title,
@ -244,7 +264,7 @@ export class CollectionsService {
}
}
private async addPostToMatchingCollections(post: {id: string, featured: boolean, published_at: Date}) {
private async addPostToMatchingCollections(post: CollectionPost) {
const collections = await this.collectionsRepository.getAll({
filter: 'type:automatic'
});
@ -382,7 +402,7 @@ export class CollectionsService {
const posts = await this.postsRepository.getBulk(postIds);
return {
data: posts,
data: posts.map(this.toCollectionPostDTO),
meta: {
pagination: {
page: page,

View file

@ -2,6 +2,7 @@ type PostData = {
id: string;
featured: boolean;
published_at: Date;
tags: string[];
};
export class PostAddedEvent {

View file

@ -1,25 +1,37 @@
export const posts = [{
id: 'post-1',
url: 'http://localhost:2368/post-1/',
title: 'Post 1',
slug: 'post-1',
featured: false,
created_at: new Date('2023-03-15T07:19:07.447Z'),
updated_at: new Date('2023-03-15T07:19:07.447Z'),
published_at: new Date('2023-03-15T07:19:07.447Z')
}, {
id: 'post-2',
url: 'http://localhost:2368/post-2/',
title: 'Post 2',
slug: 'post-2',
featured: false,
created_at: new Date('2023-04-05T07:20:07.447Z'),
updated_at: new Date('2023-04-05T07:20:07.447Z'),
published_at: new Date('2023-04-05T07:20:07.447Z')
}, {
id: 'post-3-featured',
url: 'http://localhost:2368/featured-post-3/',
title: 'Featured Post 3',
slug: 'featured-post-3',
featured: true,
created_at: new Date('2023-05-25T07:21:07.447Z'),
updated_at: new Date('2023-05-25T07:21:07.447Z'),
published_at: new Date('2023-05-25T07:21:07.447Z')
}, {
id: 'post-4-featured',
url: 'http://localhost:2368/featured-post-4/',
title: 'Featured Post 4',
slug: 'featured-post-4',
featured: true,
created_at: new Date('2023-05-15T07:21:07.447Z'),
updated_at: new Date('2023-05-15T07:21:07.447Z'),
published_at: new Date('2023-05-15T07:21:07.447Z')
}];

View file

@ -27,17 +27,31 @@ class PostsRepository {
return attrs;
}
serializeTags(attrs) {
if (attrs.tags) {
attrs.tags = attrs.tags.map(tag => tag.slug);
}
if (attrs.primary_tag) {
delete attrs.primary_tag;
}
return attrs;
}
async getAll({filter}) {
const response = await this.browsePostsAPI({
options: {
filter: `(${filter})+type:post`,
status: 'all',
limit: 'all'
limit: 'all',
withRelated: ['tags']
}
});
response.posts = response.posts
.map(this.serializeDates.bind(this));
.map(this.serializeDates.bind(this))
.map(this.serializeTags.bind(this));
return response.posts;
}
@ -46,12 +60,14 @@ class PostsRepository {
const response = await this.browsePostsAPI({
options: {
filter: `id:[${ids.join(',')}]+type:post`,
status: 'all'
status: 'all',
withRelated: ['tags']
}
});
response.posts = response.posts
.map(this.serializeDates.bind(this));
.map(this.serializeDates.bind(this))
.map(this.serializeTags.bind(this));
return response.posts;
}

View file

@ -114,6 +114,107 @@ Object {
}
`;
exports[`Collections API Automatic Collection Filtering Creates an automatic Collection with a tag filter 1: [body] 1`] = `
Object {
"collections": Array [
Object {
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"description": "BACON!",
"feature_image": null,
"filter": "tags:['bacon']",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"posts": Array [
Object {
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"sort_order": 0,
},
Object {
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"sort_order": 1,
},
],
"slug": "bacon",
"title": "Test Collection with tag filter",
"type": "automatic",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
},
],
}
`;
exports[`Collections API Automatic Collection Filtering Creates an automatic Collection with a tag filter 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "385",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/collections\\\\/\\[a-f0-9\\]\\{24\\}\\\\//,
"vary": "Accept-Version, Origin, Accept-Encoding",
"x-cache-invalidate": "/*",
"x-powered-by": "Express",
}
`;
exports[`Collections API Automatic Collection Filtering Creates an automatic Collection with a tag filter 3: [body] 1`] = `
Object {
"collection_posts": Array [
Object {
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000\\\\\\+\\\\d\\{2\\}:\\\\d\\{2\\}/,
"featured": false,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000\\\\\\+\\\\d\\{2\\}:\\\\d\\{2\\}/,
"slug": "ghostly-kitchen-sink",
"tags": Array [
"kitchen-sink",
"bacon",
],
"title": "Ghostly Kitchen Sink",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000\\\\\\+\\\\d\\{2\\}:\\\\d\\{2\\}/,
"url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//,
},
Object {
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000\\\\\\+\\\\d\\{2\\}:\\\\d\\{2\\}/,
"featured": false,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000\\\\\\+\\\\d\\{2\\}:\\\\d\\{2\\}/,
"slug": "html-ipsum",
"tags": Array [
"kitchen-sink",
"bacon",
],
"title": "HTML Ipsum",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000\\\\\\+\\\\d\\{2\\}:\\\\d\\{2\\}/,
"url": StringMatching /http:\\\\/\\\\/127\\.0\\.0\\.1:2369\\\\/\\[A-Za-z0-9_-\\]\\+\\\\//,
},
],
"meta": Object {
"pagination": Object {
"limit": 15,
"next": null,
"page": 1,
"pages": 1,
"prev": null,
"total": 2,
},
},
}
`;
exports[`Collections API Automatic Collection Filtering Creates an automatic Collection with a tag filter 4: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "746",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Collections API Browse Can browse Collections 1: [body] 1`] = `
Object {
"collections": Array [

View file

@ -12,10 +12,9 @@ const {
anyLocationFor,
anyObjectId,
anyISODateTime,
// anyISODateTimeWithTZ,
anyISODateTimeWithTZ,
anyNumber,
// anyUuid,
// anyLocalURL,
anyLocalURL,
anyString
} = matchers;
@ -25,14 +24,13 @@ const matchCollection = {
updated_at: anyISODateTime
};
// const matchCollectionPost = {
// id: anyObjectId,
// url: anyLocalURL,
// created_at: anyISODateTimeWithTZ,
// updated_at: anyISODateTimeWithTZ,
// published_at: anyISODateTimeWithTZ,
// uuid: anyUuid
// };
const matchCollectionPost = {
id: anyObjectId,
url: anyLocalURL,
created_at: anyISODateTimeWithTZ,
updated_at: anyISODateTimeWithTZ,
published_at: anyISODateTimeWithTZ
};
/**
*
@ -373,5 +371,44 @@ describe('Collections API', function () {
collections: [buildMatcher(9)]
});
});
it('Creates an automatic Collection with a tag filter', async function () {
const collection = {
title: 'Test Collection with tag filter',
slug: 'bacon',
description: 'BACON!',
type: 'automatic',
filter: 'tags:[\'bacon\']'
};
await agent
.post('/collections/')
.body({
collections: [collection]
})
.expectStatus(201)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag,
location: anyLocationFor('collections')
})
.matchBodySnapshot({
collections: [buildMatcher(2)]
});
await agent
.get('/collections/bacon/posts/')
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
collection_posts: [
matchCollectionPost,
matchCollectionPost
]
});
});
});
});