From b3a76ca141bd867c7bc34f989f6a8783e08f2152 Mon Sep 17 00:00:00 2001 From: batmnkh2344 Date: Wed, 3 Sep 2025 10:20:49 +0800 Subject: [PATCH 01/10] chore: flatten tags & enforce single-level groups --- .../src/commands/migrateContentType.ts | 5 +- backend/core-api/src/commands/migrateTags.ts | 43 ++++ backend/core-api/src/main.ts | 28 +-- .../documents/db/definitions/documents.ts | 2 +- .../core-api/src/modules/tags/@types/tag.ts | 1 + .../src/modules/tags/db/definitions/tags.ts | 26 +-- .../src/modules/tags/db/models/Tags.ts | 198 +++++------------- .../src/modules/tags/graphql/mutations.ts | 14 -- .../src/modules/tags/graphql/queries.ts | 57 +---- .../src/modules/tags/graphql/schemas.ts | 26 +-- backend/core-api/src/modules/tags/utils.ts | 47 ++--- .../src/core-types/modules/tags/tag.ts | 6 +- 12 files changed, 156 insertions(+), 297 deletions(-) create mode 100644 backend/core-api/src/commands/migrateTags.ts diff --git a/backend/core-api/src/commands/migrateContentType.ts b/backend/core-api/src/commands/migrateContentType.ts index 6106bead9b..3291018176 100644 --- a/backend/core-api/src/commands/migrateContentType.ts +++ b/backend/core-api/src/commands/migrateContentType.ts @@ -33,8 +33,7 @@ const command = async () => { const COLLECTIONS = { // IMPORTANT: Do not add collections here unless they have a `type` (contentType) field. // This script will break or have no effect if the collection does not contain `type`. - - tags: db.collection('tags'), + // tags: db.collection('tags'), // Note: 'tags' no longer include a contentType field }; try { @@ -60,6 +59,8 @@ const command = async () => { } console.log(`Process finished at: ${new Date().toISOString()}`); + + process.exit(); }; command(); diff --git a/backend/core-api/src/commands/migrateTags.ts b/backend/core-api/src/commands/migrateTags.ts new file mode 100644 index 0000000000..5dc4c1b3f0 --- /dev/null +++ b/backend/core-api/src/commands/migrateTags.ts @@ -0,0 +1,43 @@ +import * as dotenv from 'dotenv'; + +dotenv.config(); + +import { Collection, Db, MongoClient } from 'mongodb'; + +const { MONGO_URL = 'mongodb://localhost:27017/erxes?directConnection=true' } = + process.env; + +if (!MONGO_URL) { + throw new Error(`Environment variable MONGO_URL not set.`); +} + +const client = new MongoClient(MONGO_URL); + +let db: Db; +let Tags: Collection; + +const command = async () => { + await client.connect(); + db = client.db() as Db; + + Tags = db.collection('tags'); + + try { + await Tags.updateMany( + {}, + { + $unset: { type: '', objectCount: '', order: '', scopeBrandIds: '' }, + $set: { isGroup: false, parentId: '' }, // flatten parentId: no nested parent tags allowed + }, + ); + } catch (e) { + console.log(`Error occurred: ${e.message}`); + await client.close(); + } + + console.log(`Process finished at: ${new Date().toISOString()}`); + + process.exit(); +}; + +command(); diff --git a/backend/core-api/src/main.ts b/backend/core-api/src/main.ts index 894bd48ec4..1254c5784f 100644 --- a/backend/core-api/src/main.ts +++ b/backend/core-api/src/main.ts @@ -49,20 +49,20 @@ const allowedOrigins = [ ...(process.env.ALLOWED_ORIGINS || '').split(',').map((c) => c && RegExp(c)), ]; -const corsOptions = { - credentials: true, - origin: (origin, callback) => { - if (!origin || allowedOrigins.includes(origin.replace(/\/$/, ''))) { - callback(null, true); - } else { - console.error('Origin not allowed:', origin); - callback(new Error('Not allowed by CORS')); - } - }, -}; - -app.use(cors(corsOptions)); -app.options('*', cors(corsOptions)); +// const corsOptions = { +// credentials: true, +// origin: (origin, callback) => { +// if (!origin || allowedOrigins.includes(origin.replace(/\/$/, ''))) { +// callback(null, true); +// } else { +// console.error('Origin not allowed:', origin); +// callback(new Error('Not allowed by CORS')); +// } +// }, +// }; + +// app.use(cors(corsOptions)); +// app.options('*', cors(corsOptions)); app.use(router); const fileLimiter = rateLimit({ diff --git a/backend/core-api/src/modules/documents/db/definitions/documents.ts b/backend/core-api/src/modules/documents/db/definitions/documents.ts index bab5d867a1..71bb4a0357 100644 --- a/backend/core-api/src/modules/documents/db/definitions/documents.ts +++ b/backend/core-api/src/modules/documents/db/definitions/documents.ts @@ -8,7 +8,7 @@ export const documentSchema = new Schema( name: { type: String }, content: { type: String }, replacer: { type: String }, - code: { type: String }, + code: { type: String, optional: true }, }, { timestamps: true, diff --git a/backend/core-api/src/modules/tags/@types/tag.ts b/backend/core-api/src/modules/tags/@types/tag.ts index 99a6529a96..2c17be78f2 100644 --- a/backend/core-api/src/modules/tags/@types/tag.ts +++ b/backend/core-api/src/modules/tags/@types/tag.ts @@ -9,6 +9,7 @@ export interface ITagFilterQueryParams type: string; tagIds?: string[]; parentId?: string; + isGroup?: boolean; ids: string[]; excludeIds: boolean; } diff --git a/backend/core-api/src/modules/tags/db/definitions/tags.ts b/backend/core-api/src/modules/tags/db/definitions/tags.ts index 0efc713887..5415b28064 100644 --- a/backend/core-api/src/modules/tags/db/definitions/tags.ts +++ b/backend/core-api/src/modules/tags/db/definitions/tags.ts @@ -5,26 +5,11 @@ export const tagSchema = schemaWrapper( new Schema( { _id: mongooseStringRandomId, - name: { type: String, label: 'Name' }, - type: { - type: String, - label: 'Type', - index: true, - }, + name: { type: String, label: 'Name', unique: true }, colorCode: { type: String, label: 'Color code' }, - objectCount: { type: Number, label: 'Object count' }, - order: { type: String, label: 'Order', index: true }, - parentId: { - type: String, - optional: true, - index: true, - label: 'Parent', - }, - relatedIds: { - type: [String], - optional: true, - label: 'Children tag ids', - }, + parentId: { type: String, label: 'Parent' }, + relatedIds: { type: [String], label: 'Children tag ids' }, + isGroup: { type: Boolean, label: 'Is group', default: false }, }, { timestamps: true, @@ -32,5 +17,4 @@ export const tagSchema = schemaWrapper( ), ); -// for tags query. increases search speed, avoids in-memory sorting -tagSchema.index({ _id: 1, type: 1, order: 1, name: 1, createdAt: 1 }); +tagSchema.index({ _id: 1, name: 1, parentId: 1 }); diff --git a/backend/core-api/src/modules/tags/db/models/Tags.ts b/backend/core-api/src/modules/tags/db/models/Tags.ts index 3503765656..55eb062f67 100644 --- a/backend/core-api/src/modules/tags/db/models/Tags.ts +++ b/backend/core-api/src/modules/tags/db/models/Tags.ts @@ -1,9 +1,8 @@ -import { escapeRegExp } from 'erxes-api-shared/utils'; +import { tagSchema } from '@/tags/db/definitions/tags'; +import { removeRelatedTagIds, setRelatedTagIds } from '@/tags/utils'; import { ITag, ITagDocument } from 'erxes-api-shared/core-types'; import { Model } from 'mongoose'; import { IModels } from '~/connectionResolvers'; -import { removeRelatedTagIds, setRelatedTagIds } from '@/tags/utils'; -import { tagSchema } from '@/tags/db/definitions/tags'; export interface ITagModel extends Model { getTag(_id: string): Promise; createTag(doc: ITag): Promise; @@ -13,9 +12,40 @@ export interface ITagModel extends Model { export const loadTagClass = (models: IModels) => { class Tag { - /* - * Get a tag - */ + public static async validate(_id: string | null, doc: ITag) { + const { name, parentId, isGroup } = doc; + + const tag = await models.Tags.findOne({ name }); + + if (tag && tag._id !== _id) { + throw new Error('There is already a tag with this name'); + } + + if (parentId) { + const parentTag = await models.Tags.findOne({ _id: parentId }); + + if (!parentTag?.isGroup) { + throw new Error('Parent tag must be a group'); + } + } + + if (isGroup && parentId) { + throw new Error('Group tag cannot have parent tag'); + } + + if (_id) { + const existingTag = await models.Tags.findOne({ _id }); + + if (isGroup && parentId) { + throw new Error('Group tag cannot have parent tag'); + } + + if (!existingTag?.isGroup && isGroup && existingTag?.parentId) { + throw new Error('Cannot convert a nested tag into a group'); + } + } + } + public static async getTag(_id: string) { const tag = await models.Tags.findOne({ _id }); @@ -26,178 +56,48 @@ export const loadTagClass = (models: IModels) => { return tag; } - /** - * Create a tag - */ public static async createTag(doc: ITag) { - const isUnique = await this.validateUniqueness(null, doc.name, doc.type); - - if (!isUnique) { - throw new Error('Tag duplicated'); - } + await this.validate(null, doc); - const parentTag = await this.getParentTag(doc); - - // Generatingg order - const order = await this.generateOrder(parentTag, doc); - - const tag = await models.Tags.create({ - ...doc, - order, - createdAt: new Date(), - }); + const tag = await models.Tags.create(doc); await setRelatedTagIds(models, tag); return tag; } - /** - * Update Tag - */ public static async updateTag(_id: string, doc: ITag) { - const isUnique = await this.validateUniqueness( - { _id }, - doc.name, - doc.type, - ); - - if (!isUnique) { - throw new Error('Tag duplicated'); - } - - const parentTag = await this.getParentTag(doc); - - if (parentTag && parentTag.parentId === _id) { - throw new Error('Cannot change tag'); - } + await this.validate(_id, doc); const tag = await models.Tags.getTag(_id); - // Generatingg order - const order = await this.generateOrder(parentTag, doc); - - const childTags = await models.Tags.find({ - $and: [ - { order: { $regex: new RegExp(escapeRegExp(tag.order || ''), 'i') } }, - { _id: { $ne: _id } }, - ], + const updated = await models.Tags.findOneAndUpdate({ _id }, doc, { + new: true, }); - if (childTags.length > 0) { - const bulkDoc: Array<{ - updateOne: { - filter: { _id: string }; - update: { $set: { order: string } }; - }; - }> = []; - - // updating child tag order - childTags.forEach((childTag) => { - let childOrder = childTag.order || ''; - - childOrder = childOrder.replace(tag.order || '', order); - - bulkDoc.push({ - updateOne: { - filter: { _id: childTag._id }, - update: { $set: { order: childOrder } }, - }, - }); - }); - - await models.Tags.bulkWrite(bulkDoc); - - await removeRelatedTagIds(models, tag); - } - - await models.Tags.updateOne({ _id }, { $set: { ...doc, order } }); - - const updated = await models.Tags.findOne({ _id }); - if (updated) { - await setRelatedTagIds(models, updated); + await setRelatedTagIds(models, tag); } return updated; } - /** - * Remove Tag - */ public static async removeTag(_id: string) { const tag = await models.Tags.getTag(_id); - const childCount = await models.Tags.countDocuments({ - parentId: _id, - }); + const childTagIds = await models.Tags.find({ parentId: _id }).distinct( + '_id', + ); - if (childCount > 0) { - throw new Error('Please remove child tags first'); - } + await models.Tags.updateMany( + { _id: { $in: childTagIds } }, + { $unset: { parentId: 1 } }, + ); await removeRelatedTagIds(models, tag); return models.Tags.deleteOne({ _id }); } - - /* - * Validates tag uniquness - */ - public static async validateUniqueness( - selector: any, - name: string, - type: string, - ): Promise { - // required name and type - if (!name || !type) { - return true; - } - - // can't update name & type same time more than one tags. - const count = await models.Tags.countDocuments(selector); - - if (selector && count > 1) { - return false; - } - - const obj = selector && (await models.Tags.findOne(selector)); - - const filter: any = { name, type }; - - if (obj) { - filter._id = { $ne: obj._id }; - } - - const existing = await models.Tags.findOne(filter); - - if (existing) { - return false; - } - - return true; - } - - /* - * Get a parent tag - */ - static async getParentTag(doc: ITag) { - return models.Tags.findOne({ - _id: doc.parentId, - }).lean(); - } - - /** - * Generating order - */ - public static async generateOrder( - parentTag: ITagDocument | null, - { name }: { name: string }, - ) { - const order = parentTag ? `${parentTag.order}${name}/` : `${name}/`; - - return order; - } } tagSchema.loadClass(Tag); diff --git a/backend/core-api/src/modules/tags/graphql/mutations.ts b/backend/core-api/src/modules/tags/graphql/mutations.ts index 7b837ad8fe..e2ab2ac680 100644 --- a/backend/core-api/src/modules/tags/graphql/mutations.ts +++ b/backend/core-api/src/modules/tags/graphql/mutations.ts @@ -96,18 +96,4 @@ export const tagMutations = { ) { return models.Tags.removeTag(_id); }, - - /** - * Merge tags - */ - async tagsMerge( - _parent: undefined, - { sourceId, destId }: { sourceId: string; destId: string }, - { models }: IContext, - ) { - // remove old tag - await models.Tags.removeTag(sourceId); - - return models.Tags.getTag(destId); - }, }; diff --git a/backend/core-api/src/modules/tags/graphql/queries.ts b/backend/core-api/src/modules/tags/graphql/queries.ts index 35fc787769..48db42ff94 100644 --- a/backend/core-api/src/modules/tags/graphql/queries.ts +++ b/backend/core-api/src/modules/tags/graphql/queries.ts @@ -1,39 +1,28 @@ import { ITagFilterQueryParams } from '@/tags/@types/tag'; -import { cursorPaginate, getPlugin, getPlugins } from 'erxes-api-shared/utils'; +import { cursorPaginate } from 'erxes-api-shared/utils'; import { FilterQuery } from 'mongoose'; import { IContext } from '~/connectionResolvers'; -import { getContentTypes } from '../utils'; const generateFilter = async ({ params, commonQuerySelector, models }) => { - const { type, searchValue, tagIds, parentId, ids, excludeIds } = params; + const { searchValue, parentId, ids, excludeIds, isGroup } = params; const filter: FilterQuery = { ...commonQuerySelector }; - if (type) { - const [serviceName, contentType] = type.split(':'); - - if (contentType === 'all') { - const contentTypes: string[] = await getContentTypes(serviceName); - filter.type = { $in: contentTypes }; - } else { - filter.type = type; - } - } - if (searchValue) { filter.name = new RegExp(`.*${searchValue}.*`, 'i'); } - if (tagIds) { - filter._id = { $in: tagIds }; + if (ids?.length) { + filter._id = { [excludeIds ? '$nin' : '$in']: ids }; } - if (ids && ids.length > 0) { - filter._id = { [excludeIds ? '$nin' : '$in']: ids }; + if (isGroup) { + filter.isGroup = isGroup; } if (parentId) { const parentTag = await models.Tags.find({ parentId }).distinct('_id'); + let ids = [parentId, ...parentTag]; const getChildTags = async (parentTagIds: string[]) => { @@ -56,33 +45,6 @@ const generateFilter = async ({ params, commonQuerySelector, models }) => { }; export const tagQueries = { - /** - * Get tag types - */ - async tagsGetTypes() { - const services = await getPlugins(); - - const fieldTypes: Array<{ description: string; contentType: string }> = []; - - for (const serviceName of services) { - const service = await getPlugin(serviceName); - const meta = service.config.meta || {}; - - if (meta.tags) { - const types = meta.tags.types || []; - - for (const type of types) { - fieldTypes.push({ - description: type.description, - contentType: `${serviceName}:${type.type}`, - }); - } - } - } - - return fieldTypes; - }, - /** * Get tags */ @@ -130,14 +92,11 @@ export const tagQueries = { return models.Tags.countDocuments(selector); }, - /** - * Get one tag - */ async tagDetail( _parent: undefined, { _id }: { _id: string }, { models }: IContext, ) { - return models.Tags.findOne({ _id }); + return models.Tags.getTag(_id); }, }; diff --git a/backend/core-api/src/modules/tags/graphql/schemas.ts b/backend/core-api/src/modules/tags/graphql/schemas.ts index ec1b467f4e..59b1344950 100644 --- a/backend/core-api/src/modules/tags/graphql/schemas.ts +++ b/backend/core-api/src/modules/tags/graphql/schemas.ts @@ -1,19 +1,15 @@ import { GQL_CURSOR_PARAM_DEFS } from 'erxes-api-shared/utils'; export const types = ` - type Tag @key(fields: "_id") @cacheControl(maxAge: 3) { + type Tag @key(fields: "_id") @cacheControl(maxAge: 3) { _id: String name: String - type: String colorCode: String - createdAt: Date - objectCount: Int - totalObjectCount: Int parentId: String - order: String relatedIds: [String] - - cursor: String + isGroup: Boolean + + createdAt: Date } type TagsListResponse { @@ -24,12 +20,11 @@ export const types = ` `; const queryParams = ` - type: String, searchValue: String, - tagIds: [String], parentId: String, ids: [String], excludeIds: Boolean, + isGroup: Boolean, sortField: String, @@ -37,23 +32,20 @@ const queryParams = ` `; export const queries = ` - tagsGetTypes: [JSON] tags(${queryParams}): TagsListResponse tagDetail(_id: String!): Tag tagsQueryCount(type: String, searchValue: String): Int `; const mutationParams = ` - name: String!, - type: String!, colorCode: String, parentId: String, + isGroup: Boolean, `; export const mutations = ` - tagsAdd(${mutationParams}): Tag - tagsEdit(_id: String!, ${mutationParams}): Tag + tagsAdd(name: String!, ${mutationParams}): Tag + tagsEdit(_id: String!, name: String, ${mutationParams}): Tag + tagsTag(targetIds: [String!]!, tagIds: [String!]!): JSON tagsRemove(_id: String!): JSON - tagsTag(type: String!, targetIds: [String!]!, tagIds: [String!]!): JSON - tagsMerge(sourceId: String!, destId: String!): Tag `; diff --git a/backend/core-api/src/modules/tags/utils.ts b/backend/core-api/src/modules/tags/utils.ts index 5d4090b98b..a5b0d56081 100644 --- a/backend/core-api/src/modules/tags/utils.ts +++ b/backend/core-api/src/modules/tags/utils.ts @@ -1,33 +1,35 @@ import { ITagDocument } from 'erxes-api-shared/core-types'; -import { getPlugin } from 'erxes-api-shared/utils'; import { IModels } from '~/connectionResolvers'; // set related tags export const setRelatedTagIds = async (models: IModels, tag: ITagDocument) => { - if (tag.parentId) { - const parentTag = await models.Tags.findOne({ _id: tag.parentId }); + if (!tag.parentId) { + return; + } - if (parentTag) { - let relatedIds: string[]; + const parentTag = await models.Tags.findOne({ _id: tag.parentId }); - relatedIds = tag.relatedIds || []; - relatedIds.push(tag._id); + if (!parentTag) { + return; + } - relatedIds = [ - ...new Set([...relatedIds, ...(parentTag.relatedIds || [])]), - ]; + const relatedIds: string[] = [tag._id, ...(tag.relatedIds || [])]; - await models.Tags.updateOne( - { _id: parentTag._id }, - { $set: { relatedIds } }, - ); + await models.Tags.updateOne( + { _id: parentTag._id }, + { + $set: { + relatedIds: [ + ...new Set([...relatedIds, ...(parentTag.relatedIds || [])]), + ], + }, + }, + ); - const updated = await models.Tags.findOne({ _id: tag.parentId }); + const updated = await models.Tags.findOne({ _id: tag.parentId }); - if (updated) { - await setRelatedTagIds(models, updated); - } - } + if (updated) { + await setRelatedTagIds(models, updated); } }; @@ -66,10 +68,3 @@ export const removeRelatedTagIds = async ( await models.Tags.bulkWrite(doc); }; - -export const getContentTypes = async (serviceName) => { - const service = await getPlugin(serviceName); - const meta = service.config.meta || {}; - const types = (meta.tags && meta.tags.types) || []; - return types.map((type) => `${serviceName}:${type.type}`); -}; diff --git a/backend/erxes-api-shared/src/core-types/modules/tags/tag.ts b/backend/erxes-api-shared/src/core-types/modules/tags/tag.ts index fee6b11e46..4225289d96 100644 --- a/backend/erxes-api-shared/src/core-types/modules/tags/tag.ts +++ b/backend/erxes-api-shared/src/core-types/modules/tags/tag.ts @@ -2,15 +2,13 @@ import { Document } from 'mongoose'; export interface ITag { name: string; - type: string; colorCode?: string; - objectCount?: number; parentId?: string; + relatedIds?: string[]; + isGroup?: boolean; } export interface ITagDocument extends ITag, Document { _id: string; createdAt: Date; - order?: string; - relatedIds?: string[]; } From a28c2cafae2f82a276727d634052f49724ccd5bb Mon Sep 17 00:00:00 2001 From: batmnkh2344 Date: Wed, 3 Sep 2025 10:22:07 +0800 Subject: [PATCH 02/10] chore: undo temp commit --- backend/core-api/src/main.ts | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/backend/core-api/src/main.ts b/backend/core-api/src/main.ts index 1254c5784f..894bd48ec4 100644 --- a/backend/core-api/src/main.ts +++ b/backend/core-api/src/main.ts @@ -49,20 +49,20 @@ const allowedOrigins = [ ...(process.env.ALLOWED_ORIGINS || '').split(',').map((c) => c && RegExp(c)), ]; -// const corsOptions = { -// credentials: true, -// origin: (origin, callback) => { -// if (!origin || allowedOrigins.includes(origin.replace(/\/$/, ''))) { -// callback(null, true); -// } else { -// console.error('Origin not allowed:', origin); -// callback(new Error('Not allowed by CORS')); -// } -// }, -// }; - -// app.use(cors(corsOptions)); -// app.options('*', cors(corsOptions)); +const corsOptions = { + credentials: true, + origin: (origin, callback) => { + if (!origin || allowedOrigins.includes(origin.replace(/\/$/, ''))) { + callback(null, true); + } else { + console.error('Origin not allowed:', origin); + callback(new Error('Not allowed by CORS')); + } + }, +}; + +app.use(cors(corsOptions)); +app.options('*', cors(corsOptions)); app.use(router); const fileLimiter = rateLimit({ From ac262e5405ef985f1c9ca33d0e428dd5ee06a721 Mon Sep 17 00:00:00 2001 From: batmnkh2344 Date: Wed, 3 Sep 2025 10:26:57 +0800 Subject: [PATCH 03/10] review changes --- backend/core-api/src/commands/migrateContentType.ts | 1 + backend/core-api/src/commands/migrateTags.ts | 1 + backend/core-api/src/modules/tags/db/models/Tags.ts | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/core-api/src/commands/migrateContentType.ts b/backend/core-api/src/commands/migrateContentType.ts index 3291018176..5d4f10f0f4 100644 --- a/backend/core-api/src/commands/migrateContentType.ts +++ b/backend/core-api/src/commands/migrateContentType.ts @@ -60,6 +60,7 @@ const command = async () => { console.log(`Process finished at: ${new Date().toISOString()}`); + await client.close(); process.exit(); }; diff --git a/backend/core-api/src/commands/migrateTags.ts b/backend/core-api/src/commands/migrateTags.ts index 5dc4c1b3f0..ad6fb7d449 100644 --- a/backend/core-api/src/commands/migrateTags.ts +++ b/backend/core-api/src/commands/migrateTags.ts @@ -37,6 +37,7 @@ const command = async () => { console.log(`Process finished at: ${new Date().toISOString()}`); + await client.close(); process.exit(); }; diff --git a/backend/core-api/src/modules/tags/db/models/Tags.ts b/backend/core-api/src/modules/tags/db/models/Tags.ts index 55eb062f67..eb9d6d57c9 100644 --- a/backend/core-api/src/modules/tags/db/models/Tags.ts +++ b/backend/core-api/src/modules/tags/db/models/Tags.ts @@ -76,7 +76,7 @@ export const loadTagClass = (models: IModels) => { }); if (updated) { - await setRelatedTagIds(models, tag); + await setRelatedTagIds(models, updated); } return updated; From f83ea3c830d17508b29a73065b4bf1c431af5411 Mon Sep 17 00:00:00 2001 From: batmnkh2344 Date: Wed, 3 Sep 2025 15:42:09 +0800 Subject: [PATCH 04/10] chore: expand content type for specific instance --- .../src/commands/migrateContentType.ts | 3 +- backend/core-api/src/commands/migrateTags.ts | 2 +- .../src/modules/tags/db/definitions/tags.ts | 3 +- .../src/modules/tags/db/models/Tags.ts | 57 +++++++++++++------ .../src/modules/tags/graphql/queries.ts | 14 ++++- .../src/modules/tags/graphql/schemas.ts | 4 ++ .../src/core-types/modules/tags/tag.ts | 1 + 7 files changed, 64 insertions(+), 20 deletions(-) diff --git a/backend/core-api/src/commands/migrateContentType.ts b/backend/core-api/src/commands/migrateContentType.ts index 5d4f10f0f4..bedb50fef7 100644 --- a/backend/core-api/src/commands/migrateContentType.ts +++ b/backend/core-api/src/commands/migrateContentType.ts @@ -33,7 +33,8 @@ const command = async () => { const COLLECTIONS = { // IMPORTANT: Do not add collections here unless they have a `type` (contentType) field. // This script will break or have no effect if the collection does not contain `type`. - // tags: db.collection('tags'), // Note: 'tags' no longer include a contentType field + + tags: db.collection('tags'), }; try { diff --git a/backend/core-api/src/commands/migrateTags.ts b/backend/core-api/src/commands/migrateTags.ts index ad6fb7d449..cd686f73f5 100644 --- a/backend/core-api/src/commands/migrateTags.ts +++ b/backend/core-api/src/commands/migrateTags.ts @@ -26,7 +26,7 @@ const command = async () => { await Tags.updateMany( {}, { - $unset: { type: '', objectCount: '', order: '', scopeBrandIds: '' }, + $unset: { objectCount: '', order: '', scopeBrandIds: '' }, $set: { isGroup: false, parentId: '' }, // flatten parentId: no nested parent tags allowed }, ); diff --git a/backend/core-api/src/modules/tags/db/definitions/tags.ts b/backend/core-api/src/modules/tags/db/definitions/tags.ts index 5415b28064..f57ab959b1 100644 --- a/backend/core-api/src/modules/tags/db/definitions/tags.ts +++ b/backend/core-api/src/modules/tags/db/definitions/tags.ts @@ -10,6 +10,7 @@ export const tagSchema = schemaWrapper( parentId: { type: String, label: 'Parent' }, relatedIds: { type: [String], label: 'Children tag ids' }, isGroup: { type: Boolean, label: 'Is group', default: false }, + type: { type: String, label: 'Content type' }, }, { timestamps: true, @@ -17,4 +18,4 @@ export const tagSchema = schemaWrapper( ), ); -tagSchema.index({ _id: 1, name: 1, parentId: 1 }); +tagSchema.index({ _id: 1, name: 1, parentId: 1, type: 1 }); diff --git a/backend/core-api/src/modules/tags/db/models/Tags.ts b/backend/core-api/src/modules/tags/db/models/Tags.ts index eb9d6d57c9..1398762616 100644 --- a/backend/core-api/src/modules/tags/db/models/Tags.ts +++ b/backend/core-api/src/modules/tags/db/models/Tags.ts @@ -15,33 +15,48 @@ export const loadTagClass = (models: IModels) => { public static async validate(_id: string | null, doc: ITag) { const { name, parentId, isGroup } = doc; - const tag = await models.Tags.findOne({ name }); + const tag = await models.Tags.findOne({ + $or: [{ _id }, { name }], + }); + + if (tag?.name === name) { + throw new Error(`A tag named ${name} already exists`); + } - if (tag && tag._id !== _id) { - throw new Error('There is already a tag with this name'); + if (tag?.isGroup && isGroup) { + throw new Error('Nested group is not allowed 1'); + } + + if (_id === parentId) { + throw new Error('Group cannot be itself'); } if (parentId) { const parentTag = await models.Tags.findOne({ _id: parentId }); - if (!parentTag?.isGroup) { + if (!parentTag) { + throw new Error('Group not found'); + } + + if (!parentTag.isGroup) { throw new Error('Parent tag must be a group'); } - } - if (isGroup && parentId) { - throw new Error('Group tag cannot have parent tag'); + if ((isGroup || tag?.isGroup) && parentTag?.isGroup) { + throw new Error('Nested group is not allowed 2 '); + } } - if (_id) { - const existingTag = await models.Tags.findOne({ _id }); + if (tag) { + const parentTag = await models.Tags.findOne({ _id: tag.parentId }); + const childTags = await models.Tags.find({ parentId: tag._id }); - if (isGroup && parentId) { - throw new Error('Group tag cannot have parent tag'); + if (parentTag?.isGroup && isGroup) { + throw new Error('Nested group is not allowed 3'); } - if (!existingTag?.isGroup && isGroup && existingTag?.parentId) { - throw new Error('Cannot convert a nested tag into a group'); + if (!isGroup && childTags.length) { + throw new Error('Group has tags'); } } } @@ -71,9 +86,19 @@ export const loadTagClass = (models: IModels) => { const tag = await models.Tags.getTag(_id); - const updated = await models.Tags.findOneAndUpdate({ _id }, doc, { - new: true, - }); + const childTags = await models.Tags.find({ parentId: tag._id }); + + if (childTags.length) { + await removeRelatedTagIds(models, tag); + } + + const updated = await models.Tags.findOneAndUpdate( + { _id: tag._id }, + doc, + { + new: true, + }, + ); if (updated) { await setRelatedTagIds(models, updated); diff --git a/backend/core-api/src/modules/tags/graphql/queries.ts b/backend/core-api/src/modules/tags/graphql/queries.ts index 48db42ff94..1b32be211c 100644 --- a/backend/core-api/src/modules/tags/graphql/queries.ts +++ b/backend/core-api/src/modules/tags/graphql/queries.ts @@ -4,10 +4,22 @@ import { FilterQuery } from 'mongoose'; import { IContext } from '~/connectionResolvers'; const generateFilter = async ({ params, commonQuerySelector, models }) => { - const { searchValue, parentId, ids, excludeIds, isGroup } = params; + const { searchValue, parentId, ids, excludeIds, isGroup, type } = params; const filter: FilterQuery = { ...commonQuerySelector }; + if (type) { + let contentType = type; + + const [_pluginName, _moduleName, instanceId] = contentType.split(':'); + + if (!instanceId && params.instanceId) { + contentType = `${contentType}:${params.instanceId}`; + } + + filter.type = contentType; + } + if (searchValue) { filter.name = new RegExp(`.*${searchValue}.*`, 'i'); } diff --git a/backend/core-api/src/modules/tags/graphql/schemas.ts b/backend/core-api/src/modules/tags/graphql/schemas.ts index 59b1344950..2b1bb99329 100644 --- a/backend/core-api/src/modules/tags/graphql/schemas.ts +++ b/backend/core-api/src/modules/tags/graphql/schemas.ts @@ -8,6 +8,7 @@ export const types = ` parentId: String relatedIds: [String] isGroup: Boolean + type: String createdAt: Date } @@ -20,11 +21,13 @@ export const types = ` `; const queryParams = ` + type: String, searchValue: String, parentId: String, ids: [String], excludeIds: Boolean, isGroup: Boolean, + instanceId: String, sortField: String, @@ -38,6 +41,7 @@ export const queries = ` `; const mutationParams = ` + type: String, colorCode: String, parentId: String, isGroup: Boolean, diff --git a/backend/erxes-api-shared/src/core-types/modules/tags/tag.ts b/backend/erxes-api-shared/src/core-types/modules/tags/tag.ts index 4225289d96..aeca850e7d 100644 --- a/backend/erxes-api-shared/src/core-types/modules/tags/tag.ts +++ b/backend/erxes-api-shared/src/core-types/modules/tags/tag.ts @@ -6,6 +6,7 @@ export interface ITag { parentId?: string; relatedIds?: string[]; isGroup?: boolean; + type?: string; } export interface ITagDocument extends ITag, Document { From 791ae67cf0ada31e59d3c89fb0a182ac903200b7 Mon Sep 17 00:00:00 2001 From: batmnkh2344 Date: Wed, 3 Sep 2025 15:58:13 +0800 Subject: [PATCH 05/10] chore: review change --- backend/core-api/src/modules/tags/db/models/Tags.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/core-api/src/modules/tags/db/models/Tags.ts b/backend/core-api/src/modules/tags/db/models/Tags.ts index 1398762616..56d37dfe30 100644 --- a/backend/core-api/src/modules/tags/db/models/Tags.ts +++ b/backend/core-api/src/modules/tags/db/models/Tags.ts @@ -17,7 +17,7 @@ export const loadTagClass = (models: IModels) => { const tag = await models.Tags.findOne({ $or: [{ _id }, { name }], - }); + }).lean(); if (tag?.name === name) { throw new Error(`A tag named ${name} already exists`); @@ -27,12 +27,12 @@ export const loadTagClass = (models: IModels) => { throw new Error('Nested group is not allowed 1'); } - if (_id === parentId) { + if (String(_id) === String(parentId)) { throw new Error('Group cannot be itself'); } if (parentId) { - const parentTag = await models.Tags.findOne({ _id: parentId }); + const parentTag = await models.Tags.findOne({ _id: parentId }).lean(); if (!parentTag) { throw new Error('Group not found'); @@ -48,8 +48,8 @@ export const loadTagClass = (models: IModels) => { } if (tag) { - const parentTag = await models.Tags.findOne({ _id: tag.parentId }); - const childTags = await models.Tags.find({ parentId: tag._id }); + const parentTag = await models.Tags.findOne({ _id: tag.parentId }).lean(); + const childTags = await models.Tags.find({ parentId: tag._id }).lean(); if (parentTag?.isGroup && isGroup) { throw new Error('Nested group is not allowed 3'); From d972adfbc633dafb8df841820f4ed303137cb267 Mon Sep 17 00:00:00 2001 From: batmnkh2344 Date: Tue, 9 Sep 2025 11:54:08 +0800 Subject: [PATCH 06/10] chore: restore objectCount field & prevent group tags from being tagged --- backend/core-api/src/commands/migrateTags.ts | 2 +- .../src/modules/contacts/trpc/customer.ts | 28 ++++++++++++++++++ .../src/modules/tags/db/definitions/tags.ts | 1 + .../tags/graphql/customResolvers/tag.ts | 24 ++++++++++++++- .../src/modules/tags/graphql/mutations.ts | 11 +++---- .../src/modules/tags/graphql/queries.ts | 25 +++++++++++++++- .../src/modules/tags/graphql/schemas.ts | 4 +++ backend/core-api/src/modules/tags/utils.ts | 29 +++++++++++++++++++ .../src/core-types/modules/tags/tag.ts | 3 +- .../tags/graphql/queries/tagsQueries.tsx | 7 +++-- 10 files changed, 122 insertions(+), 12 deletions(-) diff --git a/backend/core-api/src/commands/migrateTags.ts b/backend/core-api/src/commands/migrateTags.ts index cd686f73f5..bcb1f2fcc8 100644 --- a/backend/core-api/src/commands/migrateTags.ts +++ b/backend/core-api/src/commands/migrateTags.ts @@ -26,7 +26,7 @@ const command = async () => { await Tags.updateMany( {}, { - $unset: { objectCount: '', order: '', scopeBrandIds: '' }, + $unset: { order: '', scopeBrandIds: '' }, $set: { isGroup: false, parentId: '' }, // flatten parentId: no nested parent tags allowed }, ); diff --git a/backend/core-api/src/modules/contacts/trpc/customer.ts b/backend/core-api/src/modules/contacts/trpc/customer.ts index 9883abf68b..25aac5ca81 100644 --- a/backend/core-api/src/modules/contacts/trpc/customer.ts +++ b/backend/core-api/src/modules/contacts/trpc/customer.ts @@ -223,5 +223,33 @@ export const customerRouter = t.router({ data: doc, }); }), + + tag: t.procedure.input(z.any()).mutation(async ({ ctx, input }) => { + const { action, _ids, tagIds, targetIds } = input; + const { models } = ctx; + + let response = {}; + + console.log('input', input); + + if (action === 'count') { + response = await models.Customers.countDocuments({ + tagIds: { $in: _ids }, + }); + } + + if (action === 'tagObject') { + await models.Customers.updateMany( + { _id: { $in: targetIds } }, + { $set: { tagIds } }, + ); + + response = await models.Customers.find({ + _id: { $in: targetIds }, + }).lean(); + } + + return response; + }), }), }); diff --git a/backend/core-api/src/modules/tags/db/definitions/tags.ts b/backend/core-api/src/modules/tags/db/definitions/tags.ts index f57ab959b1..9eb2399233 100644 --- a/backend/core-api/src/modules/tags/db/definitions/tags.ts +++ b/backend/core-api/src/modules/tags/db/definitions/tags.ts @@ -11,6 +11,7 @@ export const tagSchema = schemaWrapper( relatedIds: { type: [String], label: 'Children tag ids' }, isGroup: { type: Boolean, label: 'Is group', default: false }, type: { type: String, label: 'Content type' }, + objectCount: { type: Number, label: 'Object count' }, }, { timestamps: true, diff --git a/backend/core-api/src/modules/tags/graphql/customResolvers/tag.ts b/backend/core-api/src/modules/tags/graphql/customResolvers/tag.ts index b32449202d..d020d643a0 100644 --- a/backend/core-api/src/modules/tags/graphql/customResolvers/tag.ts +++ b/backend/core-api/src/modules/tags/graphql/customResolvers/tag.ts @@ -1,7 +1,29 @@ +import { ITagDocument } from 'erxes-api-shared/core-types'; import { IContext } from '~/connectionResolvers'; +import { countDocuments } from '~/modules/tags/utils'; export default { - async __resolveReference({ _id }, { models }: IContext) { + async __resolveReference({ _id }: { _id: string }, { models }: IContext) { return models.Tags.findOne({ _id }); }, + + async totalObjectCount( + tag: ITagDocument, + _args: undefined, + { subdomain }: IContext, + ) { + if (tag.relatedIds && tag.relatedIds.length > 0) { + const tagIds = tag.relatedIds.concat(tag._id); + + return countDocuments(subdomain, tag.type, tagIds); + } + }, + + async objectCount( + tag: ITagDocument, + _args: undefined, + { subdomain }: IContext, + ) { + return countDocuments(subdomain, tag.type, [tag._id]); + }, }; diff --git a/backend/core-api/src/modules/tags/graphql/mutations.ts b/backend/core-api/src/modules/tags/graphql/mutations.ts index e2ab2ac680..823166575c 100644 --- a/backend/core-api/src/modules/tags/graphql/mutations.ts +++ b/backend/core-api/src/modules/tags/graphql/mutations.ts @@ -41,12 +41,13 @@ export const tagMutations = { ); } - const existingTagsCount = await models.Tags.countDocuments({ - _id: { $in: tagIds }, + const tags = await models.Tags.find({ type, + _id: { $in: tagIds }, + isGroup: false, }); - if (existingTagsCount !== tagIds.length) { + if (tags.length !== tagIds.length) { throw new Error('Tag not found.'); } @@ -68,7 +69,7 @@ export const tagMutations = { return await model.updateMany( { _id: { $in: targetIds } }, - { $set: { tagIds } }, + { $set: { tagIds: tags.map((tag) => tag._id) } }, ); } @@ -78,7 +79,7 @@ export const tagMutations = { module: moduleName, action: 'tag', input: { - tagIds, + tagIds: tags.map((tag) => tag._id), targetIds, type: moduleName, action: 'tagObject', diff --git a/backend/core-api/src/modules/tags/graphql/queries.ts b/backend/core-api/src/modules/tags/graphql/queries.ts index 1b32be211c..e60f14a036 100644 --- a/backend/core-api/src/modules/tags/graphql/queries.ts +++ b/backend/core-api/src/modules/tags/graphql/queries.ts @@ -1,5 +1,5 @@ import { ITagFilterQueryParams } from '@/tags/@types/tag'; -import { cursorPaginate } from 'erxes-api-shared/utils'; +import { cursorPaginate, getPlugin, getPlugins } from 'erxes-api-shared/utils'; import { FilterQuery } from 'mongoose'; import { IContext } from '~/connectionResolvers'; @@ -57,6 +57,29 @@ const generateFilter = async ({ params, commonQuerySelector, models }) => { }; export const tagQueries = { + /** + * Get tags types + */ + async tagsGetTypes() { + const services = await getPlugins(); + const fieldTypes: Array<{ description: string; contentType: string }> = []; + for (const serviceName of services) { + const service = await getPlugin(serviceName); + const meta = service.config.meta || {}; + if (meta && meta.tags) { + const types = meta.tags.types || []; + + for (const type of types) { + fieldTypes.push({ + description: type.description, + contentType: `${serviceName}:${type.type}`, + }); + } + } + } + + return fieldTypes; + }, /** * Get tags */ diff --git a/backend/core-api/src/modules/tags/graphql/schemas.ts b/backend/core-api/src/modules/tags/graphql/schemas.ts index 2b1bb99329..54892acf11 100644 --- a/backend/core-api/src/modules/tags/graphql/schemas.ts +++ b/backend/core-api/src/modules/tags/graphql/schemas.ts @@ -10,6 +10,9 @@ export const types = ` isGroup: Boolean type: String + objectCount: Int + totalObjectCount: Int + createdAt: Date } @@ -35,6 +38,7 @@ const queryParams = ` `; export const queries = ` + tagsGetTypes: [JSON] tags(${queryParams}): TagsListResponse tagDetail(_id: String!): Tag tagsQueryCount(type: String, searchValue: String): Int diff --git a/backend/core-api/src/modules/tags/utils.ts b/backend/core-api/src/modules/tags/utils.ts index a5b0d56081..bbe6d13e51 100644 --- a/backend/core-api/src/modules/tags/utils.ts +++ b/backend/core-api/src/modules/tags/utils.ts @@ -1,4 +1,5 @@ import { ITagDocument } from 'erxes-api-shared/core-types'; +import { isEnabled, sendTRPCMessage } from 'erxes-api-shared/utils'; import { IModels } from '~/connectionResolvers'; // set related tags @@ -68,3 +69,31 @@ export const removeRelatedTagIds = async ( await models.Tags.bulkWrite(doc); }; + +export const countDocuments = async ( + subdomain: string, + type: string, + _ids: string[], +) => { + const [pluginName, moduleName] = type.split(':'); + + if (!isEnabled(pluginName)) { + return 0; + } + + const MODULE_NAMES = { + customer: 'customers', + }; + + return await sendTRPCMessage({ + pluginName, + method: 'mutation', + module: MODULE_NAMES[moduleName] || moduleName, + action: 'tag', + input: { + type, + _ids, + action: 'count', + }, + }); +}; diff --git a/backend/erxes-api-shared/src/core-types/modules/tags/tag.ts b/backend/erxes-api-shared/src/core-types/modules/tags/tag.ts index aeca850e7d..20b5772c6c 100644 --- a/backend/erxes-api-shared/src/core-types/modules/tags/tag.ts +++ b/backend/erxes-api-shared/src/core-types/modules/tags/tag.ts @@ -6,7 +6,8 @@ export interface ITag { parentId?: string; relatedIds?: string[]; isGroup?: boolean; - type?: string; + type: string; + objectCount?: number; } export interface ITagDocument extends ITag, Document { diff --git a/frontend/libs/ui-modules/src/modules/tags/graphql/queries/tagsQueries.tsx b/frontend/libs/ui-modules/src/modules/tags/graphql/queries/tagsQueries.tsx index 431b3c76df..db421bb530 100644 --- a/frontend/libs/ui-modules/src/modules/tags/graphql/queries/tagsQueries.tsx +++ b/frontend/libs/ui-modules/src/modules/tags/graphql/queries/tagsQueries.tsx @@ -8,13 +8,15 @@ export const TAGS_QUERY = gql` $cursor: String $limit: Int $direction: CURSOR_DIRECTION - $tagIds: [String] + $ids: [String] + $excludeIds: Boolean ) { tags( type: $type searchValue: $searchValue parentId: $parentId - tagIds: $tagIds + ids: $ids + excludeIds: $excludeIds cursor: $cursor limit: $limit direction: $direction @@ -23,7 +25,6 @@ export const TAGS_QUERY = gql` _id colorCode name - order parentId totalObjectCount objectCount From 60efd6fb3d11951025f0484444a59d9b2a2179e2 Mon Sep 17 00:00:00 2001 From: batmnkh2344 Date: Tue, 9 Sep 2025 12:23:38 +0800 Subject: [PATCH 07/10] chore: review change --- backend/core-api/src/modules/contacts/trpc/customer.ts | 2 -- backend/core-api/src/modules/tags/graphql/mutations.ts | 2 +- backend/core-api/src/modules/tags/utils.ts | 6 +----- 3 files changed, 2 insertions(+), 8 deletions(-) diff --git a/backend/core-api/src/modules/contacts/trpc/customer.ts b/backend/core-api/src/modules/contacts/trpc/customer.ts index 25aac5ca81..a181c7e0bc 100644 --- a/backend/core-api/src/modules/contacts/trpc/customer.ts +++ b/backend/core-api/src/modules/contacts/trpc/customer.ts @@ -230,8 +230,6 @@ export const customerRouter = t.router({ let response = {}; - console.log('input', input); - if (action === 'count') { response = await models.Customers.countDocuments({ tagIds: { $in: _ids }, diff --git a/backend/core-api/src/modules/tags/graphql/mutations.ts b/backend/core-api/src/modules/tags/graphql/mutations.ts index 823166575c..9c1080273c 100644 --- a/backend/core-api/src/modules/tags/graphql/mutations.ts +++ b/backend/core-api/src/modules/tags/graphql/mutations.ts @@ -44,7 +44,7 @@ export const tagMutations = { const tags = await models.Tags.find({ type, _id: { $in: tagIds }, - isGroup: false, + isGroup: { $ne: true }, }); if (tags.length !== tagIds.length) { diff --git a/backend/core-api/src/modules/tags/utils.ts b/backend/core-api/src/modules/tags/utils.ts index bbe6d13e51..541fae2a81 100644 --- a/backend/core-api/src/modules/tags/utils.ts +++ b/backend/core-api/src/modules/tags/utils.ts @@ -1,5 +1,5 @@ import { ITagDocument } from 'erxes-api-shared/core-types'; -import { isEnabled, sendTRPCMessage } from 'erxes-api-shared/utils'; +import { sendTRPCMessage } from 'erxes-api-shared/utils'; import { IModels } from '~/connectionResolvers'; // set related tags @@ -77,10 +77,6 @@ export const countDocuments = async ( ) => { const [pluginName, moduleName] = type.split(':'); - if (!isEnabled(pluginName)) { - return 0; - } - const MODULE_NAMES = { customer: 'customers', }; From c28a60d3d6ac120e9dc03900227e44e3f45b82ad Mon Sep 17 00:00:00 2001 From: Munkhtenger Date: Tue, 16 Sep 2025 15:29:46 +0800 Subject: [PATCH 08/10] [Settings] Tags table update --- .../src/modules/tags/db/definitions/tags.ts | 1 + .../src/modules/tags/db/models/Tags.ts | 13 +- .../tags/graphql/customResolvers/tag.ts | 9 + .../src/modules/tags/graphql/queries.ts | 2 +- .../src/modules/tags/graphql/schemas.ts | 2 + backend/core-api/src/modules/tags/utils.ts | 4 + .../settings/tags/components/TagsColumns.tsx | 472 ++++++++++++++++++ .../tags/components/TagsGroupsAddButtons.tsx | 14 + .../tags/components/TagsRecordTable.tsx | 302 ++++------- .../tags/components/TagsSettingBreadcrumb.tsx | 12 - .../tags/components/TagsSettingsFilter.tsx | 3 - .../tags/graphql/mutations/tagsMutations.ts | 12 +- .../settings/tags/hooks/useTagsAdd.tsx | 6 +- .../settings/tags/providers/TagProvider.tsx | 48 ++ .../workspace/tags/TagsSettingPage.tsx | 31 +- .../tags/graphql/queries/tagsQueries.tsx | 5 + .../ui-modules/src/modules/tags/types/Tag.ts | 12 +- 17 files changed, 708 insertions(+), 240 deletions(-) create mode 100644 frontend/core-ui/src/modules/settings/tags/components/TagsColumns.tsx create mode 100644 frontend/core-ui/src/modules/settings/tags/components/TagsGroupsAddButtons.tsx create mode 100644 frontend/core-ui/src/modules/settings/tags/providers/TagProvider.tsx diff --git a/backend/core-api/src/modules/tags/db/definitions/tags.ts b/backend/core-api/src/modules/tags/db/definitions/tags.ts index 9eb2399233..f680835bf1 100644 --- a/backend/core-api/src/modules/tags/db/definitions/tags.ts +++ b/backend/core-api/src/modules/tags/db/definitions/tags.ts @@ -11,6 +11,7 @@ export const tagSchema = schemaWrapper( relatedIds: { type: [String], label: 'Children tag ids' }, isGroup: { type: Boolean, label: 'Is group', default: false }, type: { type: String, label: 'Content type' }, + description: { type: String, label: 'Description' }, objectCount: { type: Number, label: 'Object count' }, }, { diff --git a/backend/core-api/src/modules/tags/db/models/Tags.ts b/backend/core-api/src/modules/tags/db/models/Tags.ts index 56d37dfe30..df60d7d069 100644 --- a/backend/core-api/src/modules/tags/db/models/Tags.ts +++ b/backend/core-api/src/modules/tags/db/models/Tags.ts @@ -15,17 +15,16 @@ export const loadTagClass = (models: IModels) => { public static async validate(_id: string | null, doc: ITag) { const { name, parentId, isGroup } = doc; - const tag = await models.Tags.findOne({ - $or: [{ _id }, { name }], + const existingTag = await models.Tags.findOne({ + name, + _id: { $ne: _id } }).lean(); - - if (tag?.name === name) { + + if (existingTag) { throw new Error(`A tag named ${name} already exists`); } - if (tag?.isGroup && isGroup) { - throw new Error('Nested group is not allowed 1'); - } + const tag = _id ? await models.Tags.findOne({ _id }).lean() : null; if (String(_id) === String(parentId)) { throw new Error('Group cannot be itself'); diff --git a/backend/core-api/src/modules/tags/graphql/customResolvers/tag.ts b/backend/core-api/src/modules/tags/graphql/customResolvers/tag.ts index d020d643a0..d8eb3d0832 100644 --- a/backend/core-api/src/modules/tags/graphql/customResolvers/tag.ts +++ b/backend/core-api/src/modules/tags/graphql/customResolvers/tag.ts @@ -12,6 +12,11 @@ export default { _args: undefined, { subdomain }: IContext, ) { + + if(!tag.type) { + return 0; + } + if (tag.relatedIds && tag.relatedIds.length > 0) { const tagIds = tag.relatedIds.concat(tag._id); @@ -24,6 +29,10 @@ export default { _args: undefined, { subdomain }: IContext, ) { + if(!tag.type) { + return 0; + } + return countDocuments(subdomain, tag.type, [tag._id]); }, }; diff --git a/backend/core-api/src/modules/tags/graphql/queries.ts b/backend/core-api/src/modules/tags/graphql/queries.ts index e60f14a036..7a02aaabf1 100644 --- a/backend/core-api/src/modules/tags/graphql/queries.ts +++ b/backend/core-api/src/modules/tags/graphql/queries.ts @@ -6,7 +6,7 @@ import { IContext } from '~/connectionResolvers'; const generateFilter = async ({ params, commonQuerySelector, models }) => { const { searchValue, parentId, ids, excludeIds, isGroup, type } = params; - const filter: FilterQuery = { ...commonQuerySelector }; + const filter: FilterQuery = { ...commonQuerySelector, type: { $in: [null, ''] } }; if (type) { let contentType = type; diff --git a/backend/core-api/src/modules/tags/graphql/schemas.ts b/backend/core-api/src/modules/tags/graphql/schemas.ts index 54892acf11..e23d720180 100644 --- a/backend/core-api/src/modules/tags/graphql/schemas.ts +++ b/backend/core-api/src/modules/tags/graphql/schemas.ts @@ -8,6 +8,7 @@ export const types = ` parentId: String relatedIds: [String] isGroup: Boolean + description: String type: String objectCount: Int @@ -49,6 +50,7 @@ const mutationParams = ` colorCode: String, parentId: String, isGroup: Boolean, + description: String, `; export const mutations = ` diff --git a/backend/core-api/src/modules/tags/utils.ts b/backend/core-api/src/modules/tags/utils.ts index 541fae2a81..ac4b49c917 100644 --- a/backend/core-api/src/modules/tags/utils.ts +++ b/backend/core-api/src/modules/tags/utils.ts @@ -81,6 +81,10 @@ export const countDocuments = async ( customer: 'customers', }; + if (!MODULE_NAMES[moduleName]) { + return 0; + } + return await sendTRPCMessage({ pluginName, method: 'mutation', diff --git a/frontend/core-ui/src/modules/settings/tags/components/TagsColumns.tsx b/frontend/core-ui/src/modules/settings/tags/components/TagsColumns.tsx new file mode 100644 index 0000000000..f315ea5a06 --- /dev/null +++ b/frontend/core-ui/src/modules/settings/tags/components/TagsColumns.tsx @@ -0,0 +1,472 @@ +import { useRemoveTag } from '@/settings/tags/hooks/useRemoveTag'; +import { useTagsEdit } from '@/settings/tags/hooks/useTagsEdit'; +import { useTagContext } from '@/settings/tags/providers/TagProvider'; +import { + IconEdit, + IconTrash, + IconArrowRight, + IconX, + IconDropletsFilled, + IconPlus, + IconTransform, +} from '@tabler/icons-react'; +import { Cell, ColumnDef } from '@tanstack/table-core'; +import { + toast, + Input, + Command, + RecordTableInlineCell, + RecordTableTree, + TextOverflowTooltip, + Textarea, + useConfirm, + useQueryState, + RecordTable, + Combobox, + Popover, +} from 'erxes-ui'; +import React, { useState } from 'react'; +import { ITag, ITagQueryResponse, useTags } from 'ui-modules'; +import { useTagsAdd } from '@/settings/tags/hooks/useTagsAdd'; + +const MoveTagPopover: React.FC<{ + tagId: string; + currentParentId?: string; + onMove: (tagId: string, newParentId: string | null) => void; + trigger: React.ReactNode; +}> = ({ tagId, currentParentId, onMove, trigger }) => { + const [open, setOpen] = React.useState(false); + + const { tags: tagsGroup } = useTags({ + variables: { + isGroup: true, + }, + skip: !open + }); + + const handleMove = (newParentId: string | null) => { + onMove(tagId, newParentId); + setOpen(false); + }; + + return ( + + {trigger} + + + + + No groups found. + + {currentParentId && ( + <> + handleMove(null)}> +
+ + Remove from group +
+
+ + + )} + + + {tagsGroup + ?.filter((group) => group._id !== currentParentId) + .map((group) => ( + handleMove(group._id)} + > +
+ {group.colorCode ? ( +
+ ) : ( + + )} + + {group.name} +
+
+ ))} +
+
+
+
+
+ ); +}; + +const NewItemCell: React.FC = () => { + const { mode, targetGroupId, cancel } = useTagContext(); + const [value, setValue] = React.useState(''); + const { addTag } = useTagsAdd(); + React.useEffect(() => { + if ( + mode === 'adding-tag' || + mode === 'adding-group' || + mode === 'adding-tag-to-group' + ) { + setValue(''); + } + }, [mode]); + + const handleSave = () => { + if (value.trim()) { + const newTag: ITagQueryResponse = { + name: value, + isGroup: mode === 'adding-group', + parentId: mode === 'adding-tag-to-group' ? targetGroupId : undefined, + }; + + addTag({ + variables: newTag, + onCompleted: () => { + toast({ title: 'Tag added successfully.' }); + }, + onError: (error) => { + console.log('error', error); + toast({ + title: error.message, + variant: 'destructive', + }); + }, + }); + + cancel(); + setValue(''); + } + }; + + const handleCancel = () => { + cancel(); + setValue(''); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleSave(); + } else if (e.key === 'Escape') { + e.preventDefault(); + handleCancel(); + } + }; + + if (mode === 'idle') { + return null; + } + + return ( + setValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleCancel} + className="p-2 focus:ring-0" + /> + ); +}; + +export const TagMoreColumnCell = ({ cell }: { cell: Cell }) => { + const confirmOptions = { confirmationValue: 'delete' }; + const { confirm } = useConfirm(); + const [, setEditingTagId] = useQueryState('editingTagId'); + const { removeTag, loading } = useRemoveTag(); + const { tagsEdit } = useTagsEdit(); + const { startAddingTagToGroup } = useTagContext(); + + const { _id, name, isGroup, parentId, hasChildren } = cell.row.original; + + const handleMoveTag = (tagId: string, newParentId: string | null) => { + tagsEdit({ + variables: { id: tagId, name: name, parentId: newParentId }, + }); + }; + + const onRemove = () => { + confirm({ + message: 'Are you sure you want to remove the selected?', + options: confirmOptions, + }).then(async () => { + try { + removeTag(_id); + } catch (e) { + console.error(e.message); + } + }); + }; + + return ( + + + + + + + + { + setEditingTagId(_id); + }} + > + Edit + + {!isGroup && ( + + + Move + + } + /> + )} + {isGroup && ( + { + startAddingTagToGroup(_id); + }} + > + Add tag to group + + )} + {isGroup && !hasChildren && ( + { + tagsEdit({ + variables: { + id: _id, + name: name, + parentId: null, + isGroup: false, + }, + }); + }} + > + Convert to tag + + )} + {!isGroup && (!parentId || parentId === '') && ( + { + tagsEdit({ + variables: { + id: _id, + name: name, + isGroup: true, + }, + }); + }} + > + Convert to tag group + + )} + + Delete + + + + + + ); +}; + +export const tagsColumns: ColumnDef[] = [ + { + id: 'name', + header: 'Name', + accessorKey: 'name', + cell: ({ cell }) => { + const { mode } = useTagContext(); + const row = cell.row.original; + + if ( + (mode === 'adding-tag' || + mode === 'adding-group' || + mode === 'adding-tag-to-group') && + row._id === 'new-item-temp' + ) { + return ; + } + + const { tagsEdit, loading } = useTagsEdit(); + const { _id, name, isGroup, order, hasChildren } = row; + const [editingTagId, setEditingTagId] = useQueryState('editingTagId'); + const [open, setOpen] = React.useState(false); + const [_name, setName] = React.useState(name); + + React.useEffect(() => { + setName(name); + }, [name]); + + React.useEffect(() => { + if (editingTagId === _id) { + setName(name); + setOpen(true); + setEditingTagId(null); + } + }, [editingTagId, _id, name, setEditingTagId]); + + const onSave = () => { + if (name !== _name) { + tagsEdit({ + variables: { + id: _id, + name: _name, + isGroup: isGroup, + }, + }); + } + }; + + const onChange = (el: React.ChangeEvent) => { + setName(el.currentTarget.value); + }; + + return ( + { + setOpen(open); + if (!open) { + onSave(); + } + }} + > + + +
+ + {cell.getValue() as string} + +
+
+
+ + + +
+ ); + }, + minSize: 200, + size: 300, + }, + + { + id: 'description', + accessorKey: 'description', + cell: ({ cell }) => { + const { _id, description, name, isGroup } = cell.row.original; + const [open, setOpen] = useState(false); + const [_description, setDescription] = useState( + description ?? '', + ); + const { tagsEdit, loading } = useTagsEdit(); + + React.useEffect(() => { + setDescription(description ?? ''); + }, [description]); + + const onSave = () => { + if (_description !== description) { + tagsEdit({ + variables: { + id: _id, + name: name, + description: _description, + isGroup: isGroup, + }, + }); + } + }; + const onChange = (el: React.ChangeEvent) => { + setDescription(el.currentTarget.value); + }; + return ( + { + setOpen(open); + if (!open) { + onSave(); + } + }} + > + + + + +