diff --git a/backend/core-api/src/commands/migrateContentType.ts b/backend/core-api/src/commands/migrateContentType.ts index 6106bead9b..bedb50fef7 100644 --- a/backend/core-api/src/commands/migrateContentType.ts +++ b/backend/core-api/src/commands/migrateContentType.ts @@ -60,6 +60,9 @@ const command = async () => { } console.log(`Process finished at: ${new Date().toISOString()}`); + + await client.close(); + 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..b5bb83fdf5 --- /dev/null +++ b/backend/core-api/src/commands/migrateTags.ts @@ -0,0 +1,44 @@ +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: { scopeBrandIds: '' }, + $set: { isGroup: false, parentId: '', order: '' }, // 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()}`); + + await client.close(); + process.exit(); +}; + +command(); diff --git a/backend/core-api/src/modules/contacts/trpc/customer.ts b/backend/core-api/src/modules/contacts/trpc/customer.ts index 9883abf68b..a181c7e0bc 100644 --- a/backend/core-api/src/modules/contacts/trpc/customer.ts +++ b/backend/core-api/src/modules/contacts/trpc/customer.ts @@ -223,5 +223,31 @@ 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 = {}; + + 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/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..a68c823129 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,15 @@ 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' }, + 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' }, + description: { type: String, label: 'Description' }, 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', - }, + order: { type: String, label: 'Order' }, }, { timestamps: true, @@ -32,5 +21,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, 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 3503765656..5ad2f69eca 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,9 @@ -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 { escapeRegExp } from 'erxes-api-shared/utils'; 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 +13,57 @@ 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 existingTag = await models.Tags.findOne({ + name, + _id: { $ne: _id }, + }).lean(); + + if (existingTag) { + throw new Error(`A tag named ${name} already exists`); + } + + const tag = _id ? await models.Tags.findOne({ _id }).lean() : null; + + if (String(_id) === String(parentId)) { + throw new Error('Group cannot be itself'); + } + + if (parentId) { + const parentTag = await models.Tags.findOne({ _id: parentId }).lean(); + + if (!parentTag) { + throw new Error('Group not found'); + } + + if (!parentTag.isGroup) { + throw new Error('Parent tag must be a group'); + } + + if ((isGroup || tag?.isGroup) && parentTag?.isGroup) { + throw new Error('Nested group is not allowed 2 '); + } + } + + if (tag) { + 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'); + } + + if (!isGroup && childTags.length) { + throw new Error('Group has tags'); + } + } + } + public static async getTag(_id: string) { const tag = await models.Tags.findOne({ _id }); @@ -26,25 +74,14 @@ 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); + await this.validate(null, doc); - if (!isUnique) { - throw new Error('Tag duplicated'); - } - - const parentTag = await this.getParentTag(doc); - - // Generatingg order - const order = await this.generateOrder(parentTag, doc); + const order = await this.generateOrder(doc); const tag = await models.Tags.create({ ...doc, order, - createdAt: new Date(), }); await setRelatedTagIds(models, tag); @@ -52,39 +89,21 @@ export const loadTagClass = (models: IModels) => { 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 order = await this.generateOrder(doc); const childTags = await models.Tags.find({ $and: [ - { order: { $regex: new RegExp(escapeRegExp(tag.order || ''), 'i') } }, { _id: { $ne: _id } }, + { order: { $regex: new RegExp(escapeRegExp(tag.order || ''), 'i') } }, ], }); - if (childTags.length > 0) { + if (childTags.length) { const bulkDoc: Array<{ updateOne: { filter: { _id: string }; @@ -92,8 +111,8 @@ export const loadTagClass = (models: IModels) => { }; }> = []; - // updating child tag order - childTags.forEach((childTag) => { + // updating child categories order + childTags.forEach(async (childTag) => { let childOrder = childTag.order || ''; childOrder = childOrder.replace(tag.order || '', order); @@ -111,9 +130,16 @@ export const loadTagClass = (models: IModels) => { await removeRelatedTagIds(models, tag); } - await models.Tags.updateOne({ _id }, { $set: { ...doc, order } }); - - const updated = await models.Tags.findOne({ _id }); + const updated = await models.Tags.findOneAndUpdate( + { _id: tag._id }, + { + ...doc, + order, + }, + { + new: true, + }, + ); if (updated) { await setRelatedTagIds(models, updated); @@ -122,79 +148,27 @@ export const loadTagClass = (models: IModels) => { 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(); - } + public static async generateOrder({ name, parentId }: ITag) { + const tag = await models.Tags.findOne({ _id: parentId }).lean(); - /** - * Generating order - */ - public static async generateOrder( - parentTag: ITagDocument | null, - { name }: { name: string }, - ) { - const order = parentTag ? `${parentTag.order}${name}/` : `${name}/`; + const order = tag?.order ? `${tag.order}${name}/` : `${name}/`; return order; } 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..d8eb3d0832 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,38 @@ +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.type) { + return 0; + } + + 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, + ) { + if(!tag.type) { + return 0; + } + + 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 7b837ad8fe..9c1080273c 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: { $ne: true }, }); - 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', @@ -96,18 +97,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..2ab681e619 100644 --- a/backend/core-api/src/modules/tags/graphql/queries.ts +++ b/backend/core-api/src/modules/tags/graphql/queries.ts @@ -2,38 +2,42 @@ import { ITagFilterQueryParams } from '@/tags/@types/tag'; import { cursorPaginate, getPlugin, getPlugins } 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, type } = params; - const filter: FilterQuery = { ...commonQuerySelector }; + const filter: FilterQuery = { + ...commonQuerySelector, + type: { $in: [null, ''] }, + }; if (type) { - const [serviceName, contentType] = type.split(':'); + let contentType = type; - if (contentType === 'all') { - const contentTypes: string[] = await getContentTypes(serviceName); - filter.type = { $in: contentTypes }; - } else { - filter.type = 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'); } - 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[]) => { @@ -57,18 +61,15 @@ const generateFilter = async ({ params, commonQuerySelector, models }) => { export const tagQueries = { /** - * Get tag types + * 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.tags) { + if (meta && meta.tags) { const types = meta.tags.types || []; for (const type of types) { @@ -82,7 +83,6 @@ export const tagQueries = { return fieldTypes; }, - /** * Get tags */ @@ -99,7 +99,10 @@ export const tagQueries = { const { list, totalCount, pageInfo } = await cursorPaginate({ model: models.Tags, - params, + params: { + orderBy: { order: 1 }, + ...params, + }, query: filter, }); @@ -130,14 +133,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..c745a4ed4a 100644 --- a/backend/core-api/src/modules/tags/graphql/schemas.ts +++ b/backend/core-api/src/modules/tags/graphql/schemas.ts @@ -1,19 +1,21 @@ 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 + description: String + type: String + + order: String + objectCount: Int + totalObjectCount: Int + + createdAt: Date } type TagsListResponse { @@ -26,10 +28,11 @@ export const types = ` const queryParams = ` type: String, searchValue: String, - tagIds: [String], parentId: String, ids: [String], excludeIds: Boolean, + isGroup: Boolean, + instanceId: String, sortField: String, @@ -44,16 +47,16 @@ export const queries = ` `; const mutationParams = ` - name: String!, - type: String!, + type: String, colorCode: String, parentId: String, + isGroup: Boolean, + description: String, `; export const mutations = ` - tagsAdd(${mutationParams}): Tag - tagsEdit(_id: String!, ${mutationParams}): Tag - tagsRemove(_id: String!): JSON + tagsAdd(name: String!, ${mutationParams}): Tag + tagsEdit(_id: String!, name: String, ${mutationParams}): Tag tagsTag(type: String!, targetIds: [String!]!, tagIds: [String!]!): JSON - tagsMerge(sourceId: String!, destId: String!): Tag + tagsRemove(_id: String!): JSON `; diff --git a/backend/core-api/src/modules/tags/utils.ts b/backend/core-api/src/modules/tags/utils.ts index 5d4090b98b..ac4b49c917 100644 --- a/backend/core-api/src/modules/tags/utils.ts +++ b/backend/core-api/src/modules/tags/utils.ts @@ -1,33 +1,36 @@ import { ITagDocument } from 'erxes-api-shared/core-types'; -import { getPlugin } from 'erxes-api-shared/utils'; +import { sendTRPCMessage } 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); } }; @@ -67,9 +70,30 @@ 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}`); +export const countDocuments = async ( + subdomain: string, + type: string, + _ids: string[], +) => { + const [pluginName, moduleName] = type.split(':'); + + const MODULE_NAMES = { + customer: 'customers', + }; + + if (!MODULE_NAMES[moduleName]) { + return 0; + } + + 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 fee6b11e46..f084d23f46 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,16 @@ import { Document } from 'mongoose'; export interface ITag { name: string; - type: string; colorCode?: string; - objectCount?: number; parentId?: string; + relatedIds?: string[]; + isGroup?: boolean; + type: string; + objectCount?: number; + order?: string; } export interface ITagDocument extends ITag, Document { _id: string; createdAt: Date; - order?: string; - relatedIds?: string[]; } 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..cd40a61f4d --- /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 } = 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(); + } + }} + > + + + + +