From 4736c327c26f9b230c25272b951c0f85947aba0a Mon Sep 17 00:00:00 2001 From: Mikael Araya Date: Wed, 3 Jun 2026 19:08:50 +0300 Subject: [PATCH 1/4] Add products bulk operation mutations --- examples/kitchensink/src/boot.ts | 10 ++--- .../bulk/bulkAssignProductsToAssortment.ts | 35 +++++++++++++++ .../mutations/bulk/bulkRemoveProducts.ts | 32 +++++++++++++ .../mutations/bulk/bulkSetProductStatus.ts | 45 +++++++++++++++++++ .../mutations/bulk/bulkUpdateProductTags.ts | 38 ++++++++++++++++ .../mutations/bulk/bulkUpdateUserTags.ts | 38 ++++++++++++++++ packages/api/src/resolvers/mutations/index.ts | 10 +++++ packages/api/src/schema/mutation.ts | 25 +++++++++++ packages/api/src/schema/types/common.ts | 6 +++ 9 files changed, 234 insertions(+), 5 deletions(-) create mode 100644 packages/api/src/resolvers/mutations/bulk/bulkAssignProductsToAssortment.ts create mode 100644 packages/api/src/resolvers/mutations/bulk/bulkRemoveProducts.ts create mode 100644 packages/api/src/resolvers/mutations/bulk/bulkSetProductStatus.ts create mode 100644 packages/api/src/resolvers/mutations/bulk/bulkUpdateProductTags.ts create mode 100644 packages/api/src/resolvers/mutations/bulk/bulkUpdateUserTags.ts diff --git a/examples/kitchensink/src/boot.ts b/examples/kitchensink/src/boot.ts index 3eb4b1beba..9bcd80ccf9 100644 --- a/examples/kitchensink/src/boot.ts +++ b/examples/kitchensink/src/boot.ts @@ -127,11 +127,11 @@ try { }, chat: provider ? { - model: provider.chat(process.env.OPENAI_MODEL || 'gpt-5.2'), - imageGenerationTool: imageProvider - ? { model: imageProvider.imageModel('gpt-image-1') } - : undefined, - } + model: provider.chat(process.env.OPENAI_MODEL || 'gpt-5.2'), + imageGenerationTool: imageProvider + ? { model: imageProvider.imageModel('gpt-image-1') } + : undefined, + } : undefined, }); diff --git a/packages/api/src/resolvers/mutations/bulk/bulkAssignProductsToAssortment.ts b/packages/api/src/resolvers/mutations/bulk/bulkAssignProductsToAssortment.ts new file mode 100644 index 0000000000..968c76e619 --- /dev/null +++ b/packages/api/src/resolvers/mutations/bulk/bulkAssignProductsToAssortment.ts @@ -0,0 +1,35 @@ +import type { Context } from '../../../context.ts'; +import { log } from '@unchainedshop/logger'; +import { InvalidIdError, AssortmentNotFoundError } from '../../../errors.ts'; + +export default async function bulkAssignProductsToAssortment( + root: never, + { productIds, assortmentId }: { productIds: string[]; assortmentId: string }, + { modules, userId }: Context, +) { + log(`mutation bulkAssignProductsToAssortment ${assortmentId} for ${productIds.length} products`, { + userId, + }); + + if (!assortmentId) throw new InvalidIdError({ assortmentId }); + if (!(await modules.assortments.assortmentExists({ assortmentId }))) + throw new AssortmentNotFoundError({ assortmentId }); + + const failedIds: string[] = []; + let successCount = 0; + + for (const productId of productIds) { + try { + if (!(await modules.products.productExists({ productId }))) { + failedIds.push(productId); + continue; + } + await modules.assortments.products.create({ assortmentId, productId, tags: [] }); + successCount += 1; + } catch { + failedIds.push(productId); + } + } + + return { successCount, failedCount: failedIds.length, failedIds }; +} diff --git a/packages/api/src/resolvers/mutations/bulk/bulkRemoveProducts.ts b/packages/api/src/resolvers/mutations/bulk/bulkRemoveProducts.ts new file mode 100644 index 0000000000..cd19a14cd9 --- /dev/null +++ b/packages/api/src/resolvers/mutations/bulk/bulkRemoveProducts.ts @@ -0,0 +1,32 @@ +import type { Context } from '../../../context.ts'; +import { log } from '@unchainedshop/logger'; + +export default async function bulkRemoveProducts( + root: never, + { productIds }: { productIds: string[] }, + { modules, services, userId }: Context, +) { + log(`mutation bulkRemoveProducts for ${productIds.length} products`, { userId }); + + const failedIds: string[] = []; + let successCount = 0; + + for (const productId of productIds) { + try { + if (!(await modules.products.productExists({ productId }))) { + failedIds.push(productId); + continue; + } + const result = await services.products.removeProduct({ productId }); + if (result) { + successCount += 1; + } else { + failedIds.push(productId); + } + } catch { + failedIds.push(productId); + } + } + + return { successCount, failedCount: failedIds.length, failedIds }; +} diff --git a/packages/api/src/resolvers/mutations/bulk/bulkSetProductStatus.ts b/packages/api/src/resolvers/mutations/bulk/bulkSetProductStatus.ts new file mode 100644 index 0000000000..fe4e7783ef --- /dev/null +++ b/packages/api/src/resolvers/mutations/bulk/bulkSetProductStatus.ts @@ -0,0 +1,45 @@ +import type { Context } from '../../../context.ts'; +import { log } from '@unchainedshop/logger'; + +export default async function bulkSetProductStatus( + root: never, + { productIds, status }: { productIds: string[]; status: string }, + { modules, userId }: Context, +) { + log(`mutation bulkSetProductStatus ${status} for ${productIds.length} products`, { userId }); + + const failedIds: string[] = []; + let successCount = 0; + + for (const productId of productIds) { + try { + const product = await modules.products.findProduct({ productId }); + if (!product) { + failedIds.push(productId); + continue; + } + + if (status === 'ACTIVE') { + const result = await modules.products.publish(product); + if (!result) { + failedIds.push(productId); + continue; + } + } else if (status === 'DRAFT') { + const result = await modules.products.unpublish(product); + if (!result) { + failedIds.push(productId); + continue; + } + } else { + failedIds.push(productId); + continue; + } + successCount += 1; + } catch { + failedIds.push(productId); + } + } + + return { successCount, failedCount: failedIds.length, failedIds }; +} diff --git a/packages/api/src/resolvers/mutations/bulk/bulkUpdateProductTags.ts b/packages/api/src/resolvers/mutations/bulk/bulkUpdateProductTags.ts new file mode 100644 index 0000000000..229ea64aa8 --- /dev/null +++ b/packages/api/src/resolvers/mutations/bulk/bulkUpdateProductTags.ts @@ -0,0 +1,38 @@ +import type { Context } from '../../../context.ts'; +import { log } from '@unchainedshop/logger'; + +export default async function bulkUpdateProductTags( + root: never, + { productIds, add, remove }: { productIds: string[]; add?: string[]; remove?: string[] }, + { modules, userId }: Context, +) { + log(`mutation bulkUpdateProductTags for ${productIds.length} products`, { userId }); + + const failedIds: string[] = []; + let successCount = 0; + + for (const productId of productIds) { + try { + const product = await modules.products.findProduct({ productId }); + if (!product) { + failedIds.push(productId); + continue; + } + + let tags = product.tags || []; + if (remove?.length) { + tags = tags.filter((t) => !remove.includes(t)); + } + if (add?.length) { + tags = [...new Set([...tags, ...add])]; + } + + await modules.products.update(productId, { tags }); + successCount += 1; + } catch { + failedIds.push(productId); + } + } + + return { successCount, failedCount: failedIds.length, failedIds }; +} diff --git a/packages/api/src/resolvers/mutations/bulk/bulkUpdateUserTags.ts b/packages/api/src/resolvers/mutations/bulk/bulkUpdateUserTags.ts new file mode 100644 index 0000000000..79572c968e --- /dev/null +++ b/packages/api/src/resolvers/mutations/bulk/bulkUpdateUserTags.ts @@ -0,0 +1,38 @@ +import type { Context } from '../../../context.ts'; +import { log } from '@unchainedshop/logger'; + +export default async function bulkUpdateUserTags( + root: never, + { userIds, add, remove }: { userIds: string[]; add?: string[]; remove?: string[] }, + { modules, userId }: Context, +) { + log(`mutation bulkUpdateUserTags for ${userIds.length} users`, { userId }); + + const failedIds: string[] = []; + let successCount = 0; + + for (const targetUserId of userIds) { + try { + const user = await modules.users.findUserById(targetUserId); + if (!user) { + failedIds.push(targetUserId); + continue; + } + + let tags = user.tags || []; + if (remove?.length) { + tags = tags.filter((t) => !remove.includes(t)); + } + if (add?.length) { + tags = [...new Set([...tags, ...add])]; + } + + await modules.users.updateTags(targetUserId, tags); + successCount += 1; + } catch { + failedIds.push(targetUserId); + } + } + + return { successCount, failedCount: failedIds.length, failedIds }; +} diff --git a/packages/api/src/resolvers/mutations/index.ts b/packages/api/src/resolvers/mutations/index.ts index f8773a5319..850b46db8c 100755 --- a/packages/api/src/resolvers/mutations/index.ts +++ b/packages/api/src/resolvers/mutations/index.ts @@ -150,6 +150,11 @@ import updateCartDeliveryPickUp from './orders/updateCartDeliveryPickUp.ts'; import updateCartDeliveryShipping from './orders/updateCartDeliveryShipping.ts'; import updateCartPaymentGeneric from './orders/updateCartPaymentGeneric.ts'; import updateCartPaymentInvoice from './orders/updateCartPaymentInvoice.ts'; +import bulkSetProductStatus from './bulk/bulkSetProductStatus.ts'; +import bulkUpdateProductTags from './bulk/bulkUpdateProductTags.ts'; +import bulkAssignProductsToAssortment from './bulk/bulkAssignProductsToAssortment.ts'; +import bulkRemoveProducts from './bulk/bulkRemoveProducts.ts'; +import bulkUpdateUserTags from './bulk/bulkUpdateUserTags.ts'; export default { logout: acl(actions.logout)(logout), @@ -311,4 +316,9 @@ export default { ), signPaymentProviderForCheckout: acl(actions.updateOrderPayment)(signPaymentProviderForCheckout), removeUserProductReviews: acl(actions.updateUser)(removeUserProductReviews), + bulkSetProductStatus: acl(actions.manageProducts)(bulkSetProductStatus), + bulkUpdateProductTags: acl(actions.manageProducts)(bulkUpdateProductTags), + bulkAssignProductsToAssortment: acl(actions.manageAssortments)(bulkAssignProductsToAssortment), + bulkRemoveProducts: acl(actions.manageProducts)(bulkRemoveProducts), + bulkUpdateUserTags: acl(actions.manageUsers)(bulkUpdateUserTags), }; diff --git a/packages/api/src/schema/mutation.ts b/packages/api/src/schema/mutation.ts index d5305ec405..0c6649efcf 100644 --- a/packages/api/src/schema/mutation.ts +++ b/packages/api/src/schema/mutation.ts @@ -858,6 +858,31 @@ export default [ invalidateToken(tokenId: ID!): Token! exportToken(tokenId: ID!, quantity: Int! = 1, recipientWalletAddress: String!): Token! + """ + Publish or unpublish multiple products at once + """ + bulkSetProductStatus(productIds: [ID!]!, status: ProductStatus!): BulkOperationResult! + + """ + Add or remove tags from multiple products at once + """ + bulkUpdateProductTags(productIds: [ID!]!, add: [LowerCaseString!], remove: [LowerCaseString!]): BulkOperationResult! + + """ + Assign multiple products to an assortment + """ + bulkAssignProductsToAssortment(productIds: [ID!]!, assortmentId: ID!): BulkOperationResult! + + """ + Remove multiple products + """ + bulkRemoveProducts(productIds: [ID!]!): BulkOperationResult! + + """ + Add or remove tags from multiple users at once + """ + bulkUpdateUserTags(userIds: [ID!]!, add: [LowerCaseString!], remove: [LowerCaseString!]): BulkOperationResult! + """ Store user W3C Push subscription object """ diff --git a/packages/api/src/schema/types/common.ts b/packages/api/src/schema/types/common.ts index 7ceec26c15..99bf634f0b 100755 --- a/packages/api/src/schema/types/common.ts +++ b/packages/api/src/schema/types/common.ts @@ -4,6 +4,12 @@ export default [ success: Boolean } + type BulkOperationResult @cacheControl(maxAge: 0, scope: PRIVATE) { + successCount: Int! + failedCount: Int! + failedIds: [ID!]! + } + enum SortDirection { ASC DESC From 733d5a8541f958131dffdcb779482162e5ad4d30 Mon Sep 17 00:00:00 2001 From: Mikael Araya Date: Wed, 3 Jun 2026 23:36:45 +0300 Subject: [PATCH 2/4] Add bulk user, assortments and filters operations mutations --- .../bulk/bulkAssignProductsToAssortment.ts | 20 ++--- .../mutations/bulk/bulkRemoveAssortments.ts | 14 ++++ .../mutations/bulk/bulkRemoveFilters.ts | 14 ++++ .../mutations/bulk/bulkRemoveProducts.ts | 24 +----- .../mutations/bulk/bulkRemoveUsers.ts | 14 ++++ .../mutations/bulk/bulkSetAssortmentActive.ts | 21 +++++ .../mutations/bulk/bulkSetFilterActive.ts | 15 ++++ .../mutations/bulk/bulkSetProductStatus.ts | 40 +++------- .../mutations/bulk/bulkSetUserRoles.ts | 23 ++++++ .../bulk/bulkUpdateAssortmentTags.ts | 21 +++++ .../mutations/bulk/bulkUpdateProductTags.ts | 31 ++------ .../mutations/bulk/bulkUpdateUserTags.ts | 31 ++------ packages/api/src/resolvers/mutations/index.ts | 14 ++++ packages/api/src/schema/mutation.ts | 51 +++++++++++- .../src/module/configureAssortmentsModule.ts | 77 +++++++++++++++++++ .../src/module/configureFiltersModule.ts | 10 +++ .../src/module/configureProductsModule.ts | 66 ++++++++++++++++ .../src/module/configureUsersModule.ts | 32 ++++++++ packages/core/src/services/bulkDeleteUsers.ts | 28 +++++++ .../core/src/services/bulkRemoveFilters.ts | 30 ++++++++ .../core/src/services/bulkRemoveProducts.ts | 28 +++++++ packages/core/src/services/index.ts | 6 ++ 22 files changed, 497 insertions(+), 113 deletions(-) create mode 100644 packages/api/src/resolvers/mutations/bulk/bulkRemoveAssortments.ts create mode 100644 packages/api/src/resolvers/mutations/bulk/bulkRemoveFilters.ts create mode 100644 packages/api/src/resolvers/mutations/bulk/bulkRemoveUsers.ts create mode 100644 packages/api/src/resolvers/mutations/bulk/bulkSetAssortmentActive.ts create mode 100644 packages/api/src/resolvers/mutations/bulk/bulkSetFilterActive.ts create mode 100644 packages/api/src/resolvers/mutations/bulk/bulkSetUserRoles.ts create mode 100644 packages/api/src/resolvers/mutations/bulk/bulkUpdateAssortmentTags.ts create mode 100644 packages/core/src/services/bulkDeleteUsers.ts create mode 100644 packages/core/src/services/bulkRemoveFilters.ts create mode 100644 packages/core/src/services/bulkRemoveProducts.ts diff --git a/packages/api/src/resolvers/mutations/bulk/bulkAssignProductsToAssortment.ts b/packages/api/src/resolvers/mutations/bulk/bulkAssignProductsToAssortment.ts index 968c76e619..e22493ee93 100644 --- a/packages/api/src/resolvers/mutations/bulk/bulkAssignProductsToAssortment.ts +++ b/packages/api/src/resolvers/mutations/bulk/bulkAssignProductsToAssortment.ts @@ -18,18 +18,20 @@ export default async function bulkAssignProductsToAssortment( const failedIds: string[] = []; let successCount = 0; - for (const productId of productIds) { - try { - if (!(await modules.products.productExists({ productId }))) { - failedIds.push(productId); - continue; - } + const results = await Promise.allSettled( + productIds.map(async (productId) => { + if (!(await modules.products.productExists({ productId }))) throw new Error('not-found'); await modules.assortments.products.create({ assortmentId, productId, tags: [] }); + }), + ); + + results.forEach((result, index) => { + if (result.status === 'fulfilled') { successCount += 1; - } catch { - failedIds.push(productId); + } else { + failedIds.push(productIds[index]); } - } + }); return { successCount, failedCount: failedIds.length, failedIds }; } diff --git a/packages/api/src/resolvers/mutations/bulk/bulkRemoveAssortments.ts b/packages/api/src/resolvers/mutations/bulk/bulkRemoveAssortments.ts new file mode 100644 index 0000000000..aca963e8d1 --- /dev/null +++ b/packages/api/src/resolvers/mutations/bulk/bulkRemoveAssortments.ts @@ -0,0 +1,14 @@ +import type { Context } from '../../../context.ts'; +import { log } from '@unchainedshop/logger'; + +export default async function bulkRemoveAssortments( + root: never, + { assortmentIds }: { assortmentIds: string[] }, + { modules, userId }: Context, +) { + log(`mutation bulkRemoveAssortments for ${assortmentIds.length} assortments`, { userId }); + + const { successIds, failedIds } = await modules.assortments.bulkDelete(assortmentIds); + + return { successCount: successIds.length, failedCount: failedIds.length, failedIds }; +} diff --git a/packages/api/src/resolvers/mutations/bulk/bulkRemoveFilters.ts b/packages/api/src/resolvers/mutations/bulk/bulkRemoveFilters.ts new file mode 100644 index 0000000000..b7ac70c8fc --- /dev/null +++ b/packages/api/src/resolvers/mutations/bulk/bulkRemoveFilters.ts @@ -0,0 +1,14 @@ +import type { Context } from '../../../context.ts'; +import { log } from '@unchainedshop/logger'; + +export default async function bulkRemoveFilters( + root: never, + { filterIds }: { filterIds: string[] }, + { services, userId }: Context, +) { + log(`mutation bulkRemoveFilters for ${filterIds.length} filters`, { userId }); + + const { successIds, failedIds } = await services.filters.bulkRemoveFilters({ filterIds }); + + return { successCount: successIds.length, failedCount: failedIds.length, failedIds }; +} diff --git a/packages/api/src/resolvers/mutations/bulk/bulkRemoveProducts.ts b/packages/api/src/resolvers/mutations/bulk/bulkRemoveProducts.ts index cd19a14cd9..226cc4e333 100644 --- a/packages/api/src/resolvers/mutations/bulk/bulkRemoveProducts.ts +++ b/packages/api/src/resolvers/mutations/bulk/bulkRemoveProducts.ts @@ -4,29 +4,11 @@ import { log } from '@unchainedshop/logger'; export default async function bulkRemoveProducts( root: never, { productIds }: { productIds: string[] }, - { modules, services, userId }: Context, + { services, userId }: Context, ) { log(`mutation bulkRemoveProducts for ${productIds.length} products`, { userId }); - const failedIds: string[] = []; - let successCount = 0; + const { successIds, failedIds } = await services.products.bulkRemoveProducts({ productIds }); - for (const productId of productIds) { - try { - if (!(await modules.products.productExists({ productId }))) { - failedIds.push(productId); - continue; - } - const result = await services.products.removeProduct({ productId }); - if (result) { - successCount += 1; - } else { - failedIds.push(productId); - } - } catch { - failedIds.push(productId); - } - } - - return { successCount, failedCount: failedIds.length, failedIds }; + return { successCount: successIds.length, failedCount: failedIds.length, failedIds }; } diff --git a/packages/api/src/resolvers/mutations/bulk/bulkRemoveUsers.ts b/packages/api/src/resolvers/mutations/bulk/bulkRemoveUsers.ts new file mode 100644 index 0000000000..5aa0176201 --- /dev/null +++ b/packages/api/src/resolvers/mutations/bulk/bulkRemoveUsers.ts @@ -0,0 +1,14 @@ +import type { Context } from '../../../context.ts'; +import { log } from '@unchainedshop/logger'; + +export default async function bulkRemoveUsers( + root: never, + { userIds }: { userIds: string[] }, + { services, userId }: Context, +) { + log(`mutation bulkRemoveUsers for ${userIds.length} users`, { userId }); + + const { successIds, failedIds } = await services.users.bulkDeleteUsers({ userIds }); + + return { successCount: successIds.length, failedCount: failedIds.length, failedIds }; +} diff --git a/packages/api/src/resolvers/mutations/bulk/bulkSetAssortmentActive.ts b/packages/api/src/resolvers/mutations/bulk/bulkSetAssortmentActive.ts new file mode 100644 index 0000000000..0100e3c67e --- /dev/null +++ b/packages/api/src/resolvers/mutations/bulk/bulkSetAssortmentActive.ts @@ -0,0 +1,21 @@ +import type { Context } from '../../../context.ts'; +import { log } from '@unchainedshop/logger'; + +export default async function bulkSetAssortmentActive( + root: never, + { assortmentIds, isActive }: { assortmentIds: string[]; isActive: boolean }, + { modules, userId }: Context, +) { + log(`mutation bulkSetAssortmentActive ${isActive} for ${assortmentIds.length} assortments`, { + userId, + }); + + const modifiedCount = await modules.assortments.bulkSetActive(assortmentIds, isActive); + + if (modifiedCount > 0) { + await modules.assortments.invalidateCache({ assortmentIds }, { skipUpstreamTraversal: false }); + } + + const failedCount = assortmentIds.length - modifiedCount; + return { successCount: modifiedCount, failedCount, failedIds: [] }; +} diff --git a/packages/api/src/resolvers/mutations/bulk/bulkSetFilterActive.ts b/packages/api/src/resolvers/mutations/bulk/bulkSetFilterActive.ts new file mode 100644 index 0000000000..3cda2f2dc0 --- /dev/null +++ b/packages/api/src/resolvers/mutations/bulk/bulkSetFilterActive.ts @@ -0,0 +1,15 @@ +import type { Context } from '../../../context.ts'; +import { log } from '@unchainedshop/logger'; + +export default async function bulkSetFilterActive( + root: never, + { filterIds, isActive }: { filterIds: string[]; isActive: boolean }, + { modules, userId }: Context, +) { + log(`mutation bulkSetFilterActive ${isActive} for ${filterIds.length} filters`, { userId }); + + const modifiedCount = await modules.filters.bulkSetActive(filterIds, isActive); + + const failedCount = filterIds.length - modifiedCount; + return { successCount: modifiedCount, failedCount, failedIds: [] }; +} diff --git a/packages/api/src/resolvers/mutations/bulk/bulkSetProductStatus.ts b/packages/api/src/resolvers/mutations/bulk/bulkSetProductStatus.ts index fe4e7783ef..d905556af0 100644 --- a/packages/api/src/resolvers/mutations/bulk/bulkSetProductStatus.ts +++ b/packages/api/src/resolvers/mutations/bulk/bulkSetProductStatus.ts @@ -8,38 +8,16 @@ export default async function bulkSetProductStatus( ) { log(`mutation bulkSetProductStatus ${status} for ${productIds.length} products`, { userId }); - const failedIds: string[] = []; - let successCount = 0; + let successIds: string[]; - for (const productId of productIds) { - try { - const product = await modules.products.findProduct({ productId }); - if (!product) { - failedIds.push(productId); - continue; - } - - if (status === 'ACTIVE') { - const result = await modules.products.publish(product); - if (!result) { - failedIds.push(productId); - continue; - } - } else if (status === 'DRAFT') { - const result = await modules.products.unpublish(product); - if (!result) { - failedIds.push(productId); - continue; - } - } else { - failedIds.push(productId); - continue; - } - successCount += 1; - } catch { - failedIds.push(productId); - } + if (status === 'ACTIVE') { + successIds = await modules.products.bulkPublish(productIds); + } else if (status === 'DRAFT') { + successIds = await modules.products.bulkUnpublish(productIds); + } else { + return { successCount: 0, failedCount: productIds.length, failedIds: productIds }; } - return { successCount, failedCount: failedIds.length, failedIds }; + const failedIds = productIds.filter((id) => !successIds.includes(id)); + return { successCount: successIds.length, failedCount: failedIds.length, failedIds }; } diff --git a/packages/api/src/resolvers/mutations/bulk/bulkSetUserRoles.ts b/packages/api/src/resolvers/mutations/bulk/bulkSetUserRoles.ts new file mode 100644 index 0000000000..0d2b842099 --- /dev/null +++ b/packages/api/src/resolvers/mutations/bulk/bulkSetUserRoles.ts @@ -0,0 +1,23 @@ +import type { Context } from '../../../context.ts'; +import { log } from '@unchainedshop/logger'; +import { getPublicRoles } from '../../../roles/index.ts'; + +export default async function bulkSetUserRoles( + root: never, + { userIds, roles }: { userIds: string[]; roles: string[] }, + context: Context, +) { + const { modules, userId } = context; + log(`mutation bulkSetUserRoles for ${userIds.length} users`, { userId }); + + const publicRoles = getPublicRoles(context.roles!); + const invalidRoles = roles.filter((r) => !publicRoles.includes(r)); + if (invalidRoles.length) { + throw new Error(`Invalid role names: ${invalidRoles.join(', ')}`); + } + + const modifiedCount = await modules.users.bulkUpdateRoles(userIds, roles); + + const failedCount = userIds.length - modifiedCount; + return { successCount: modifiedCount, failedCount, failedIds: [] }; +} diff --git a/packages/api/src/resolvers/mutations/bulk/bulkUpdateAssortmentTags.ts b/packages/api/src/resolvers/mutations/bulk/bulkUpdateAssortmentTags.ts new file mode 100644 index 0000000000..9eae32989d --- /dev/null +++ b/packages/api/src/resolvers/mutations/bulk/bulkUpdateAssortmentTags.ts @@ -0,0 +1,21 @@ +import type { Context } from '../../../context.ts'; +import { log } from '@unchainedshop/logger'; + +export default async function bulkUpdateAssortmentTags( + root: never, + { assortmentIds, add, remove }: { assortmentIds: string[]; add?: string[]; remove?: string[] }, + { modules, userId }: Context, +) { + log(`mutation bulkUpdateAssortmentTags for ${assortmentIds.length} assortments`, { userId }); + + if (remove?.length) { + await modules.assortments.bulkRemoveTags(assortmentIds, remove); + } + if (add?.length) { + await modules.assortments.bulkAddTags(assortmentIds, add); + } + + await modules.assortments.invalidateCache({ assortmentIds }, { skipUpstreamTraversal: false }); + + return { successCount: assortmentIds.length, failedCount: 0, failedIds: [] }; +} diff --git a/packages/api/src/resolvers/mutations/bulk/bulkUpdateProductTags.ts b/packages/api/src/resolvers/mutations/bulk/bulkUpdateProductTags.ts index 229ea64aa8..d2cc08dcb7 100644 --- a/packages/api/src/resolvers/mutations/bulk/bulkUpdateProductTags.ts +++ b/packages/api/src/resolvers/mutations/bulk/bulkUpdateProductTags.ts @@ -8,31 +8,12 @@ export default async function bulkUpdateProductTags( ) { log(`mutation bulkUpdateProductTags for ${productIds.length} products`, { userId }); - const failedIds: string[] = []; - let successCount = 0; - - for (const productId of productIds) { - try { - const product = await modules.products.findProduct({ productId }); - if (!product) { - failedIds.push(productId); - continue; - } - - let tags = product.tags || []; - if (remove?.length) { - tags = tags.filter((t) => !remove.includes(t)); - } - if (add?.length) { - tags = [...new Set([...tags, ...add])]; - } - - await modules.products.update(productId, { tags }); - successCount += 1; - } catch { - failedIds.push(productId); - } + if (remove?.length) { + await modules.products.bulkRemoveTags(productIds, remove); + } + if (add?.length) { + await modules.products.bulkAddTags(productIds, add); } - return { successCount, failedCount: failedIds.length, failedIds }; + return { successCount: productIds.length, failedCount: 0, failedIds: [] }; } diff --git a/packages/api/src/resolvers/mutations/bulk/bulkUpdateUserTags.ts b/packages/api/src/resolvers/mutations/bulk/bulkUpdateUserTags.ts index 79572c968e..ec6d76ceb2 100644 --- a/packages/api/src/resolvers/mutations/bulk/bulkUpdateUserTags.ts +++ b/packages/api/src/resolvers/mutations/bulk/bulkUpdateUserTags.ts @@ -8,31 +8,12 @@ export default async function bulkUpdateUserTags( ) { log(`mutation bulkUpdateUserTags for ${userIds.length} users`, { userId }); - const failedIds: string[] = []; - let successCount = 0; - - for (const targetUserId of userIds) { - try { - const user = await modules.users.findUserById(targetUserId); - if (!user) { - failedIds.push(targetUserId); - continue; - } - - let tags = user.tags || []; - if (remove?.length) { - tags = tags.filter((t) => !remove.includes(t)); - } - if (add?.length) { - tags = [...new Set([...tags, ...add])]; - } - - await modules.users.updateTags(targetUserId, tags); - successCount += 1; - } catch { - failedIds.push(targetUserId); - } + if (remove?.length) { + await modules.users.bulkRemoveTags(userIds, remove); + } + if (add?.length) { + await modules.users.bulkAddTags(userIds, add); } - return { successCount, failedCount: failedIds.length, failedIds }; + return { successCount: userIds.length, failedCount: 0, failedIds: [] }; } diff --git a/packages/api/src/resolvers/mutations/index.ts b/packages/api/src/resolvers/mutations/index.ts index 850b46db8c..b23e47b3be 100755 --- a/packages/api/src/resolvers/mutations/index.ts +++ b/packages/api/src/resolvers/mutations/index.ts @@ -155,6 +155,13 @@ import bulkUpdateProductTags from './bulk/bulkUpdateProductTags.ts'; import bulkAssignProductsToAssortment from './bulk/bulkAssignProductsToAssortment.ts'; import bulkRemoveProducts from './bulk/bulkRemoveProducts.ts'; import bulkUpdateUserTags from './bulk/bulkUpdateUserTags.ts'; +import bulkRemoveAssortments from './bulk/bulkRemoveAssortments.ts'; +import bulkUpdateAssortmentTags from './bulk/bulkUpdateAssortmentTags.ts'; +import bulkSetAssortmentActive from './bulk/bulkSetAssortmentActive.ts'; +import bulkRemoveFilters from './bulk/bulkRemoveFilters.ts'; +import bulkSetFilterActive from './bulk/bulkSetFilterActive.ts'; +import bulkRemoveUsers from './bulk/bulkRemoveUsers.ts'; +import bulkSetUserRoles from './bulk/bulkSetUserRoles.ts'; export default { logout: acl(actions.logout)(logout), @@ -321,4 +328,11 @@ export default { bulkAssignProductsToAssortment: acl(actions.manageAssortments)(bulkAssignProductsToAssortment), bulkRemoveProducts: acl(actions.manageProducts)(bulkRemoveProducts), bulkUpdateUserTags: acl(actions.manageUsers)(bulkUpdateUserTags), + bulkRemoveAssortments: acl(actions.manageAssortments)(bulkRemoveAssortments), + bulkUpdateAssortmentTags: acl(actions.manageAssortments)(bulkUpdateAssortmentTags), + bulkSetAssortmentActive: acl(actions.manageAssortments)(bulkSetAssortmentActive), + bulkRemoveFilters: acl(actions.manageFilters)(bulkRemoveFilters), + bulkSetFilterActive: acl(actions.manageFilters)(bulkSetFilterActive), + bulkRemoveUsers: acl(actions.manageUsers)(bulkRemoveUsers), + bulkSetUserRoles: acl(actions.manageUsers)(bulkSetUserRoles), }; diff --git a/packages/api/src/schema/mutation.ts b/packages/api/src/schema/mutation.ts index 0c6649efcf..a9923d5aa2 100644 --- a/packages/api/src/schema/mutation.ts +++ b/packages/api/src/schema/mutation.ts @@ -866,7 +866,11 @@ export default [ """ Add or remove tags from multiple products at once """ - bulkUpdateProductTags(productIds: [ID!]!, add: [LowerCaseString!], remove: [LowerCaseString!]): BulkOperationResult! + bulkUpdateProductTags( + productIds: [ID!]! + add: [LowerCaseString!] + remove: [LowerCaseString!] + ): BulkOperationResult! """ Assign multiple products to an assortment @@ -881,7 +885,50 @@ export default [ """ Add or remove tags from multiple users at once """ - bulkUpdateUserTags(userIds: [ID!]!, add: [LowerCaseString!], remove: [LowerCaseString!]): BulkOperationResult! + bulkUpdateUserTags( + userIds: [ID!]! + add: [LowerCaseString!] + remove: [LowerCaseString!] + ): BulkOperationResult! + + """ + Remove multiple users + """ + bulkRemoveUsers(userIds: [ID!]!): BulkOperationResult! + + """ + Set roles for multiple users at once + """ + bulkSetUserRoles(userIds: [ID!]!, roles: [String!]!): BulkOperationResult! + + """ + Remove multiple assortments + """ + bulkRemoveAssortments(assortmentIds: [ID!]!): BulkOperationResult! + + """ + Add or remove tags from multiple assortments at once + """ + bulkUpdateAssortmentTags( + assortmentIds: [ID!]! + add: [LowerCaseString!] + remove: [LowerCaseString!] + ): BulkOperationResult! + + """ + Activate or deactivate multiple assortments at once + """ + bulkSetAssortmentActive(assortmentIds: [ID!]!, isActive: Boolean!): BulkOperationResult! + + """ + Remove multiple filters + """ + bulkRemoveFilters(filterIds: [ID!]!): BulkOperationResult! + + """ + Activate or deactivate multiple filters at once + """ + bulkSetFilterActive(filterIds: [ID!]!, isActive: Boolean!): BulkOperationResult! """ Store user W3C Push subscription object diff --git a/packages/core-assortments/src/module/configureAssortmentsModule.ts b/packages/core-assortments/src/module/configureAssortmentsModule.ts index e7039b7ff1..6c2e2183a0 100644 --- a/packages/core-assortments/src/module/configureAssortmentsModule.ts +++ b/packages/core-assortments/src/module/configureAssortmentsModule.ts @@ -455,6 +455,83 @@ export const configureAssortmentsModule = async ( invalidateCache, + bulkDelete: async ( + assortmentIds: string[], + ): Promise<{ successIds: string[]; failedIds: string[] }> => { + const successIds: string[] = []; + const failedIds: string[] = []; + + const results = await Promise.allSettled( + assortmentIds.map(async (assortmentId) => { + await assortmentLinks.deleteMany( + { + $or: [{ parentAssortmentId: assortmentId }, { childAssortmentId: assortmentId }], + }, + { skipInvalidation: true }, + ); + await assortmentProducts.deleteMany({ assortmentId }, { skipInvalidation: true }); + await assortmentFilters.deleteMany({ assortmentId }); + await assortmentTexts.deleteMany({ assortmentId }); + await assortmentMedia.deleteMediaFiles({ assortmentId }); + + const deletedAssortment = await Assortments.findOneAndUpdate( + generateDbFilterById(assortmentId), + { $set: { deleted: new Date() } }, + { returnDocument: 'after' }, + ); + if (!deletedAssortment) throw new Error('not-found'); + await emit('ASSORTMENT_REMOVE', { assortmentId }); + return assortmentId; + }), + ); + + results.forEach((result, index) => { + if (result.status === 'fulfilled') { + successIds.push(result.value); + } else { + failedIds.push(assortmentIds[index]); + } + }); + + if (successIds.length > 0) { + await invalidateCache({}, { skipUpstreamTraversal: true }); + } + + return { successIds, failedIds }; + }, + + bulkSetActive: async (assortmentIds: string[], isActive: boolean): Promise => { + const result = await Assortments.updateMany( + { _id: { $in: assortmentIds } }, + { + $set: { isActive, updated: new Date() }, + }, + ); + return result.modifiedCount; + }, + + bulkAddTags: async (assortmentIds: string[], tags: string[]): Promise => { + const result = await Assortments.updateMany( + { _id: { $in: assortmentIds } }, + { + $addToSet: { tags: { $each: tags } }, + $set: { updated: new Date() }, + }, + ); + return result.modifiedCount; + }, + + bulkRemoveTags: async (assortmentIds: string[], tags: string[]): Promise => { + const result = await Assortments.updateMany( + { _id: { $in: assortmentIds } }, + { + $pullAll: { tags }, + $set: { updated: new Date() }, + }, + ); + return result.modifiedCount; + }, + search: { findFilteredAssortments: async ({ limit, diff --git a/packages/core-filters/src/module/configureFiltersModule.ts b/packages/core-filters/src/module/configureFiltersModule.ts index 77e8083f42..cbba4faa1a 100644 --- a/packages/core-filters/src/module/configureFiltersModule.ts +++ b/packages/core-filters/src/module/configureFiltersModule.ts @@ -196,6 +196,16 @@ export const configureFiltersModule = async ({ return filter; }, + bulkSetActive: async (filterIds: string[], isActive: boolean): Promise => { + const result = await Filters.updateMany( + { _id: { $in: filterIds } }, + { + $set: { isActive, updated: new Date() }, + }, + ); + return result.modifiedCount; + }, + texts: filterTexts, }; }; diff --git a/packages/core-products/src/module/configureProductsModule.ts b/packages/core-products/src/module/configureProductsModule.ts index f2dbc07b77..52e0d000bc 100644 --- a/packages/core-products/src/module/configureProductsModule.ts +++ b/packages/core-products/src/module/configureProductsModule.ts @@ -460,6 +460,72 @@ export const configureProductsModule = async (moduleInput: ModuleInput => { + const result = await Products.updateMany( + { _id: { $in: productIds }, status: InternalProductStatus.DRAFT }, + { + $set: { + status: ProductStatus.ACTIVE, + updated: new Date(), + published: new Date(), + }, + }, + ); + if (result.modifiedCount > 0) { + const published = await Products.find({ + _id: { $in: productIds }, + status: ProductStatus.ACTIVE, + }).toArray(); + await Promise.all(published.map((product) => emit('PRODUCT_PUBLISH', { product }))); + return published.map((p) => p._id); + } + return []; + }, + + bulkUnpublish: async (productIds: string[]): Promise => { + const activeProducts = await Products.find({ + _id: { $in: productIds }, + status: ProductStatus.ACTIVE, + }).toArray(); + const activeIds = activeProducts.map((p) => p._id); + if (activeIds.length > 0) { + await Products.updateMany( + { _id: { $in: activeIds } }, + { + $set: { + status: InternalProductStatus.DRAFT, + updated: new Date(), + }, + $unset: { published: 1 }, + }, + ); + await Promise.all(activeProducts.map((product) => emit('PRODUCT_UNPUBLISH', { product }))); + } + return activeIds; + }, + + bulkAddTags: async (productIds: string[], tags: string[]): Promise => { + const result = await Products.updateMany( + { _id: { $in: productIds } }, + { + $addToSet: { tags: { $each: tags } }, + $set: { updated: new Date() }, + }, + ); + return result.modifiedCount; + }, + + bulkRemoveTags: async (productIds: string[], tags: string[]): Promise => { + const result = await Products.updateMany( + { _id: { $in: productIds } }, + { + $pullAll: { tags }, + $set: { updated: new Date() }, + }, + ); + return result.modifiedCount; + }, + /* * Sub entities */ diff --git a/packages/core-users/src/module/configureUsersModule.ts b/packages/core-users/src/module/configureUsersModule.ts index 9a933414b5..7081d59b69 100644 --- a/packages/core-users/src/module/configureUsersModule.ts +++ b/packages/core-users/src/module/configureUsersModule.ts @@ -1085,6 +1085,38 @@ export const configureUsersModule = async (moduleInput: ModuleInput => { + const result = await Users.updateMany( + { _id: { $in: userIds } }, + { + $addToSet: { tags: { $each: tags } }, + $set: { updated: new Date() }, + }, + ); + return result.modifiedCount; + }, + + bulkRemoveTags: async (userIds: string[], tags: string[]): Promise => { + const result = await Users.updateMany( + { _id: { $in: userIds } }, + { + $pullAll: { tags }, + $set: { updated: new Date() }, + }, + ); + return result.modifiedCount; + }, + + bulkUpdateRoles: async (userIds: string[], roles: string[]): Promise => { + const result = await Users.updateMany( + { _id: { $in: userIds } }, + { + $set: { roles, updated: new Date() }, + }, + ); + return result.modifiedCount; + }, }; }; diff --git a/packages/core/src/services/bulkDeleteUsers.ts b/packages/core/src/services/bulkDeleteUsers.ts new file mode 100644 index 0000000000..09b517ca04 --- /dev/null +++ b/packages/core/src/services/bulkDeleteUsers.ts @@ -0,0 +1,28 @@ +import type { Modules } from '../modules.ts'; +import { deleteUserService } from './deleteUser.ts'; + +export async function bulkDeleteUsersService( + this: Modules, + { userIds }: { userIds: string[] }, +): Promise<{ successIds: string[]; failedIds: string[] }> { + const successIds: string[] = []; + const failedIds: string[] = []; + + const results = await Promise.allSettled( + userIds.map(async (userId) => { + const result = await deleteUserService.call(this, { userId }); + if (!result) throw new Error('delete-failed'); + return userId; + }), + ); + + results.forEach((result, index) => { + if (result.status === 'fulfilled') { + successIds.push(result.value); + } else { + failedIds.push(userIds[index]); + } + }); + + return { successIds, failedIds }; +} diff --git a/packages/core/src/services/bulkRemoveFilters.ts b/packages/core/src/services/bulkRemoveFilters.ts new file mode 100644 index 0000000000..43a7918089 --- /dev/null +++ b/packages/core/src/services/bulkRemoveFilters.ts @@ -0,0 +1,30 @@ +import type { Modules } from '../modules.ts'; +import { removeFilterService } from './removeFilter.ts'; + +export async function bulkRemoveFiltersService( + this: Modules, + { filterIds }: { filterIds: string[] }, +): Promise<{ successIds: string[]; failedIds: string[] }> { + const successIds: string[] = []; + const failedIds: string[] = []; + + const filters = await Promise.all(filterIds.map((filterId) => this.filters.findFilter({ filterId }))); + + const results = await Promise.allSettled( + filters.map(async (filter, index) => { + if (!filter) throw new Error('not-found'); + await removeFilterService.call(this, { filter }); + return filterIds[index]; + }), + ); + + results.forEach((result, index) => { + if (result.status === 'fulfilled') { + successIds.push(result.value); + } else { + failedIds.push(filterIds[index]); + } + }); + + return { successIds, failedIds }; +} diff --git a/packages/core/src/services/bulkRemoveProducts.ts b/packages/core/src/services/bulkRemoveProducts.ts new file mode 100644 index 0000000000..1542bdcae5 --- /dev/null +++ b/packages/core/src/services/bulkRemoveProducts.ts @@ -0,0 +1,28 @@ +import type { Modules } from '../modules.ts'; +import { removeProductService } from './removeProduct.ts'; + +export async function bulkRemoveProductsService( + this: Modules, + { productIds }: { productIds: string[] }, +): Promise<{ successIds: string[]; failedIds: string[] }> { + const successIds: string[] = []; + const failedIds: string[] = []; + + const results = await Promise.allSettled( + productIds.map(async (productId) => { + const result = await removeProductService.call(this, { productId }); + if (!result) throw new Error('already-deleted'); + return productId; + }), + ); + + results.forEach((result, index) => { + if (result.status === 'fulfilled') { + successIds.push(result.value); + } else { + failedIds.push(productIds[index]); + } + }); + + return { successIds, failedIds }; +} diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index 83c9ff0583..479547c221 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -57,6 +57,9 @@ import { simulateConfigurablePriceRangeService } from './simulateConfigurablePri import { createFileDownloadURLService } from './createFileDownloadURL.ts'; import { resolveTokenStatusService } from './resolveTokenStatus.ts'; import { isTokenInvalidateableService } from './isTokenInvalidateable.ts'; +import { bulkRemoveProductsService } from './bulkRemoveProducts.ts'; +import { bulkRemoveFiltersService } from './bulkRemoveFilters.ts'; +import { bulkDeleteUsersService } from './bulkDeleteUsers.ts'; // Auto-Inject Unchained API as last parameter // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy @@ -143,6 +146,7 @@ export default function initServices(modules: Modules, customServices: CustomSer typeof simulateProductInventoryService >, removeProduct: removeProductService as Bound, + bulkRemoveProducts: bulkRemoveProductsService as Bound, findProductSiblings: findProductSiblingsService as Bound, simulateConfigurablePriceRange: simulateConfigurablePriceRangeService as Bound< typeof simulateConfigurablePriceRangeService @@ -154,6 +158,7 @@ export default function initServices(modules: Modules, customServices: CustomSer typeof updateUserAvatarAfterUploadService >, deleteUser: deleteUserService as Bound, + bulkDeleteUsers: bulkDeleteUsersService as Bound, }, enrollments: { createEnrollmentFromCheckout: createEnrollmentFromCheckoutService as Bound< @@ -181,6 +186,7 @@ export default function initServices(modules: Modules, customServices: CustomSer loadFilters: loadFiltersService as Bound, loadFilterOptions: loadFilterOptionsService as Bound, removeFilter: removeFilterService as Bound, + bulkRemoveFilters: bulkRemoveFiltersService as Bound, }, warehousing: { ercMetadata: ercMetadataService as Bound, From ea95d9dc0b3633115fe3a437f74cc9687037b52b Mon Sep 17 00:00:00 2001 From: Mikael Araya Date: Thu, 4 Jun 2026 00:10:30 +0300 Subject: [PATCH 3/4] Add bulk operations support in admin-ui --- .../modules/accounts/components/UserList.tsx | 290 ++++++++++++++---- .../accounts/components/UserListItem.tsx | 18 +- .../accounts/hooks/useBulkUserOperations.ts | 70 +++++ .../assortment/components/AssortmentList.tsx | 226 +++++++++++--- .../components/AssortmentListItem.tsx | 19 +- .../hooks/useBulkAssortmentOperations.ts | 82 +++++ .../common/components/BulkActionsToolbar.tsx | 125 ++++++++ .../modules/common/components/BulkTagForm.tsx | 88 ++++++ .../modules/common/hooks/useBulkSelection.ts | 53 ++++ .../modules/filter/components/FilterList.tsx | 204 +++++++++--- .../filter/components/FilterListItem.tsx | 19 +- .../filter/hooks/useBulkFilterOperations.ts | 45 +++ .../product/components/ProductList.tsx | 245 ++++++++++++--- .../product/components/ProductListItem.tsx | 14 + .../product/hooks/useBulkProductOperations.ts | 98 ++++++ 15 files changed, 1410 insertions(+), 186 deletions(-) create mode 100644 admin-ui/src/modules/accounts/hooks/useBulkUserOperations.ts create mode 100644 admin-ui/src/modules/assortment/hooks/useBulkAssortmentOperations.ts create mode 100644 admin-ui/src/modules/common/components/BulkActionsToolbar.tsx create mode 100644 admin-ui/src/modules/common/components/BulkTagForm.tsx create mode 100644 admin-ui/src/modules/common/hooks/useBulkSelection.ts create mode 100644 admin-ui/src/modules/filter/hooks/useBulkFilterOperations.ts create mode 100644 admin-ui/src/modules/product/hooks/useBulkProductOperations.ts diff --git a/admin-ui/src/modules/accounts/components/UserList.tsx b/admin-ui/src/modules/accounts/components/UserList.tsx index 083aa56a5a..8b1c299a22 100644 --- a/admin-ui/src/modules/accounts/components/UserList.tsx +++ b/admin-ui/src/modules/accounts/components/UserList.tsx @@ -1,73 +1,255 @@ +import React from 'react'; import { useIntl } from 'react-intl'; +import { toast } from 'react-toastify'; +import { IRoleAction } from '../../../gql/types'; import useAuth from '../../Auth/useAuth'; import Table from '../../common/components/Table'; +import BulkActionsToolbar from '../../common/components/BulkActionsToolbar'; +import BulkTagForm from '../../common/components/BulkTagForm'; +import useBulkSelection from '../../common/hooks/useBulkSelection'; +import useBulkUserOperations from '../hooks/useBulkUserOperations'; +import useModal from '../../modal/hooks/useModal'; +import DangerMessage from '../../modal/components/DangerMessage'; import UserListItem from './UserListItem'; const UserList = ({ users }) => { const { formatMessage } = useIntl(); const { hasRole } = useAuth(); + const { setModal } = useModal(); - return ( - - {users?.map((user) => ( - - - {formatMessage({ - id: 'name', - defaultMessage: 'Name', - })} - - - - {formatMessage({ - id: 'email', - defaultMessage: 'Email', - })} - + const { + selectedIds, + selectedCount, + toggle, + clearAll, + isSelected, + toggleAll, + } = useBulkSelection(); - - {formatMessage({ - id: 'status', - defaultMessage: 'Status', - })} - + const { bulkUpdateUserTags, bulkRemoveUsers, bulkSetUserRoles } = + useBulkUserOperations(); - - {formatMessage({ - id: 'last_login', - defaultMessage: 'Last Login:', - })} - + const allIds = users?.map((u) => u._id) || []; + const canManage = hasRole(IRoleAction.RemoveUser); - - {formatMessage({ - id: 'tags', - defaultMessage: 'Tags', - })} - + const handleBulkResult = (result: any, operationName: string) => { + const data = result?.data?.[operationName]; + if (data) { + toast.success( + formatMessage( + { + id: 'bulk_operation_result', + defaultMessage: '{successCount} succeeded, {failedCount} failed', + }, + { + successCount: data.successCount, + failedCount: data.failedCount, + }, + ), + ); + } + }; - - {formatMessage({ - id: 'cart', - defaultMessage: 'Cart', - })} - + const bulkActions = canManage + ? [ + { + key: 'update-tags', + label: formatMessage({ + id: 'bulk_update_tags', + defaultMessage: 'Update Tags', + }), + renderForm: ({ onCancel }) => ( + { + const result = await bulkUpdateUserTags( + selectedIds, + add, + remove, + ); + handleBulkResult(result, 'bulkUpdateUserTags'); + clearAll(); + }} + onCancel={onCancel} + /> + ), + }, + { + key: 'set-roles', + label: formatMessage({ + id: 'bulk_set_roles', + defaultMessage: 'Set Roles', + }), + renderForm: ({ onCancel }) => ( + { + const result = await bulkSetUserRoles(selectedIds, roles); + handleBulkResult(result, 'bulkSetUserRoles'); + clearAll(); + }} + onCancel={onCancel} + /> + ), + }, + { + key: 'delete', + label: formatMessage({ + id: 'bulk_delete', + defaultMessage: 'Delete', + }), + variant: 'danger' as const, + onAction: async (ids: string[]) => { + await new Promise((resolve) => { + setModal( + { + setModal(''); + resolve(); + }} + message={formatMessage( + { + id: 'bulk_delete_users_warning', + defaultMessage: + 'This will permanently delete {count} users. Are you sure?', + }, + { count: ids.length }, + )} + onOkClick={async () => { + setModal(''); + const result = await bulkRemoveUsers(ids); + handleBulkResult(result, 'bulkRemoveUsers'); + resolve(); + }} + okText={formatMessage({ + id: 'delete_users', + defaultMessage: 'Delete Users', + })} + />, + ); + }); + }, + }, + ] + : []; - - {formatMessage({ - id: 'orders', - defaultMessage: 'Orders', - })} - + return ( + <> + +
+ {users?.map((user) => ( + + {canManage && ( + + 0 && selectedCount === allIds.length} + onChange={() => toggleAll(allIds)} + className="h-4 w-4 rounded border-slate-300 text-slate-800 focus:ring-slate-800 cursor-pointer" + onClick={(e) => e.stopPropagation()} + /> + + )} + + {formatMessage({ id: 'name', defaultMessage: 'Name' })} + + + {formatMessage({ id: 'email', defaultMessage: 'Email' })} + + + {formatMessage({ id: 'status', defaultMessage: 'Status' })} + + + {formatMessage({ + id: 'last_login', + defaultMessage: 'Last Login:', + })} + + + {formatMessage({ id: 'tags', defaultMessage: 'Tags' })} + + + {formatMessage({ id: 'cart', defaultMessage: 'Cart' })} + + + {formatMessage({ id: 'orders', defaultMessage: 'Orders' })} + +   + + ))} -   - - ))} + {users?.map((user) => ( + toggle(user._id)} + showCheckbox={canManage} + /> + ))} +
+ + ); +}; - {users?.map((user) => ( - - ))} - +const BulkRolesForm = ({ + onSubmit, + onCancel, +}: { + onSubmit: (roles: string[]) => void; + onCancel: () => void; +}) => { + const { formatMessage } = useIntl(); + const [roles, setRoles] = React.useState(''); + return ( +
{ + e.preventDefault(); + const parsed = roles + .split(',') + .map((r) => r.trim()) + .filter(Boolean); + if (parsed.length) onSubmit(parsed); + }} + className="flex flex-wrap items-end gap-3" + > +
+ + setRoles(e.target.value)} + placeholder="admin, editor" + className="text-sm px-2 py-1 rounded bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-500 text-slate-900 dark:text-white placeholder:text-slate-400 w-48" + /> +
+ + +
); }; diff --git a/admin-ui/src/modules/accounts/components/UserListItem.tsx b/admin-ui/src/modules/accounts/components/UserListItem.tsx index ba900654e9..a49f958cce 100644 --- a/admin-ui/src/modules/accounts/components/UserListItem.tsx +++ b/admin-ui/src/modules/accounts/components/UserListItem.tsx @@ -36,7 +36,12 @@ const UserLastLogin = ({ lastLogin }) => { ); }; -const UserListItem = ({ user }) => { +const UserListItem = ({ + user, + isSelected = false, + onToggleSelect = undefined, + showCheckbox = false, +}) => { const { formatMessage, locale } = useIntl(); const router = useRouter(); const { currentUser, loading } = useCurrentUser(); @@ -74,6 +79,17 @@ const UserListItem = ({ user }) => { return ( + {showCheckbox && ( + + e.stopPropagation()} + /> + + )} { + const [bulkUpdateTagsMutation] = useMutation(BulkUpdateUserTagsMutation); + const [bulkRemoveMutation] = useMutation(BulkRemoveUsersMutation); + const [bulkSetRolesMutation] = useMutation(BulkSetUserRolesMutation); + + return { + bulkUpdateUserTags: ( + userIds: string[], + add?: string[], + remove?: string[], + ) => + bulkUpdateTagsMutation({ + variables: { userIds, add, remove }, + refetchQueries, + }), + + bulkRemoveUsers: (userIds: string[]) => + bulkRemoveMutation({ + variables: { userIds }, + refetchQueries, + }), + + bulkSetUserRoles: (userIds: string[], roles: string[]) => + bulkSetRolesMutation({ + variables: { userIds, roles }, + refetchQueries, + }), + }; +}; + +export default useBulkUserOperations; diff --git a/admin-ui/src/modules/assortment/components/AssortmentList.tsx b/admin-ui/src/modules/assortment/components/AssortmentList.tsx index d237f69543..721badd3f2 100644 --- a/admin-ui/src/modules/assortment/components/AssortmentList.tsx +++ b/admin-ui/src/modules/assortment/components/AssortmentList.tsx @@ -1,51 +1,195 @@ import { useIntl } from 'react-intl'; +import { toast } from 'react-toastify'; +import { IRoleAction } from '../../../gql/types'; +import useAuth from '../../Auth/useAuth'; import Table from '../../common/components/Table'; +import BulkActionsToolbar from '../../common/components/BulkActionsToolbar'; +import BulkTagForm from '../../common/components/BulkTagForm'; +import useBulkSelection from '../../common/hooks/useBulkSelection'; +import useBulkAssortmentOperations from '../hooks/useBulkAssortmentOperations'; +import useModal from '../../modal/hooks/useModal'; +import DangerMessage from '../../modal/components/DangerMessage'; import AssortmentListItem from './AssortmentListItem'; const AssortmentList = ({ assortments, showAvatar = true, sortable }) => { const { formatMessage } = useIntl(); + const { hasRole } = useAuth(); + const { setModal } = useModal(); + + const { + selectedIds, + selectedCount, + toggle, + clearAll, + isSelected, + toggleAll, + } = useBulkSelection(); + + const { + bulkRemoveAssortments, + bulkUpdateAssortmentTags, + bulkSetAssortmentActive, + } = useBulkAssortmentOperations(); + + const allIds = assortments?.map((a) => a._id) || []; + const canManage = hasRole(IRoleAction.ManageAssortments); + + const handleBulkResult = (result: any, operationName: string) => { + const data = result?.data?.[operationName]; + if (data) { + toast.success( + formatMessage( + { + id: 'bulk_operation_result', + defaultMessage: '{successCount} succeeded, {failedCount} failed', + }, + { + successCount: data.successCount, + failedCount: data.failedCount, + }, + ), + ); + } + }; + + const bulkActions = canManage + ? [ + { + key: 'activate', + label: formatMessage({ + id: 'bulk_activate', + defaultMessage: 'Activate', + }), + onAction: async (ids: string[]) => { + const result = await bulkSetAssortmentActive(ids, true); + handleBulkResult(result, 'bulkSetAssortmentActive'); + }, + }, + { + key: 'deactivate', + label: formatMessage({ + id: 'bulk_deactivate', + defaultMessage: 'Deactivate', + }), + onAction: async (ids: string[]) => { + const result = await bulkSetAssortmentActive(ids, false); + handleBulkResult(result, 'bulkSetAssortmentActive'); + }, + }, + { + key: 'update-tags', + label: formatMessage({ + id: 'bulk_update_tags', + defaultMessage: 'Update Tags', + }), + renderForm: ({ onCancel }) => ( + { + const result = await bulkUpdateAssortmentTags( + selectedIds, + add, + remove, + ); + handleBulkResult(result, 'bulkUpdateAssortmentTags'); + clearAll(); + }} + onCancel={onCancel} + /> + ), + }, + { + key: 'delete', + label: formatMessage({ + id: 'bulk_delete', + defaultMessage: 'Delete', + }), + variant: 'danger' as const, + onAction: async (ids: string[]) => { + await new Promise((resolve) => { + setModal( + { + setModal(''); + resolve(); + }} + message={formatMessage( + { + id: 'bulk_delete_assortments_warning', + defaultMessage: + 'This will permanently delete {count} assortments. Are you sure?', + }, + { count: ids.length }, + )} + onOkClick={async () => { + setModal(''); + const result = await bulkRemoveAssortments(ids); + handleBulkResult(result, 'bulkRemoveAssortments'); + resolve(); + }} + okText={formatMessage({ + id: 'delete_assortments', + defaultMessage: 'Delete Assortments', + })} + />, + ); + }); + }, + }, + ] + : []; + return ( - - {assortments?.map((assortment) => ( - - - {formatMessage({ - id: 'name', - defaultMessage: 'Name', - })} - - - - {formatMessage({ - id: 'active', - defaultMessage: 'Active', - })} - - - - {formatMessage({ - id: 'root', - defaultMessage: 'Root', - })} - - - - {formatMessage({ - id: 'sequence', - defaultMessage: 'Display Order', - })} - -   - - ))} - {assortments?.map((assortment) => ( - - ))} -
+ <> + + + {assortments?.map((assortment) => ( + + {canManage && ( + + 0 && selectedCount === allIds.length} + onChange={() => toggleAll(allIds)} + className="h-4 w-4 rounded border-slate-300 text-slate-800 focus:ring-slate-800 cursor-pointer" + onClick={(e) => e.stopPropagation()} + /> + + )} + + {formatMessage({ id: 'name', defaultMessage: 'Name' })} + + + {formatMessage({ id: 'active', defaultMessage: 'Active' })} + + + {formatMessage({ id: 'root', defaultMessage: 'Root' })} + + + {formatMessage({ + id: 'sequence', + defaultMessage: 'Display Order', + })} + +   + + ))} + {assortments?.map((assortment) => ( + toggle(assortment._id)} + showCheckbox={canManage} + /> + ))} +
+ ); }; diff --git a/admin-ui/src/modules/assortment/components/AssortmentListItem.tsx b/admin-ui/src/modules/assortment/components/AssortmentListItem.tsx index a96ab3821e..2817b73b45 100644 --- a/admin-ui/src/modules/assortment/components/AssortmentListItem.tsx +++ b/admin-ui/src/modules/assortment/components/AssortmentListItem.tsx @@ -14,7 +14,13 @@ import DangerMessage from '../../modal/components/DangerMessage'; import useUpdateAssortment from '../hooks/useUpdateAssortment'; import useRemoveAssortment from '../hooks/useRemoveAssortment'; -const AssortmentListItem = ({ assortment, showAvatar }) => { +const AssortmentListItem = ({ + assortment, + showAvatar, + isSelected = false, + onToggleSelect = undefined, + showCheckbox = false, +}) => { const { formatMessage } = useIntl(); const router = useRouter(); const { hasRole } = useAuth(); @@ -62,6 +68,17 @@ const AssortmentListItem = ({ assortment, showAvatar }) => { return ( + {showCheckbox && ( + + e.stopPropagation()} + /> + + )}
{showAvatar && ( diff --git a/admin-ui/src/modules/assortment/hooks/useBulkAssortmentOperations.ts b/admin-ui/src/modules/assortment/hooks/useBulkAssortmentOperations.ts new file mode 100644 index 0000000000..62399bce8e --- /dev/null +++ b/admin-ui/src/modules/assortment/hooks/useBulkAssortmentOperations.ts @@ -0,0 +1,82 @@ +import { gql } from '@apollo/client'; +import { useMutation } from '@apollo/client/react'; + +const BulkRemoveAssortmentsMutation = gql` + mutation BulkRemoveAssortments($assortmentIds: [ID!]!) { + bulkRemoveAssortments(assortmentIds: $assortmentIds) { + successCount + failedCount + failedIds + } + } +`; + +const BulkUpdateAssortmentTagsMutation = gql` + mutation BulkUpdateAssortmentTags( + $assortmentIds: [ID!]! + $add: [LowerCaseString!] + $remove: [LowerCaseString!] + ) { + bulkUpdateAssortmentTags( + assortmentIds: $assortmentIds + add: $add + remove: $remove + ) { + successCount + failedCount + failedIds + } + } +`; + +const BulkSetAssortmentActiveMutation = gql` + mutation BulkSetAssortmentActive( + $assortmentIds: [ID!]! + $isActive: Boolean! + ) { + bulkSetAssortmentActive( + assortmentIds: $assortmentIds + isActive: $isActive + ) { + successCount + failedCount + failedIds + } + } +`; + +const refetchQueries = ['Assortments', 'AssortmentsCount']; + +const useBulkAssortmentOperations = () => { + const [bulkRemoveMutation] = useMutation(BulkRemoveAssortmentsMutation); + const [bulkUpdateTagsMutation] = useMutation( + BulkUpdateAssortmentTagsMutation, + ); + const [bulkSetActiveMutation] = useMutation(BulkSetAssortmentActiveMutation); + + return { + bulkRemoveAssortments: (assortmentIds: string[]) => + bulkRemoveMutation({ + variables: { assortmentIds }, + refetchQueries, + }), + + bulkUpdateAssortmentTags: ( + assortmentIds: string[], + add?: string[], + remove?: string[], + ) => + bulkUpdateTagsMutation({ + variables: { assortmentIds, add, remove }, + refetchQueries, + }), + + bulkSetAssortmentActive: (assortmentIds: string[], isActive: boolean) => + bulkSetActiveMutation({ + variables: { assortmentIds, isActive }, + refetchQueries, + }), + }; +}; + +export default useBulkAssortmentOperations; diff --git a/admin-ui/src/modules/common/components/BulkActionsToolbar.tsx b/admin-ui/src/modules/common/components/BulkActionsToolbar.tsx new file mode 100644 index 0000000000..87ddd79896 --- /dev/null +++ b/admin-ui/src/modules/common/components/BulkActionsToolbar.tsx @@ -0,0 +1,125 @@ +import React, { useState } from 'react'; +import { useIntl } from 'react-intl'; +import { XMarkIcon } from '@heroicons/react/20/solid'; + +export interface BulkAction { + key: string; + label: string; + variant?: 'danger' | 'default'; + renderForm?: (props: { + onSubmit: (data: any) => void; + onCancel: () => void; + }) => React.ReactNode; + onAction?: (selectedIds: string[]) => Promise; +} + +interface BulkActionsToolbarProps { + selectedCount: number; + selectedIds: string[]; + onClear: () => void; + actions: BulkAction[]; +} + +const BulkActionsToolbar: React.FC = ({ + selectedCount, + selectedIds, + onClear, + actions, +}) => { + const { formatMessage } = useIntl(); + const [activeAction, setActiveAction] = useState(null); + const [loading, setLoading] = useState(false); + + if (selectedCount === 0) return null; + + const handleAction = async (action: BulkAction) => { + if (action.renderForm) { + setActiveAction(action); + return; + } + if (action.onAction) { + setLoading(true); + try { + await action.onAction(selectedIds); + onClear(); + } finally { + setLoading(false); + } + } + }; + + const handleFormSubmit = async (data: any) => { + if (activeAction?.onAction) { + setLoading(true); + try { + await activeAction.onAction(selectedIds); + onClear(); + } finally { + setLoading(false); + setActiveAction(null); + } + } + }; + + return ( +
+
+ + {selectedCount} + + + {formatMessage( + { + id: 'bulk_selected', + defaultMessage: + '{count, plural, one {# selected} other {# selected}}', + }, + { count: selectedCount }, + )} + + +
+ +
+ +
+ {actions.map((action) => ( + + ))} +
+ + {activeAction?.renderForm && ( +
+ {activeAction.renderForm({ + onSubmit: handleFormSubmit, + onCancel: () => setActiveAction(null), + })} +
+ )} +
+ ); +}; + +export default BulkActionsToolbar; diff --git a/admin-ui/src/modules/common/components/BulkTagForm.tsx b/admin-ui/src/modules/common/components/BulkTagForm.tsx new file mode 100644 index 0000000000..09580e5cc4 --- /dev/null +++ b/admin-ui/src/modules/common/components/BulkTagForm.tsx @@ -0,0 +1,88 @@ +import React, { useState } from 'react'; +import { useIntl } from 'react-intl'; + +interface BulkTagFormProps { + onSubmit: (data: { add?: string[]; remove?: string[] }) => void; + onCancel: () => void; +} + +const BulkTagForm: React.FC = ({ onSubmit, onCancel }) => { + const { formatMessage } = useIntl(); + const [addTags, setAddTags] = useState(''); + const [removeTags, setRemoveTags] = useState(''); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const add = addTags + .split(',') + .map((t) => t.trim().toLowerCase()) + .filter(Boolean); + const remove = removeTags + .split(',') + .map((t) => t.trim().toLowerCase()) + .filter(Boolean); + onSubmit({ + add: add.length ? add : undefined, + remove: remove.length ? remove : undefined, + }); + }; + + return ( +
+
+ + setAddTags(e.target.value)} + placeholder="tag1, tag2" + className="text-sm px-2 py-1 rounded bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-500 text-slate-900 dark:text-white placeholder:text-slate-400 w-48" + /> +
+
+ + setRemoveTags(e.target.value)} + placeholder="tag1, tag2" + className="text-sm px-2 py-1 rounded bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-500 text-slate-900 dark:text-white placeholder:text-slate-400 w-48" + /> +
+ + +
+ ); +}; + +export default BulkTagForm; diff --git a/admin-ui/src/modules/common/hooks/useBulkSelection.ts b/admin-ui/src/modules/common/hooks/useBulkSelection.ts new file mode 100644 index 0000000000..068c910593 --- /dev/null +++ b/admin-ui/src/modules/common/hooks/useBulkSelection.ts @@ -0,0 +1,53 @@ +import { useState, useCallback, useMemo } from 'react'; + +const useBulkSelection = () => { + const [selectedIds, setSelectedIds] = useState>(new Set()); + + const toggle = useCallback((id: T) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }, []); + + const selectAll = useCallback((ids: T[]) => { + setSelectedIds(new Set(ids)); + }, []); + + const clearAll = useCallback(() => { + setSelectedIds(new Set()); + }, []); + + const isSelected = useCallback((id: T) => selectedIds.has(id), [selectedIds]); + + const toggleAll = useCallback( + (ids: T[]) => { + if (selectedIds.size === ids.length) { + clearAll(); + } else { + selectAll(ids); + } + }, + [selectedIds.size, selectAll, clearAll], + ); + + return useMemo( + () => ({ + selectedIds: Array.from(selectedIds), + selectedCount: selectedIds.size, + toggle, + selectAll, + clearAll, + isSelected, + toggleAll, + }), + [selectedIds, toggle, selectAll, clearAll, isSelected, toggleAll], + ); +}; + +export default useBulkSelection; diff --git a/admin-ui/src/modules/filter/components/FilterList.tsx b/admin-ui/src/modules/filter/components/FilterList.tsx index 6d3063b8ce..8e94d53f28 100644 --- a/admin-ui/src/modules/filter/components/FilterList.tsx +++ b/admin-ui/src/modules/filter/components/FilterList.tsx @@ -1,10 +1,16 @@ import { useIntl } from 'react-intl'; +import { toast } from 'react-toastify'; import { IRoleAction } from '../../../gql/types'; import useAuth from '../../Auth/useAuth'; import Loading from '@/components/ui/Loading'; import InfiniteScroll from '../../common/components/InfiniteScroll'; import Table from '../../common/components/Table'; +import BulkActionsToolbar from '../../common/components/BulkActionsToolbar'; +import useBulkSelection from '../../common/hooks/useBulkSelection'; +import useBulkFilterOperations from '../hooks/useBulkFilterOperations'; +import useModal from '../../modal/hooks/useModal'; +import DangerMessage from '../../modal/components/DangerMessage'; import useFilters from '../hooks/useFilters'; import FilterListItem from './FilterListItem'; import useApp from '../../common/hooks/useApp'; @@ -20,6 +26,7 @@ const FilterList = ({ const { formatMessage } = useIntl(); const { selectedLocale } = useApp(); const { hasRole } = useAuth(); + const { setModal } = useModal(); const { filters, loading, loadMore, hasMore } = useFilters({ queryString, limit, @@ -29,61 +36,174 @@ const FilterList = ({ forceLocale: selectedLocale, }); + const { + selectedIds, + selectedCount, + toggle, + clearAll, + isSelected, + toggleAll, + } = useBulkSelection(); + + const { bulkRemoveFilters, bulkSetFilterActive } = useBulkFilterOperations(); + + const allIds = filters?.map((f) => f._id) || []; + const canManage = hasRole(IRoleAction.ManageFilters); + + const handleBulkResult = (result: any, operationName: string) => { + const data = result?.data?.[operationName]; + if (data) { + toast.success( + formatMessage( + { + id: 'bulk_operation_result', + defaultMessage: '{successCount} succeeded, {failedCount} failed', + }, + { + successCount: data.successCount, + failedCount: data.failedCount, + }, + ), + ); + } + }; + + const bulkActions = canManage + ? [ + { + key: 'activate', + label: formatMessage({ + id: 'bulk_activate', + defaultMessage: 'Activate', + }), + onAction: async (ids: string[]) => { + const result = await bulkSetFilterActive(ids, true); + handleBulkResult(result, 'bulkSetFilterActive'); + }, + }, + { + key: 'deactivate', + label: formatMessage({ + id: 'bulk_deactivate', + defaultMessage: 'Deactivate', + }), + onAction: async (ids: string[]) => { + const result = await bulkSetFilterActive(ids, false); + handleBulkResult(result, 'bulkSetFilterActive'); + }, + }, + { + key: 'delete', + label: formatMessage({ + id: 'bulk_delete', + defaultMessage: 'Delete', + }), + variant: 'danger' as const, + onAction: async (ids: string[]) => { + await new Promise((resolve) => { + setModal( + { + setModal(''); + resolve(); + }} + message={formatMessage( + { + id: 'bulk_delete_filters_warning', + defaultMessage: + 'This will permanently delete {count} filters. Are you sure?', + }, + { count: ids.length }, + )} + onOkClick={async () => { + setModal(''); + const result = await bulkRemoveFilters(ids); + handleBulkResult(result, 'bulkRemoveFilters'); + resolve(); + }} + okText={formatMessage({ + id: 'delete_filters', + defaultMessage: 'Delete Filters', + })} + />, + ); + }); + }, + }, + ] + : []; + if (loading && filters?.length === 0) { return ; } return ( - - - {filters?.map((filter) => ( - - - {formatMessage({ - id: 'filter_key', - defaultMessage: 'Key', - description: 'Filter form key', - })} - + <> + + +
+ {filters?.map((filter) => ( + + {canManage && ( + + 0 && selectedCount === allIds.length + } + onChange={() => toggleAll(allIds)} + className="h-4 w-4 rounded border-slate-300 text-slate-800 focus:ring-slate-800 cursor-pointer" + onClick={(e) => e.stopPropagation()} + /> + + )} + + {formatMessage({ + id: 'filter_key', + defaultMessage: 'Key', + description: 'Filter form key', + })} + - - {formatMessage({ - id: 'type', - defaultMessage: 'Type', - })} - + + {formatMessage({ id: 'type', defaultMessage: 'Type' })} + - - {formatMessage({ - id: 'active', - defaultMessage: 'Active', - })} - + + {formatMessage({ id: 'active', defaultMessage: 'Active' })} + - - {formatMessage({ - id: 'options', - defaultMessage: 'Options', - })} - - {hasRole(IRoleAction.ManageFilters) && ( + + {formatMessage({ + id: 'options', + defaultMessage: 'Options', + })} + {formatMessage({ id: 'delete', defaultMessage: 'Delete' })} - )} - - ))} - {filters?.map((filter) => ( - - ))} -
-
+ + ))} + {filters?.map((filter) => ( + toggle(filter._id)} + showCheckbox={canManage} + /> + ))} + + + ); }; diff --git a/admin-ui/src/modules/filter/components/FilterListItem.tsx b/admin-ui/src/modules/filter/components/FilterListItem.tsx index e5d6e0dd4b..cfa9ebd8e4 100644 --- a/admin-ui/src/modules/filter/components/FilterListItem.tsx +++ b/admin-ui/src/modules/filter/components/FilterListItem.tsx @@ -13,7 +13,13 @@ const FILTER_TYPES = { SINGLE_CHOICE: 'lime', MULTI_CHOICE: 'cyan', }; -const FilterListItem = ({ filter, onRemove }) => { +const FilterListItem = ({ + filter, + onRemove, + isSelected = false, + onToggleSelect = undefined, + showCheckbox = false, +}) => { const { formatMessage } = useIntl(); const { hasRole } = useAuth(); const router = useRouter(); @@ -31,6 +37,17 @@ const FilterListItem = ({ filter, onRemove }) => { }; return ( + {showCheckbox && ( + + e.stopPropagation()} + /> + + )} { + const [bulkRemoveMutation] = useMutation(BulkRemoveFiltersMutation); + const [bulkSetActiveMutation] = useMutation(BulkSetFilterActiveMutation); + + return { + bulkRemoveFilters: (filterIds: string[]) => + bulkRemoveMutation({ + variables: { filterIds }, + refetchQueries, + }), + + bulkSetFilterActive: (filterIds: string[], isActive: boolean) => + bulkSetActiveMutation({ + variables: { filterIds, isActive }, + refetchQueries, + }), + }; +}; + +export default useBulkFilterOperations; diff --git a/admin-ui/src/modules/product/components/ProductList.tsx b/admin-ui/src/modules/product/components/ProductList.tsx index 2070774447..edee2fd9b1 100644 --- a/admin-ui/src/modules/product/components/ProductList.tsx +++ b/admin-ui/src/modules/product/components/ProductList.tsx @@ -1,10 +1,19 @@ import { useIntl } from 'react-intl'; +import { toast } from 'react-toastify'; import Loading from '@/components/ui/Loading'; import InfiniteScroll from '../../common/components/InfiniteScroll'; import Table from '../../common/components/Table'; +import BulkActionsToolbar from '../../common/components/BulkActionsToolbar'; +import BulkTagForm from '../../common/components/BulkTagForm'; import useProducts from '../hooks/useProducts'; +import useBulkProductOperations from '../hooks/useBulkProductOperations'; +import useBulkSelection from '../../common/hooks/useBulkSelection'; +import useAuth from '../../Auth/useAuth'; +import { IRoleAction } from '../../../gql/types'; import ProductListItem from './ProductListItem'; import useApp from '../../common/hooks/useApp'; +import useModal from '../../modal/hooks/useModal'; +import DangerMessage from '../../modal/components/DangerMessage'; const ProductList = ({ showAvatar = true, @@ -18,6 +27,8 @@ const ProductList = ({ }) => { const { formatMessage } = useIntl(); const { selectedLocale } = useApp(); + const { hasRole } = useAuth(); + const { setModal } = useModal(); const { products, loading, loadMore, hasMore } = useProducts({ queryString, includeDrafts, @@ -28,58 +39,200 @@ const ProductList = ({ forceLocale: selectedLocale, }); + const { + selectedIds, + selectedCount, + toggle, + clearAll, + isSelected, + toggleAll, + } = useBulkSelection(); + + const { bulkSetProductStatus, bulkUpdateProductTags, bulkRemoveProducts } = + useBulkProductOperations(); + + const allIds = products?.map((p) => p._id) || []; + + const handleBulkResult = (result: any, operationName: string) => { + const data = result?.data?.[operationName]; + if (data) { + toast.success( + formatMessage( + { + id: 'bulk_operation_result', + defaultMessage: '{successCount} succeeded, {failedCount} failed', + }, + { + successCount: data.successCount, + failedCount: data.failedCount, + }, + ), + ); + } + }; + + const bulkActions = hasRole(IRoleAction.ManageProducts) + ? [ + { + key: 'set-active', + label: formatMessage({ + id: 'bulk_set_active', + defaultMessage: 'Set Active', + }), + onAction: async (ids: string[]) => { + const result = await bulkSetProductStatus(ids, 'ACTIVE'); + handleBulkResult(result, 'bulkSetProductStatus'); + }, + }, + { + key: 'set-draft', + label: formatMessage({ + id: 'bulk_set_draft', + defaultMessage: 'Set Draft', + }), + onAction: async (ids: string[]) => { + const result = await bulkSetProductStatus(ids, 'DRAFT'); + handleBulkResult(result, 'bulkSetProductStatus'); + }, + }, + { + key: 'update-tags', + label: formatMessage({ + id: 'bulk_update_tags', + defaultMessage: 'Update Tags', + }), + renderForm: ({ onCancel }) => ( + { + const result = await bulkUpdateProductTags( + selectedIds, + add, + remove, + ); + handleBulkResult(result, 'bulkUpdateProductTags'); + clearAll(); + }} + onCancel={onCancel} + /> + ), + }, + { + key: 'delete', + label: formatMessage({ + id: 'bulk_delete', + defaultMessage: 'Delete', + }), + variant: 'danger' as const, + onAction: async (ids: string[]) => { + await new Promise((resolve) => { + setModal( + { + setModal(''); + resolve(); + }} + message={formatMessage( + { + id: 'bulk_delete_products_warning', + defaultMessage: + 'This will permanently delete {count} products. Are you sure?', + }, + { count: ids.length }, + )} + onOkClick={async () => { + setModal(''); + const result = await bulkRemoveProducts(ids); + handleBulkResult(result, 'bulkRemoveProducts'); + resolve(); + }} + okText={formatMessage({ + id: 'delete_products', + defaultMessage: 'Delete Products', + })} + />, + ); + }); + }, + }, + ] + : []; + if (loading && products?.length === 0) { return ; } return ( - - - {products?.map((product) => ( - - - {formatMessage({ - id: 'name', - defaultMessage: 'Name', - })} - - - {formatMessage({ - id: 'type', - defaultMessage: 'Type', - })} - - - {formatMessage({ - id: 'status', - defaultMessage: 'Status', - })} - + <> + + +
+ {products?.map((product) => ( + + {hasRole(IRoleAction.ManageProducts) && ( + + 0 && selectedCount === allIds.length + } + onChange={() => toggleAll(allIds)} + className="h-4 w-4 rounded border-slate-300 text-slate-800 focus:ring-slate-800 cursor-pointer" + onClick={(e) => e.stopPropagation()} + /> + + )} + + {formatMessage({ + id: 'name', + defaultMessage: 'Name', + })} + + + {formatMessage({ + id: 'type', + defaultMessage: 'Type', + })} + + + {formatMessage({ + id: 'status', + defaultMessage: 'Status', + })} + - - {formatMessage({ - id: 'tags', - defaultMessage: 'Tags', - })} - - - {formatMessage({ - id: 'sequence', - defaultMessage: 'Display Order', - })} - -   - - ))} + + {formatMessage({ + id: 'tags', + defaultMessage: 'Tags', + })} + + + {formatMessage({ + id: 'sequence', + defaultMessage: 'Display Order', + })} + +   + + ))} - {products?.map((product) => ( - - ))} -
-
+ {products?.map((product) => ( + toggle(product._id)} + showCheckbox={hasRole(IRoleAction.ManageProducts)} + /> + ))} + + + ); }; diff --git a/admin-ui/src/modules/product/components/ProductListItem.tsx b/admin-ui/src/modules/product/components/ProductListItem.tsx index bc8001aafb..49601f54e4 100644 --- a/admin-ui/src/modules/product/components/ProductListItem.tsx +++ b/admin-ui/src/modules/product/components/ProductListItem.tsx @@ -20,6 +20,9 @@ const ProductListItem = ({ product, showAvatar = false, hideSortIndex = false, + isSelected = false, + onToggleSelect = undefined, + showCheckbox = false, }) => { const { formatMessage } = useIntl(); const router = useRouter(); @@ -75,6 +78,17 @@ const ProductListItem = ({ ); return ( + {showCheckbox && ( + + e.stopPropagation()} + /> + + )} {product?.status !== 'DELETED' ? ( { + const [bulkSetStatusMutation] = useMutation(BulkSetProductStatusMutation); + const [bulkUpdateTagsMutation] = useMutation(BulkUpdateProductTagsMutation); + const [bulkRemoveMutation] = useMutation(BulkRemoveProductsMutation); + const [bulkAssignToAssortmentMutation] = useMutation( + BulkAssignProductsToAssortmentMutation, + ); + + return { + bulkSetProductStatus: (productIds: string[], status: string) => + bulkSetStatusMutation({ + variables: { productIds, status }, + refetchQueries, + }), + + bulkUpdateProductTags: ( + productIds: string[], + add?: string[], + remove?: string[], + ) => + bulkUpdateTagsMutation({ + variables: { productIds, add, remove }, + refetchQueries, + }), + + bulkRemoveProducts: (productIds: string[]) => + bulkRemoveMutation({ + variables: { productIds }, + refetchQueries, + }), + + bulkAssignProductsToAssortment: ( + productIds: string[], + assortmentId: string, + ) => + bulkAssignToAssortmentMutation({ + variables: { productIds, assortmentId }, + refetchQueries, + }), + }; +}; + +export default useBulkProductOperations; From 0b3037fb0762205f12f8fbb620a0b06ad3d083f3 Mon Sep 17 00:00:00 2001 From: Mikael Araya Date: Sun, 7 Jun 2026 19:12:47 +0300 Subject: [PATCH 4/4] Optimize bulk importer operations --- .../modules/accounts/components/UserList.tsx | 41 ++++--------- .../assortment/components/AssortmentList.tsx | 47 ++++++--------- .../common/hooks/useBulkResultHandler.ts | 59 +++++++++++++++++++ .../modules/filter/components/FilterList.tsx | 39 +++++------- .../product/components/ProductList.tsx | 47 ++++++--------- packages/core/src/services/bulkDeleteUsers.ts | 23 ++------ .../core/src/services/bulkRemoveFilters.ts | 26 ++------ .../core/src/services/bulkRemoveProducts.ts | 23 ++------ .../core/src/services/executeBulkOperation.ts | 19 ++++++ 9 files changed, 152 insertions(+), 172 deletions(-) create mode 100644 admin-ui/src/modules/common/hooks/useBulkResultHandler.ts create mode 100644 packages/core/src/services/executeBulkOperation.ts diff --git a/admin-ui/src/modules/accounts/components/UserList.tsx b/admin-ui/src/modules/accounts/components/UserList.tsx index 8b1c299a22..72f087df99 100644 --- a/admin-ui/src/modules/accounts/components/UserList.tsx +++ b/admin-ui/src/modules/accounts/components/UserList.tsx @@ -1,12 +1,12 @@ import React from 'react'; import { useIntl } from 'react-intl'; -import { toast } from 'react-toastify'; import { IRoleAction } from '../../../gql/types'; import useAuth from '../../Auth/useAuth'; import Table from '../../common/components/Table'; import BulkActionsToolbar from '../../common/components/BulkActionsToolbar'; import BulkTagForm from '../../common/components/BulkTagForm'; import useBulkSelection from '../../common/hooks/useBulkSelection'; +import useBulkResultHandler from '../../common/hooks/useBulkResultHandler'; import useBulkUserOperations from '../hooks/useBulkUserOperations'; import useModal from '../../modal/hooks/useModal'; import DangerMessage from '../../modal/components/DangerMessage'; @@ -28,28 +28,11 @@ const UserList = ({ users }) => { const { bulkUpdateUserTags, bulkRemoveUsers, bulkSetUserRoles } = useBulkUserOperations(); + const handleBulkResult = useBulkResultHandler(); const allIds = users?.map((u) => u._id) || []; const canManage = hasRole(IRoleAction.RemoveUser); - const handleBulkResult = (result: any, operationName: string) => { - const data = result?.data?.[operationName]; - if (data) { - toast.success( - formatMessage( - { - id: 'bulk_operation_result', - defaultMessage: '{successCount} succeeded, {failedCount} failed', - }, - { - successCount: data.successCount, - failedCount: data.failedCount, - }, - ), - ); - } - }; - const bulkActions = canManage ? [ { @@ -61,12 +44,10 @@ const UserList = ({ users }) => { renderForm: ({ onCancel }) => ( { - const result = await bulkUpdateUserTags( - selectedIds, - add, - remove, + await handleBulkResult( + () => bulkUpdateUserTags(selectedIds, add, remove), + 'bulkUpdateUserTags', ); - handleBulkResult(result, 'bulkUpdateUserTags'); clearAll(); }} onCancel={onCancel} @@ -82,8 +63,10 @@ const UserList = ({ users }) => { renderForm: ({ onCancel }) => ( { - const result = await bulkSetUserRoles(selectedIds, roles); - handleBulkResult(result, 'bulkSetUserRoles'); + await handleBulkResult( + () => bulkSetUserRoles(selectedIds, roles), + 'bulkSetUserRoles', + ); clearAll(); }} onCancel={onCancel} @@ -115,8 +98,10 @@ const UserList = ({ users }) => { )} onOkClick={async () => { setModal(''); - const result = await bulkRemoveUsers(ids); - handleBulkResult(result, 'bulkRemoveUsers'); + await handleBulkResult( + () => bulkRemoveUsers(ids), + 'bulkRemoveUsers', + ); resolve(); }} okText={formatMessage({ diff --git a/admin-ui/src/modules/assortment/components/AssortmentList.tsx b/admin-ui/src/modules/assortment/components/AssortmentList.tsx index 721badd3f2..ead9e39060 100644 --- a/admin-ui/src/modules/assortment/components/AssortmentList.tsx +++ b/admin-ui/src/modules/assortment/components/AssortmentList.tsx @@ -1,11 +1,11 @@ import { useIntl } from 'react-intl'; -import { toast } from 'react-toastify'; import { IRoleAction } from '../../../gql/types'; import useAuth from '../../Auth/useAuth'; import Table from '../../common/components/Table'; import BulkActionsToolbar from '../../common/components/BulkActionsToolbar'; import BulkTagForm from '../../common/components/BulkTagForm'; import useBulkSelection from '../../common/hooks/useBulkSelection'; +import useBulkResultHandler from '../../common/hooks/useBulkResultHandler'; import useBulkAssortmentOperations from '../hooks/useBulkAssortmentOperations'; import useModal from '../../modal/hooks/useModal'; import DangerMessage from '../../modal/components/DangerMessage'; @@ -30,28 +30,11 @@ const AssortmentList = ({ assortments, showAvatar = true, sortable }) => { bulkUpdateAssortmentTags, bulkSetAssortmentActive, } = useBulkAssortmentOperations(); + const handleBulkResult = useBulkResultHandler(); const allIds = assortments?.map((a) => a._id) || []; const canManage = hasRole(IRoleAction.ManageAssortments); - const handleBulkResult = (result: any, operationName: string) => { - const data = result?.data?.[operationName]; - if (data) { - toast.success( - formatMessage( - { - id: 'bulk_operation_result', - defaultMessage: '{successCount} succeeded, {failedCount} failed', - }, - { - successCount: data.successCount, - failedCount: data.failedCount, - }, - ), - ); - } - }; - const bulkActions = canManage ? [ { @@ -61,8 +44,10 @@ const AssortmentList = ({ assortments, showAvatar = true, sortable }) => { defaultMessage: 'Activate', }), onAction: async (ids: string[]) => { - const result = await bulkSetAssortmentActive(ids, true); - handleBulkResult(result, 'bulkSetAssortmentActive'); + await handleBulkResult( + () => bulkSetAssortmentActive(ids, true), + 'bulkSetAssortmentActive', + ); }, }, { @@ -72,8 +57,10 @@ const AssortmentList = ({ assortments, showAvatar = true, sortable }) => { defaultMessage: 'Deactivate', }), onAction: async (ids: string[]) => { - const result = await bulkSetAssortmentActive(ids, false); - handleBulkResult(result, 'bulkSetAssortmentActive'); + await handleBulkResult( + () => bulkSetAssortmentActive(ids, false), + 'bulkSetAssortmentActive', + ); }, }, { @@ -85,12 +72,10 @@ const AssortmentList = ({ assortments, showAvatar = true, sortable }) => { renderForm: ({ onCancel }) => ( { - const result = await bulkUpdateAssortmentTags( - selectedIds, - add, - remove, + await handleBulkResult( + () => bulkUpdateAssortmentTags(selectedIds, add, remove), + 'bulkUpdateAssortmentTags', ); - handleBulkResult(result, 'bulkUpdateAssortmentTags'); clearAll(); }} onCancel={onCancel} @@ -122,8 +107,10 @@ const AssortmentList = ({ assortments, showAvatar = true, sortable }) => { )} onOkClick={async () => { setModal(''); - const result = await bulkRemoveAssortments(ids); - handleBulkResult(result, 'bulkRemoveAssortments'); + await handleBulkResult( + () => bulkRemoveAssortments(ids), + 'bulkRemoveAssortments', + ); resolve(); }} okText={formatMessage({ diff --git a/admin-ui/src/modules/common/hooks/useBulkResultHandler.ts b/admin-ui/src/modules/common/hooks/useBulkResultHandler.ts new file mode 100644 index 0000000000..f6df2e936b --- /dev/null +++ b/admin-ui/src/modules/common/hooks/useBulkResultHandler.ts @@ -0,0 +1,59 @@ +import { useCallback } from 'react'; +import { useIntl } from 'react-intl'; +import { toast } from 'react-toastify'; + +const useBulkResultHandler = () => { + const { formatMessage } = useIntl(); + + return useCallback( + async ( + operation: () => Promise, + operationName: string, + ): Promise => { + try { + const result = await operation(); + const data = result?.data?.[operationName]; + if (data) { + if (data.failedCount > 0) { + toast.warning( + formatMessage( + { + id: 'bulk_operation_result', + defaultMessage: + '{successCount} succeeded, {failedCount} failed', + }, + { + successCount: data.successCount, + failedCount: data.failedCount, + }, + ), + ); + } else { + toast.success( + formatMessage( + { + id: 'bulk_operation_success', + defaultMessage: '{successCount} succeeded', + }, + { successCount: data.successCount }, + ), + ); + } + return true; + } + return false; + } catch (error) { + toast.error( + formatMessage({ + id: 'bulk_operation_error', + defaultMessage: 'Operation failed. Please try again.', + }), + ); + return false; + } + }, + [formatMessage], + ); +}; + +export default useBulkResultHandler; diff --git a/admin-ui/src/modules/filter/components/FilterList.tsx b/admin-ui/src/modules/filter/components/FilterList.tsx index 8e94d53f28..5cae041371 100644 --- a/admin-ui/src/modules/filter/components/FilterList.tsx +++ b/admin-ui/src/modules/filter/components/FilterList.tsx @@ -1,5 +1,4 @@ import { useIntl } from 'react-intl'; -import { toast } from 'react-toastify'; import { IRoleAction } from '../../../gql/types'; import useAuth from '../../Auth/useAuth'; @@ -8,6 +7,7 @@ import InfiniteScroll from '../../common/components/InfiniteScroll'; import Table from '../../common/components/Table'; import BulkActionsToolbar from '../../common/components/BulkActionsToolbar'; import useBulkSelection from '../../common/hooks/useBulkSelection'; +import useBulkResultHandler from '../../common/hooks/useBulkResultHandler'; import useBulkFilterOperations from '../hooks/useBulkFilterOperations'; import useModal from '../../modal/hooks/useModal'; import DangerMessage from '../../modal/components/DangerMessage'; @@ -46,28 +46,11 @@ const FilterList = ({ } = useBulkSelection(); const { bulkRemoveFilters, bulkSetFilterActive } = useBulkFilterOperations(); + const handleBulkResult = useBulkResultHandler(); const allIds = filters?.map((f) => f._id) || []; const canManage = hasRole(IRoleAction.ManageFilters); - const handleBulkResult = (result: any, operationName: string) => { - const data = result?.data?.[operationName]; - if (data) { - toast.success( - formatMessage( - { - id: 'bulk_operation_result', - defaultMessage: '{successCount} succeeded, {failedCount} failed', - }, - { - successCount: data.successCount, - failedCount: data.failedCount, - }, - ), - ); - } - }; - const bulkActions = canManage ? [ { @@ -77,8 +60,10 @@ const FilterList = ({ defaultMessage: 'Activate', }), onAction: async (ids: string[]) => { - const result = await bulkSetFilterActive(ids, true); - handleBulkResult(result, 'bulkSetFilterActive'); + await handleBulkResult( + () => bulkSetFilterActive(ids, true), + 'bulkSetFilterActive', + ); }, }, { @@ -88,8 +73,10 @@ const FilterList = ({ defaultMessage: 'Deactivate', }), onAction: async (ids: string[]) => { - const result = await bulkSetFilterActive(ids, false); - handleBulkResult(result, 'bulkSetFilterActive'); + await handleBulkResult( + () => bulkSetFilterActive(ids, false), + 'bulkSetFilterActive', + ); }, }, { @@ -117,8 +104,10 @@ const FilterList = ({ )} onOkClick={async () => { setModal(''); - const result = await bulkRemoveFilters(ids); - handleBulkResult(result, 'bulkRemoveFilters'); + await handleBulkResult( + () => bulkRemoveFilters(ids), + 'bulkRemoveFilters', + ); resolve(); }} okText={formatMessage({ diff --git a/admin-ui/src/modules/product/components/ProductList.tsx b/admin-ui/src/modules/product/components/ProductList.tsx index edee2fd9b1..a53ebcc002 100644 --- a/admin-ui/src/modules/product/components/ProductList.tsx +++ b/admin-ui/src/modules/product/components/ProductList.tsx @@ -1,5 +1,4 @@ import { useIntl } from 'react-intl'; -import { toast } from 'react-toastify'; import Loading from '@/components/ui/Loading'; import InfiniteScroll from '../../common/components/InfiniteScroll'; import Table from '../../common/components/Table'; @@ -8,6 +7,7 @@ import BulkTagForm from '../../common/components/BulkTagForm'; import useProducts from '../hooks/useProducts'; import useBulkProductOperations from '../hooks/useBulkProductOperations'; import useBulkSelection from '../../common/hooks/useBulkSelection'; +import useBulkResultHandler from '../../common/hooks/useBulkResultHandler'; import useAuth from '../../Auth/useAuth'; import { IRoleAction } from '../../../gql/types'; import ProductListItem from './ProductListItem'; @@ -50,27 +50,10 @@ const ProductList = ({ const { bulkSetProductStatus, bulkUpdateProductTags, bulkRemoveProducts } = useBulkProductOperations(); + const handleBulkResult = useBulkResultHandler(); const allIds = products?.map((p) => p._id) || []; - const handleBulkResult = (result: any, operationName: string) => { - const data = result?.data?.[operationName]; - if (data) { - toast.success( - formatMessage( - { - id: 'bulk_operation_result', - defaultMessage: '{successCount} succeeded, {failedCount} failed', - }, - { - successCount: data.successCount, - failedCount: data.failedCount, - }, - ), - ); - } - }; - const bulkActions = hasRole(IRoleAction.ManageProducts) ? [ { @@ -80,8 +63,10 @@ const ProductList = ({ defaultMessage: 'Set Active', }), onAction: async (ids: string[]) => { - const result = await bulkSetProductStatus(ids, 'ACTIVE'); - handleBulkResult(result, 'bulkSetProductStatus'); + await handleBulkResult( + () => bulkSetProductStatus(ids, 'ACTIVE'), + 'bulkSetProductStatus', + ); }, }, { @@ -91,8 +76,10 @@ const ProductList = ({ defaultMessage: 'Set Draft', }), onAction: async (ids: string[]) => { - const result = await bulkSetProductStatus(ids, 'DRAFT'); - handleBulkResult(result, 'bulkSetProductStatus'); + await handleBulkResult( + () => bulkSetProductStatus(ids, 'DRAFT'), + 'bulkSetProductStatus', + ); }, }, { @@ -104,12 +91,10 @@ const ProductList = ({ renderForm: ({ onCancel }) => ( { - const result = await bulkUpdateProductTags( - selectedIds, - add, - remove, + await handleBulkResult( + () => bulkUpdateProductTags(selectedIds, add, remove), + 'bulkUpdateProductTags', ); - handleBulkResult(result, 'bulkUpdateProductTags'); clearAll(); }} onCancel={onCancel} @@ -141,8 +126,10 @@ const ProductList = ({ )} onOkClick={async () => { setModal(''); - const result = await bulkRemoveProducts(ids); - handleBulkResult(result, 'bulkRemoveProducts'); + await handleBulkResult( + () => bulkRemoveProducts(ids), + 'bulkRemoveProducts', + ); resolve(); }} okText={formatMessage({ diff --git a/packages/core/src/services/bulkDeleteUsers.ts b/packages/core/src/services/bulkDeleteUsers.ts index 09b517ca04..e559604dba 100644 --- a/packages/core/src/services/bulkDeleteUsers.ts +++ b/packages/core/src/services/bulkDeleteUsers.ts @@ -1,28 +1,13 @@ import type { Modules } from '../modules.ts'; import { deleteUserService } from './deleteUser.ts'; +import { executeBulkOperation } from './executeBulkOperation.ts'; export async function bulkDeleteUsersService( this: Modules, { userIds }: { userIds: string[] }, ): Promise<{ successIds: string[]; failedIds: string[] }> { - const successIds: string[] = []; - const failedIds: string[] = []; - - const results = await Promise.allSettled( - userIds.map(async (userId) => { - const result = await deleteUserService.call(this, { userId }); - if (!result) throw new Error('delete-failed'); - return userId; - }), - ); - - results.forEach((result, index) => { - if (result.status === 'fulfilled') { - successIds.push(result.value); - } else { - failedIds.push(userIds[index]); - } + return executeBulkOperation(userIds, async (userId) => { + const result = await deleteUserService.call(this, { userId }); + if (!result) throw new Error('delete-failed'); }); - - return { successIds, failedIds }; } diff --git a/packages/core/src/services/bulkRemoveFilters.ts b/packages/core/src/services/bulkRemoveFilters.ts index 43a7918089..41a504eeda 100644 --- a/packages/core/src/services/bulkRemoveFilters.ts +++ b/packages/core/src/services/bulkRemoveFilters.ts @@ -1,30 +1,14 @@ import type { Modules } from '../modules.ts'; import { removeFilterService } from './removeFilter.ts'; +import { executeBulkOperation } from './executeBulkOperation.ts'; export async function bulkRemoveFiltersService( this: Modules, { filterIds }: { filterIds: string[] }, ): Promise<{ successIds: string[]; failedIds: string[] }> { - const successIds: string[] = []; - const failedIds: string[] = []; - - const filters = await Promise.all(filterIds.map((filterId) => this.filters.findFilter({ filterId }))); - - const results = await Promise.allSettled( - filters.map(async (filter, index) => { - if (!filter) throw new Error('not-found'); - await removeFilterService.call(this, { filter }); - return filterIds[index]; - }), - ); - - results.forEach((result, index) => { - if (result.status === 'fulfilled') { - successIds.push(result.value); - } else { - failedIds.push(filterIds[index]); - } + return executeBulkOperation(filterIds, async (filterId) => { + const filter = await this.filters.findFilter({ filterId }); + if (!filter) throw new Error('not-found'); + await removeFilterService.call(this, { filter }); }); - - return { successIds, failedIds }; } diff --git a/packages/core/src/services/bulkRemoveProducts.ts b/packages/core/src/services/bulkRemoveProducts.ts index 1542bdcae5..bc79330364 100644 --- a/packages/core/src/services/bulkRemoveProducts.ts +++ b/packages/core/src/services/bulkRemoveProducts.ts @@ -1,28 +1,13 @@ import type { Modules } from '../modules.ts'; import { removeProductService } from './removeProduct.ts'; +import { executeBulkOperation } from './executeBulkOperation.ts'; export async function bulkRemoveProductsService( this: Modules, { productIds }: { productIds: string[] }, ): Promise<{ successIds: string[]; failedIds: string[] }> { - const successIds: string[] = []; - const failedIds: string[] = []; - - const results = await Promise.allSettled( - productIds.map(async (productId) => { - const result = await removeProductService.call(this, { productId }); - if (!result) throw new Error('already-deleted'); - return productId; - }), - ); - - results.forEach((result, index) => { - if (result.status === 'fulfilled') { - successIds.push(result.value); - } else { - failedIds.push(productIds[index]); - } + return executeBulkOperation(productIds, async (productId) => { + const result = await removeProductService.call(this, { productId }); + if (!result) throw new Error('already-deleted'); }); - - return { successIds, failedIds }; } diff --git a/packages/core/src/services/executeBulkOperation.ts b/packages/core/src/services/executeBulkOperation.ts new file mode 100644 index 0000000000..c167be5a46 --- /dev/null +++ b/packages/core/src/services/executeBulkOperation.ts @@ -0,0 +1,19 @@ +export async function executeBulkOperation( + ids: T[], + operation: (id: T) => Promise, +): Promise<{ successIds: T[]; failedIds: T[] }> { + const results = await Promise.allSettled(ids.map((id) => operation(id).then(() => id))); + + const successIds: T[] = []; + const failedIds: T[] = []; + + results.forEach((result, index) => { + if (result.status === 'fulfilled') { + successIds.push(result.value); + } else { + failedIds.push(ids[index]); + } + }); + + return { successIds, failedIds }; +}