diff --git a/admin-ui/src/modules/accounts/components/UserList.tsx b/admin-ui/src/modules/accounts/components/UserList.tsx index 083aa56a5..72f087df9 100644 --- a/admin-ui/src/modules/accounts/components/UserList.tsx +++ b/admin-ui/src/modules/accounts/components/UserList.tsx @@ -1,73 +1,240 @@ +import React from 'react'; import { useIntl } from 'react-intl'; +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'; 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(); + const handleBulkResult = useBulkResultHandler(); - - {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 bulkActions = canManage + ? [ + { + key: 'update-tags', + label: formatMessage({ + id: 'bulk_update_tags', + defaultMessage: 'Update Tags', + }), + renderForm: ({ onCancel }) => ( + { + await handleBulkResult( + () => bulkUpdateUserTags(selectedIds, add, remove), + 'bulkUpdateUserTags', + ); + clearAll(); + }} + onCancel={onCancel} + /> + ), + }, + { + key: 'set-roles', + label: formatMessage({ + id: 'bulk_set_roles', + defaultMessage: 'Set Roles', + }), + renderForm: ({ onCancel }) => ( + { + await handleBulkResult( + () => bulkSetUserRoles(selectedIds, roles), + '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(''); + await handleBulkResult( + () => bulkRemoveUsers(ids), + 'bulkRemoveUsers', + ); + resolve(); + }} + okText={formatMessage({ + id: 'delete_users', + defaultMessage: 'Delete Users', + })} + />, + ); + }); + }, + }, + ] + : []; - - {formatMessage({ - id: 'cart', - defaultMessage: 'Cart', - })} - - - - {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 ba900654e..a49f958cc 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 d237f6954..ead9e3906 100644 --- a/admin-ui/src/modules/assortment/components/AssortmentList.tsx +++ b/admin-ui/src/modules/assortment/components/AssortmentList.tsx @@ -1,51 +1,182 @@ import { useIntl } from 'react-intl'; +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'; import AssortmentListItem from './AssortmentListItem'; const AssortmentList = ({ assortments, showAvatar = true, sortable }) => { const { formatMessage } = useIntl(); - return ( - - {assortments?.map((assortment) => ( - - - {formatMessage({ - id: 'name', - defaultMessage: 'Name', - })} - + const { hasRole } = useAuth(); + const { setModal } = useModal(); + + const { + selectedIds, + selectedCount, + toggle, + clearAll, + isSelected, + toggleAll, + } = useBulkSelection(); - - {formatMessage({ - id: 'active', - defaultMessage: 'Active', - })} - + const { + bulkRemoveAssortments, + bulkUpdateAssortmentTags, + bulkSetAssortmentActive, + } = useBulkAssortmentOperations(); + const handleBulkResult = useBulkResultHandler(); - - {formatMessage({ - id: 'root', - defaultMessage: 'Root', - })} - + const allIds = assortments?.map((a) => a._id) || []; + const canManage = hasRole(IRoleAction.ManageAssortments); - - {formatMessage({ - id: 'sequence', - defaultMessage: 'Display Order', - })} - -   - - ))} - {assortments?.map((assortment) => ( - - ))} -
+ const bulkActions = canManage + ? [ + { + key: 'activate', + label: formatMessage({ + id: 'bulk_activate', + defaultMessage: 'Activate', + }), + onAction: async (ids: string[]) => { + await handleBulkResult( + () => bulkSetAssortmentActive(ids, true), + 'bulkSetAssortmentActive', + ); + }, + }, + { + key: 'deactivate', + label: formatMessage({ + id: 'bulk_deactivate', + defaultMessage: 'Deactivate', + }), + onAction: async (ids: string[]) => { + await handleBulkResult( + () => bulkSetAssortmentActive(ids, false), + 'bulkSetAssortmentActive', + ); + }, + }, + { + key: 'update-tags', + label: formatMessage({ + id: 'bulk_update_tags', + defaultMessage: 'Update Tags', + }), + renderForm: ({ onCancel }) => ( + { + await handleBulkResult( + () => bulkUpdateAssortmentTags(selectedIds, add, remove), + '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(''); + await handleBulkResult( + () => bulkRemoveAssortments(ids), + 'bulkRemoveAssortments', + ); + resolve(); + }} + okText={formatMessage({ + id: 'delete_assortments', + defaultMessage: 'Delete Assortments', + })} + />, + ); + }); + }, + }, + ] + : []; + + return ( + <> + + + {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 a96ab3821..2817b73b4 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 000000000..62399bce8 --- /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 000000000..87ddd7989 --- /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 000000000..09580e5cc --- /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/useBulkResultHandler.ts b/admin-ui/src/modules/common/hooks/useBulkResultHandler.ts new file mode 100644 index 000000000..f6df2e936 --- /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/common/hooks/useBulkSelection.ts b/admin-ui/src/modules/common/hooks/useBulkSelection.ts new file mode 100644 index 000000000..068c91059 --- /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 6d3063b8c..5cae04137 100644 --- a/admin-ui/src/modules/filter/components/FilterList.tsx +++ b/admin-ui/src/modules/filter/components/FilterList.tsx @@ -5,6 +5,12 @@ 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 useBulkResultHandler from '../../common/hooks/useBulkResultHandler'; +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,163 @@ const FilterList = ({ forceLocale: selectedLocale, }); + const { + selectedIds, + selectedCount, + toggle, + clearAll, + isSelected, + toggleAll, + } = useBulkSelection(); + + const { bulkRemoveFilters, bulkSetFilterActive } = useBulkFilterOperations(); + const handleBulkResult = useBulkResultHandler(); + + const allIds = filters?.map((f) => f._id) || []; + const canManage = hasRole(IRoleAction.ManageFilters); + + const bulkActions = canManage + ? [ + { + key: 'activate', + label: formatMessage({ + id: 'bulk_activate', + defaultMessage: 'Activate', + }), + onAction: async (ids: string[]) => { + await handleBulkResult( + () => bulkSetFilterActive(ids, true), + 'bulkSetFilterActive', + ); + }, + }, + { + key: 'deactivate', + label: formatMessage({ + id: 'bulk_deactivate', + defaultMessage: 'Deactivate', + }), + onAction: async (ids: string[]) => { + await handleBulkResult( + () => bulkSetFilterActive(ids, false), + '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(''); + await handleBulkResult( + () => bulkRemoveFilters(ids), + '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 e5d6e0dd4..cfa9ebd8e 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 207077444..a53ebcc00 100644 --- a/admin-ui/src/modules/product/components/ProductList.tsx +++ b/admin-ui/src/modules/product/components/ProductList.tsx @@ -2,9 +2,18 @@ import { useIntl } from 'react-intl'; 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 useBulkResultHandler from '../../common/hooks/useBulkResultHandler'; +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,187 @@ const ProductList = ({ forceLocale: selectedLocale, }); + const { + selectedIds, + selectedCount, + toggle, + clearAll, + isSelected, + toggleAll, + } = useBulkSelection(); + + const { bulkSetProductStatus, bulkUpdateProductTags, bulkRemoveProducts } = + useBulkProductOperations(); + const handleBulkResult = useBulkResultHandler(); + + const allIds = products?.map((p) => p._id) || []; + + const bulkActions = hasRole(IRoleAction.ManageProducts) + ? [ + { + key: 'set-active', + label: formatMessage({ + id: 'bulk_set_active', + defaultMessage: 'Set Active', + }), + onAction: async (ids: string[]) => { + await handleBulkResult( + () => bulkSetProductStatus(ids, 'ACTIVE'), + 'bulkSetProductStatus', + ); + }, + }, + { + key: 'set-draft', + label: formatMessage({ + id: 'bulk_set_draft', + defaultMessage: 'Set Draft', + }), + onAction: async (ids: string[]) => { + await handleBulkResult( + () => bulkSetProductStatus(ids, 'DRAFT'), + 'bulkSetProductStatus', + ); + }, + }, + { + key: 'update-tags', + label: formatMessage({ + id: 'bulk_update_tags', + defaultMessage: 'Update Tags', + }), + renderForm: ({ onCancel }) => ( + { + await handleBulkResult( + () => bulkUpdateProductTags(selectedIds, add, remove), + '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(''); + await handleBulkResult( + () => bulkRemoveProducts(ids), + '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 bc8001aaf..49601f54e 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; diff --git a/examples/kitchensink/src/boot.ts b/examples/kitchensink/src/boot.ts index 3eb4b1beb..9bcd80ccf 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 000000000..e22493ee9 --- /dev/null +++ b/packages/api/src/resolvers/mutations/bulk/bulkAssignProductsToAssortment.ts @@ -0,0 +1,37 @@ +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; + + 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; + } 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 000000000..aca963e8d --- /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 000000000..b7ac70c8f --- /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 new file mode 100644 index 000000000..226cc4e33 --- /dev/null +++ b/packages/api/src/resolvers/mutations/bulk/bulkRemoveProducts.ts @@ -0,0 +1,14 @@ +import type { Context } from '../../../context.ts'; +import { log } from '@unchainedshop/logger'; + +export default async function bulkRemoveProducts( + root: never, + { productIds }: { productIds: string[] }, + { services, userId }: Context, +) { + log(`mutation bulkRemoveProducts for ${productIds.length} products`, { userId }); + + const { successIds, failedIds } = await services.products.bulkRemoveProducts({ productIds }); + + 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 000000000..5aa017620 --- /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 000000000..0100e3c67 --- /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 000000000..3cda2f2dc --- /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 new file mode 100644 index 000000000..d905556af --- /dev/null +++ b/packages/api/src/resolvers/mutations/bulk/bulkSetProductStatus.ts @@ -0,0 +1,23 @@ +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 }); + + let successIds: string[]; + + 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 }; + } + + 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 000000000..0d2b84209 --- /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 000000000..9eae32989 --- /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 new file mode 100644 index 000000000..d2cc08dcb --- /dev/null +++ b/packages/api/src/resolvers/mutations/bulk/bulkUpdateProductTags.ts @@ -0,0 +1,19 @@ +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 }); + + if (remove?.length) { + await modules.products.bulkRemoveTags(productIds, remove); + } + if (add?.length) { + await modules.products.bulkAddTags(productIds, add); + } + + 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 new file mode 100644 index 000000000..ec6d76ceb --- /dev/null +++ b/packages/api/src/resolvers/mutations/bulk/bulkUpdateUserTags.ts @@ -0,0 +1,19 @@ +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 }); + + if (remove?.length) { + await modules.users.bulkRemoveTags(userIds, remove); + } + if (add?.length) { + await modules.users.bulkAddTags(userIds, add); + } + + 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 f8773a531..b23e47b3b 100755 --- a/packages/api/src/resolvers/mutations/index.ts +++ b/packages/api/src/resolvers/mutations/index.ts @@ -150,6 +150,18 @@ 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'; +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), @@ -311,4 +323,16 @@ 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), + 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 d5305ec40..a9923d5aa 100644 --- a/packages/api/src/schema/mutation.ts +++ b/packages/api/src/schema/mutation.ts @@ -858,6 +858,78 @@ 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! + + """ + 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/api/src/schema/types/common.ts b/packages/api/src/schema/types/common.ts index 7ceec26c1..99bf634f0 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 diff --git a/packages/core-assortments/src/module/configureAssortmentsModule.ts b/packages/core-assortments/src/module/configureAssortmentsModule.ts index e7039b7ff..6c2e2183a 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 77e8083f4..cbba4faa1 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 f2dbc07b7..52e0d000b 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 9a933414b..7081d59b6 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 000000000..e559604db --- /dev/null +++ b/packages/core/src/services/bulkDeleteUsers.ts @@ -0,0 +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[] }> { + return executeBulkOperation(userIds, async (userId) => { + const result = await deleteUserService.call(this, { userId }); + if (!result) throw new Error('delete-failed'); + }); +} diff --git a/packages/core/src/services/bulkRemoveFilters.ts b/packages/core/src/services/bulkRemoveFilters.ts new file mode 100644 index 000000000..41a504eed --- /dev/null +++ b/packages/core/src/services/bulkRemoveFilters.ts @@ -0,0 +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[] }> { + return executeBulkOperation(filterIds, async (filterId) => { + const filter = await this.filters.findFilter({ filterId }); + if (!filter) throw new Error('not-found'); + await removeFilterService.call(this, { filter }); + }); +} diff --git a/packages/core/src/services/bulkRemoveProducts.ts b/packages/core/src/services/bulkRemoveProducts.ts new file mode 100644 index 000000000..bc7933036 --- /dev/null +++ b/packages/core/src/services/bulkRemoveProducts.ts @@ -0,0 +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[] }> { + return executeBulkOperation(productIds, async (productId) => { + const result = await removeProductService.call(this, { productId }); + if (!result) throw new Error('already-deleted'); + }); +} diff --git a/packages/core/src/services/executeBulkOperation.ts b/packages/core/src/services/executeBulkOperation.ts new file mode 100644 index 000000000..c167be5a4 --- /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 }; +} diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index 83c9ff058..479547c22 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,