diff --git a/README.md b/README.md index 5be533d..bf978b5 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,8 @@ In our minimum support we're following [official Node.js releases timelines](htt > This plugin is designed for **Strapi v5**. To get support for other Strapi versions, please follow the [versions](#-versions) section. **Plugin dependencies** -- `@strapi/plugin-graphql` - required to run GraphQL handled by this plugin + +- `@strapi/plugin-graphql` - required to run GraphQL handled by this plugin **We recommend always using the latest version of Strapi to start your new projects**. @@ -134,7 +135,7 @@ On the dedicated page, you will be able to set up all crucial properties which d To setup amend default plugin configuration we recommend to put following snippet as part of `config/plugins.{js|ts}` or `config//plugins.{js|ts}` file. If the file does not exist yet, you have to create it manually. If you've got already configurations for other plugins stores by this way, use just the `comments` part within exising `plugins` item. ```ts - module.exports = ({ env }) => ({ +module.exports = ({ env }) => ({ //... comments: { enabled: true, @@ -289,6 +290,7 @@ Return a hierarchical tree structure of comments for specified instance of Conte - [field selection](https://docs.strapi.io/dev-docs/api/rest/populate-select#field-selection) - [sorting](https://docs.strapi.io/dev-docs/api/rest/sort-pagination#sorting) +- [pagination](https://docs.strapi.io/dev-docs/api/rest/sort-pagination#pagination) ### Get Comments (flat structure) @@ -666,7 +668,7 @@ query { "id": "123456", "name": "Joe Doe" } - }, + } // ... ] } @@ -937,34 +939,32 @@ Lifecycle hooks can be register either in `register()` or `bootstrap()` methods Listeners can by sync and `async`. ->Be aware that lifecycle hooks registered in `register()` may be fired by plugin's bootstrapping. If you want listen to events triggered after server's startup use `bootstrap()`. +> Be aware that lifecycle hooks registered in `register()` may be fired by plugin's bootstrapping. If you want listen to events triggered after server's startup use `bootstrap()`. Example: ```ts - const commentsCommonService = strapi - .plugin("comments") - .service("common"); +const commentsCommonService = strapi.plugin("comments").service("common"); - commentsCommonService.registerLifecycleHook({ - callback: async ({ action, result }) => { - const saveResult = await logIntoSystem(action, result); +commentsCommonService.registerLifecycleHook({ + callback: async ({ action, result }) => { + const saveResult = await logIntoSystem(action, result); - console.log(saveResult); - }, - contentTypeName: "comment", - hookName: "afterCreate", - }); + console.log(saveResult); + }, + contentTypeName: "comment", + hookName: "afterCreate", +}); - commentsCommonService.registerLifecycleHook({ - callback: async ({ action, result }) => { - const saveResult = await logIntoSystem(action, result); +commentsCommonService.registerLifecycleHook({ + callback: async ({ action, result }) => { + const saveResult = await logIntoSystem(action, result); - console.log(saveResult); - }, - contentTypeName: "report", - hookName: "afterCreate", - }); + console.log(saveResult); + }, + contentTypeName: "report", + hookName: "afterCreate", +}); ``` ## 💬 FAQ @@ -977,14 +977,13 @@ Example: ```ts module.exports = { - 'comments': { enabled: true }, - 'graphql': { enabled: true }, + comments: { enabled: true }, + graphql: { enabled: true }, }; ``` If you already got it, make sure that `comments` plugin is inserted before `graphql`. That should do the job. - ## 🤝 Contributing to the plugin Feel free to fork and make a Pull Request to this plugin project. All the input is warmly welcome! diff --git a/server/src/services/__tests__/common.service.test.ts b/server/src/services/__tests__/common.service.test.ts index 807fdd8..ffc8449 100644 --- a/server/src/services/__tests__/common.service.test.ts +++ b/server/src/services/__tests__/common.service.test.ts @@ -284,17 +284,21 @@ describe('common.service', () => { const strapi = getStrapi(); const service = getService(strapi); const mockComments = [ - { id: 1, content: 'Parent 1', threadOf: null }, - { id: 2, content: 'Child 1', threadOf: 1 }, - { id: 3, content: 'Child 2', threadOf: 1 }, - { id: 4, content: 'Grandchild 1', threadOf: 2 }, + { id: 1, content: "Parent 1", threadOf: null }, + { id: 2, content: "Child 1", threadOf: "1" }, + { id: 3, content: "Child 2", threadOf: "1" }, + { id: 4, content: "Grandchild 1", threadOf: "2" }, ]; mockCommentRepository.findMany.mockResolvedValue(mockComments); caster(getOrderBy).mockReturnValue(['createdAt', 'desc']); - mockCommentRepository.findWithCount.mockResolvedValue({ - results: mockComments, - pagination: { total: 4 }, + mockCommentRepository.findWithCount.mockImplementation(async (args) => { + const threadOf = args?.where?.threadOf?.$eq ?? null; + const filtered = mockComments.filter((c) => c.threadOf === threadOf); + return { + results: filtered, + pagination: { total: filtered.length }, + }; }); mockStoreRepository.getConfig.mockResolvedValue([]); @@ -334,18 +338,25 @@ describe('common.service', () => { const strapi = getStrapi(); const service = getService(strapi); const mockComments = [ - { id: 2, content: 'Child 1', threadOf: 1 }, - { id: 3, content: 'Child 2', threadOf: 1 }, - { id: 4, content: 'Grandchild 1', threadOf: 2 }, - { id: 5, content: 'Grandchild 2', threadOf: 2 }, - { id: 6, content: 'Grandchild 3', threadOf: 4 }, + { id: 2, content: "Child 1", threadOf: "1" }, + { id: 3, content: "Child 2", threadOf: "1" }, + { id: 4, content: "Grandchild 1", threadOf: "2" }, + { id: 5, content: "Grandchild 2", threadOf: "2" }, + { id: 6, content: "Grandchild 3", threadOf: "4" }, ]; mockCommentRepository.findMany.mockResolvedValue(mockComments); caster(getOrderBy).mockReturnValue(['createdAt', 'desc']); - mockCommentRepository.findWithCount.mockResolvedValue({ - results: mockComments, - pagination: { total: 3 }, + mockCommentRepository.findWithCount.mockImplementation(async (args) => { + const threadOf = + args?.where?.threadOf?.$eq ?? + args?.where?.threadOf.toString() ?? + null; + const filtered = mockComments.filter((c) => c.threadOf === threadOf); + return { + results: filtered, + pagination: { total: filtered.length }, + }; }); mockStoreRepository.getConfig.mockResolvedValue([]); @@ -365,16 +376,20 @@ describe('common.service', () => { const service = getService(strapi); const mockComments = [ { id: 1, content: 'Parent 1', threadOf: null, dropBlockedThreads: true, blockedThread: true }, - { id: 2, content: 'Child 1', threadOf: 1, dropBlockedThreads: false }, - { id: 3, content: 'Child 2', threadOf: 1, dropBlockedThreads: false }, - { id: 4, content: 'Grandchild 1', threadOf: 2, dropBlockedThreads: false }, + { id: 2, content: 'Child 1', threadOf: '1', dropBlockedThreads: false }, + { id: 3, content: 'Child 2', threadOf: '1', dropBlockedThreads: false }, + { id: 4, content: 'Grandchild 1', threadOf: '2', dropBlockedThreads: false }, ]; mockCommentRepository.findMany.mockResolvedValue(mockComments); caster(getOrderBy).mockReturnValue(['createdAt', 'desc']); - mockCommentRepository.findWithCount.mockResolvedValue({ - results: mockComments, - pagination: { total: 4 }, + mockCommentRepository.findWithCount.mockImplementation(async (args) => { + const threadOf = args?.where?.threadOf?.$eq ?? null; + const filtered = mockComments.filter((c) => c.threadOf === threadOf); + return { + results: filtered, + pagination: { total: filtered.length }, + }; }); mockStoreRepository.getConfig.mockResolvedValue([]); @@ -606,4 +621,4 @@ describe('common.service', () => { expect(mockCommentRepository.updateMany).toHaveBeenCalled(); }); }); -}); +}); \ No newline at end of file diff --git a/server/src/services/common.service.ts b/server/src/services/common.service.ts index 06d54f6..8687f69 100644 --- a/server/src/services/common.service.ts +++ b/server/src/services/common.service.ts @@ -21,7 +21,7 @@ import PluginError from '../utils/PluginError'; import { client as clientValidator } from '../validators/api'; import { Comment, CommentRelated, CommentWithRelated } from '../validators/repositories'; import { Pagination } from '../validators/repositories/utils'; -import { buildAuthorModel, buildNestedStructure, filterOurResolvedReports, getRelatedGroups } from './utils/functions'; +import { buildAuthorModel, filterOurResolvedReports, getRelatedGroups } from './utils/functions'; const PAGE_SIZE = 10; @@ -33,7 +33,6 @@ type ParsedRelation = { relatedId: string; }; - type Created = PathTo; const commonService = ({ strapi }: StrapiContext) => ({ @@ -71,18 +70,24 @@ const commonService = ({ strapi }: StrapiContext) => ({ }, // Find comments in the flat structure - async findAllFlat({ - fields, - limit, - skip, - sort, - populate, - omit: baseOmit = [], - isAdmin = false, - pagination, - filters = {}, - locale, - }: clientValidator.FindAllFlatSchema, relatedEntity?: any): Promise<{ data: Array, pagination?: Pagination }> { + async findAllFlat( + { + fields, + limit, + skip, + sort, + populate, + omit: baseOmit = [], + isAdmin = false, + pagination, + filters = {}, + locale, + }: clientValidator.FindAllFlatSchema, + relatedEntity?: any, + ): Promise<{ + data: Array; + pagination?: Pagination; + }> { const omit = baseOmit.filter((field) => !REQUIRED_FIELDS.includes(field)); const defaultSelect = (['id', 'related'] as const).filter((field) => !omit.includes(field)); @@ -160,6 +165,81 @@ const commonService = ({ strapi }: StrapiContext) => ({ }; }, + async getCommentsChildren( + { + filters, + populate, + sort, + fields, + isAdmin = false, + omit = [], + locale, + limit, + }: clientValidator.FindAllInHierarchyValidatorSchema, + entry: Comment | CommentWithRelated, + relatedEntity?: any, + dropBlockedThreads = false, + blockNestedThreads = false, + ) { + if (!entry.gotThread) { + return { + ...entry, + threadOf: undefined, + related: undefined, + blockedThread: blockNestedThreads || entry.blockedThread, + children: [], + }; + } + + const children = await this.findAllFlat( + { + filters: { + threadOf: { $eq: entry.id.toString() }, + ...filters, + }, + populate, + sort, + fields, + isAdmin, + omit, + locale, + limit: Infinity, + }, + relatedEntity, + ); + + const allChildren = + entry.blockedThread && dropBlockedThreads + ? [] + : await Promise.all( + children.data.map((child) => + this.getCommentsChildren( + { + filters, + populate, + sort, + fields, + isAdmin, + omit, + locale, + limit, + }, + child, + relatedEntity, + dropBlockedThreads, + ), + ), + ); + + return { + ...entry, + threadOf: undefined, + related: undefined, + blockedThread: blockNestedThreads || entry.blockedThread, + children: allChildren, + }; + }, + // Find comments and create relations tree structure async findAllInHierarchy( { @@ -173,17 +253,51 @@ const commonService = ({ strapi }: StrapiContext) => ({ omit = [], locale, limit, + pagination, }: clientValidator.FindAllInHierarchyValidatorSchema, relatedEntity?: any, ) { - const entities = await this.findAllFlat({ filters, populate, sort, fields, isAdmin, omit, locale, limit }, relatedEntity); - return buildNestedStructure( - entities?.data, - startingFromId, - 'threadOf', - dropBlockedThreads, - false, + const rootEntries = await this.findAllFlat( + { + filters: { + threadOf: startingFromId + ? { $eq: startingFromId.toString() } + : { $null: true }, + ...filters, + }, + pagination, + populate, + sort, + fields, + isAdmin, + omit, + locale, + limit, + }, + relatedEntity, ); + + const rootEntriesWithChildren = await Promise.all( + rootEntries?.data.map((entry) => + this.getCommentsChildren( + { + filters, + populate, + sort, + fields, + isAdmin, + omit, + locale, + limit, + }, + entry, + relatedEntity, + dropBlockedThreads, + ), + ), + ); + + return rootEntriesWithChildren; }, // Find single comment @@ -340,7 +454,7 @@ const commonService = ({ strapi }: StrapiContext) => ({ if (content && isProfane({ testString: content })) { throw new PluginError( 400, - 'Bad language used! Please polite your comment...', + "Bad language used! Please polite your comment...", { content: { original: content, diff --git a/server/src/services/utils/functions.ts b/server/src/services/utils/functions.ts index daba54e..f1f49bf 100644 --- a/server/src/services/utils/functions.ts +++ b/server/src/services/utils/functions.ts @@ -13,45 +13,6 @@ interface StrapiAuthorUser { [key: string]: unknown; } -export const buildNestedStructure = ( - entities: Array, - id: Id | null = null, - field: string = 'threadOf', - dropBlockedThreads = false, - blockNestedThreads = false, -): Array => - entities - .filter((entity: Comment) => { - const entityField: any = get(entity, field); - if (entityField === null && id === null) { - return true; - } - let data = entityField; - if (data && typeof id === 'string') { - data = data.toString(); - } - return ( - (data && data == id) || - (isObject(entityField) && (entityField as any).id === id) - ); - }) - .map((entity: Comment) => ({ - ...entity, - [field]: undefined, - related: undefined, - blockedThread: blockNestedThreads || entity.blockedThread, - children: - entity.blockedThread && dropBlockedThreads - ? [] - : buildNestedStructure( - entities, - entity.id, - field, - dropBlockedThreads, - entity.blockedThread, - ), - })); - export const getRelatedGroups = (related: string): Array => related.split(REGEX.relatedUid).filter((s) => s && s.length > 0); diff --git a/server/src/validators/api/controllers/client.controller.validator.ts b/server/src/validators/api/controllers/client.controller.validator.ts index 655136c..d0bd995 100644 --- a/server/src/validators/api/controllers/client.controller.validator.ts +++ b/server/src/validators/api/controllers/client.controller.validator.ts @@ -84,6 +84,7 @@ const getBaseFindSchema = (enabledCollections: string[]) => { blockedThread: true, approvalStatus: true, isAdminComment: true, + threadOf: true, }); return z .object({ @@ -130,6 +131,7 @@ export const findAllInHierarchyValidator = (enabledCollections: string[], relati skip: true, relation: true, locale: true, + pagination: true, }) .merge(z.object({ startingFromId: z.number().optional(), diff --git a/server/src/validators/utils.ts b/server/src/validators/utils.ts index 55c1ad3..fb09a33 100644 --- a/server/src/validators/utils.ts +++ b/server/src/validators/utils.ts @@ -68,7 +68,7 @@ export const filtersValidator = z.union([ endWithValidators, containsValidators, notContainsValidators, - z.object({ $null: z.string().min(1) }), + z.object({ $null: z.boolean() }), z.object({ $notNull: z.boolean() }), ]);