diff --git a/web/usuaris/CHANGELOG.md b/web/usuaris/CHANGELOG.md
index 91afdd10c..f31a1433f 100644
--- a/web/usuaris/CHANGELOG.md
+++ b/web/usuaris/CHANGELOG.md
@@ -2,12 +2,19 @@
All notable changes to this project will be documented in this file.
+## [0.21.0] - 2024-05-22
+
+### Added
+
+- Added ActionsDropdown component
+- Added functionality to apply actions to multiple users
+
## [0.20.0] - 2024-05-22
### Added
- Added sort option on headers table
--
+
## [0.19.0] - 2024-05-15
### Added
diff --git a/web/usuaris/package-lock.json b/web/usuaris/package-lock.json
index 889d353fc..ee81bd976 100644
--- a/web/usuaris/package-lock.json
+++ b/web/usuaris/package-lock.json
@@ -10,7 +10,7 @@
"dependencies": {
"@hookform/resolvers": "^3.0.0",
"@itacademy/schemas": "^0.3.0",
- "@itacademy/ui": "^0.31.7",
+ "@itacademy/ui": "^0.31.8",
"@tanstack/react-query": "^4.29.5",
"@tanstack/react-query-devtools": "^4.29.6",
"@tanstack/react-table": "^8.13.2",
@@ -1193,9 +1193,9 @@
}
},
"node_modules/@itacademy/ui": {
- "version": "0.31.7",
- "resolved": "https://registry.npmjs.org/@itacademy/ui/-/ui-0.31.7.tgz",
- "integrity": "sha512-jk21SUMhjV8JIbCdyjx3UPR4s+uHdqezXOvOUDZ4ynxeegpVN4+mEggCHB+8Wm7vWvfiW1hrAubzcty35UKLwA==",
+ "version": "0.31.8",
+ "resolved": "https://registry.npmjs.org/@itacademy/ui/-/ui-0.31.8.tgz",
+ "integrity": "sha512-qz7cNr00F5dWsAdwnRqyGhAM3Ee5YH70sRsp624iFkk0E+nwdjmOsdppfi6zeAF85mD6XXobNucMm4vA02/Lng==",
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0",
diff --git a/web/usuaris/package.json b/web/usuaris/package.json
index a380c9b4c..233e98abd 100644
--- a/web/usuaris/package.json
+++ b/web/usuaris/package.json
@@ -1,7 +1,7 @@
{
"name": "web_usuaris",
"private": true,
- "version": "0.20.0",
+ "version": "0.21.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -14,7 +14,7 @@
"dependencies": {
"@hookform/resolvers": "^3.0.0",
"@itacademy/schemas": "^0.3.0",
- "@itacademy/ui": "^0.31.7",
+ "@itacademy/ui": "^0.31.8",
"@tanstack/react-query": "^4.29.5",
"@tanstack/react-query-devtools": "^4.29.6",
"@tanstack/react-table": "^8.13.2",
@@ -60,4 +60,4 @@
"vite": "5.0.13",
"vitest": "1.2.1"
}
-}
\ No newline at end of file
+}
diff --git a/web/usuaris/src/__mocks__/handlers.ts b/web/usuaris/src/__mocks__/handlers.ts
index 55a403cb3..a52983971 100644
--- a/web/usuaris/src/__mocks__/handlers.ts
+++ b/web/usuaris/src/__mocks__/handlers.ts
@@ -143,6 +143,16 @@ export const handlers = [
`${urls.deleteUser}1`,
() => new HttpResponse(null, { status: 204 })
),
+
+ http.delete(
+ urls.deleteMultipleUsers,
+ () => new HttpResponse(null, { status: 204 })
+ ),
+
+ http.post(
+ urls.changeUsersStatus,
+ () => new HttpResponse(null, { status: 204 })
+ ),
]
export const errorHandlers = [
@@ -158,4 +168,7 @@ export const errorHandlers = [
http.delete(`${urls.deleteUser}1`, () =>
HttpResponse.json({ message: 'Database error' }, { status: 500 })
),
+ http.delete(urls.deleteMultipleUsers, () =>
+ HttpResponse.json({ message: 'Database error' }, { status: 500 })
+ ),
]
diff --git a/web/usuaris/src/__tests__/hooks/useDeleteMultipleUsers.test.tsx b/web/usuaris/src/__tests__/hooks/useDeleteMultipleUsers.test.tsx
new file mode 100644
index 000000000..769896d13
--- /dev/null
+++ b/web/usuaris/src/__tests__/hooks/useDeleteMultipleUsers.test.tsx
@@ -0,0 +1,60 @@
+import { renderHook, waitFor } from '@testing-library/react'
+import { QueryClientProvider } from '@tanstack/react-query'
+import { act } from 'react-dom/test-utils'
+import { useDeleteMultipleUsers } from '../../hooks'
+import { queryClient } from '../setup'
+import { server } from '../../__mocks__/server'
+
+beforeEach(() => {
+ server.listen()
+})
+
+afterEach(() => {
+ server.resetHandlers()
+ queryClient.clear()
+})
+
+afterAll(() => {
+ server.close()
+})
+
+describe('useDeleteMultipleUsers hook', () => {
+ it('should initialize the useDeleteMultipleUsers hook', async () => {
+ const { result } = renderHook(() => useDeleteMultipleUsers(), {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ })
+
+ await waitFor(() => {
+ expect(result.current.deleteMultipleUsersMutation).toBeDefined()
+ expect(result.current.isError).toBe(false)
+ expect(result.current.isLoading).toBe(false)
+ expect(result.current.isSuccess).toBe(false)
+ })
+ })
+
+ it('should delete users successfully and invalidate queries', async () => {
+ const { result } = renderHook(() => useDeleteMultipleUsers(), {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ })
+ const mockUsersIds = ['1', '3', '4']
+
+ act(() => {
+ result.current.deleteMultipleUsersMutation(mockUsersIds)
+ })
+
+ await waitFor(() => {
+ expect(result.current.isSuccess).toBe(true)
+ expect(result.current.isError).toBe(false)
+ expect(result.current.isLoading).toBe(false)
+ expect(queryClient.getQueryData(['users'])).toBeUndefined()
+ })
+ })
+})
diff --git a/web/usuaris/src/__tests__/hooks/useUpdateUsersStatus.test.tsx b/web/usuaris/src/__tests__/hooks/useUpdateUsersStatus.test.tsx
new file mode 100644
index 000000000..1a1e4e959
--- /dev/null
+++ b/web/usuaris/src/__tests__/hooks/useUpdateUsersStatus.test.tsx
@@ -0,0 +1,61 @@
+import { renderHook, waitFor } from '@testing-library/react'
+import { QueryClientProvider } from '@tanstack/react-query'
+import { act } from 'react-dom/test-utils'
+import { useUpdateUsersStatus } from '../../hooks'
+import { queryClient } from '../setup'
+import { server } from '../../__mocks__/server'
+import { UserStatus } from '../../types'
+
+beforeEach(() => {
+ server.listen()
+})
+
+afterEach(() => {
+ server.resetHandlers()
+ queryClient.clear()
+})
+
+afterAll(() => {
+ server.close()
+})
+
+describe('useUpdateUsersStatus hook', () => {
+ it('should initialize the useUpdateUsersStatus hook', async () => {
+ const { result } = renderHook(() => useUpdateUsersStatus(), {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ })
+ await waitFor(() => {
+ expect(result.current.changeUsersStatus).toBeDefined()
+ expect(result.current.error).toBe(null)
+ expect(result.current.isSuccess).toBe(false)
+ })
+ })
+
+ it('should call changeUsersStatus on users status update and refetch users on success', async () => {
+ const { result } = renderHook(() => useUpdateUsersStatus(), {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ })
+
+ const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries')
+
+ act(() => {
+ result.current.changeUsersStatus.mutate({
+ ids: ['1', '2', '5'],
+ status: UserStatus.BLOCKED,
+ })
+ })
+ await waitFor(() => {
+ expect(result.current.error).toBe(null)
+ expect(result.current.isSuccess).toBe(true)
+ expect(invalidateQueriesSpy).toHaveBeenCalledWith(['users'])
+ })
+ })
+})
diff --git a/web/usuaris/src/__tests__/molecules/ActionsDropdown.test.tsx b/web/usuaris/src/__tests__/molecules/ActionsDropdown.test.tsx
new file mode 100644
index 000000000..2321a5fc4
--- /dev/null
+++ b/web/usuaris/src/__tests__/molecules/ActionsDropdown.test.tsx
@@ -0,0 +1,52 @@
+import { vi } from 'vitest'
+import { fireEvent, render, screen } from '../test-utils'
+import { ActionsDropdown } from '../../components/molecules'
+import { UserStatus } from '../../types'
+
+const mockHandleClick = vi.fn()
+
+afterEach(() => {
+ vi.restoreAllMocks()
+})
+
+describe('ActionsDropdown', () => {
+ it('renders correctly', () => {
+ render(
+
+ )
+ const actionsDropdown = screen.getByTestId('actions-dropdown')
+
+ expect(actionsDropdown).toHaveTextContent(/accions/i)
+ expect(actionsDropdown).toHaveAttribute('disabled')
+ expect(screen.getByTitle(/obre/i)).toBeInTheDocument()
+ })
+
+ it('renders corresponding actions when users selected and returns selected action to parent', () => {
+ render(
+
+ )
+
+ const actionsHeader = screen.getByTestId('dropdown-header')
+
+ expect(actionsHeader).toHaveTextContent(/accions/i)
+
+ fireEvent.click(actionsHeader)
+
+ const blockOption = screen.getByTestId('BLOCKED')
+ expect(blockOption).toBeInTheDocument()
+ expect(screen.getByTestId('DELETE')).toBeInTheDocument()
+
+ fireEvent.click(blockOption)
+
+ expect(actionsHeader).toHaveTextContent(/bloquejar/i)
+ expect(mockHandleClick).toHaveBeenCalledWith(UserStatus.BLOCKED)
+ })
+})
diff --git a/web/usuaris/src/__tests__/molecules/DeleteConfirmationModal.test.tsx b/web/usuaris/src/__tests__/molecules/DeleteConfirmationModal.test.tsx
index cebe6765d..75be630a2 100644
--- a/web/usuaris/src/__tests__/molecules/DeleteConfirmationModal.test.tsx
+++ b/web/usuaris/src/__tests__/molecules/DeleteConfirmationModal.test.tsx
@@ -3,19 +3,25 @@ import { server } from '../../__mocks__/server'
import { DeleteConfirmationModal } from '../../components/molecules/DeleteConfirmationModal'
import { fireEvent, render, screen, waitFor } from '../test-utils'
-const defaultProps = {
+const defaultDeleteUser = {
open: true,
toggleModal: vi.fn(),
idToDelete: '1',
}
+const defaultDeleteMultipleUsers = {
+ open: true,
+ toggleModal: vi.fn(),
+ idsToDelete: ['1', '3', '4'],
+}
+
describe('DeleteConfirmationModal', () => {
it('renders correctly', async () => {
- render()
+ render()
await waitFor(() => {
expect(
- screen.getByText('Estàs segur que vols eliminar aquest usuari?')
+ screen.getByText('Estàs segur que vols eliminar aquest/s usuari/s?')
).toBeInTheDocument()
expect(screen.getByTestId('confirm-button')).toBeInTheDocument()
expect(screen.getByTestId('cancel-button')).toBeInTheDocument()
@@ -25,12 +31,12 @@ describe('DeleteConfirmationModal', () => {
fireEvent.click(cancelButton)
await waitFor(() => {
- expect(defaultProps.toggleModal).toHaveBeenCalled()
+ expect(defaultDeleteUser.toggleModal).toHaveBeenCalled()
})
})
it('handles successful deletion', async () => {
- render()
+ render()
await waitFor(() => {
const confirmButton = screen.getByTestId('confirm-button')
@@ -39,15 +45,51 @@ describe('DeleteConfirmationModal', () => {
await waitFor(() => {
expect(
- screen.getByText('Usuario eliminado correctamente')
+ screen.getByText('Usuari/s eliminat/s correctament')
).toBeInTheDocument()
- expect(defaultProps.toggleModal).toHaveBeenCalled()
+ expect(defaultDeleteUser.toggleModal).toHaveBeenCalled()
})
})
it('handles deletion error', async () => {
server.use(...errorHandlers)
- render()
+ render()
+
+ await waitFor(() => {
+ const confirmButton = screen.getByTestId('confirm-button')
+ fireEvent.click(confirmButton)
+ })
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(`Error en eliminar l'usuari/s`)
+ ).toBeInTheDocument()
+ })
+ })
+
+ it('handles multiple deletion successfully', async () => {
+ render()
+
+ await waitFor(() => {
+ const confirmButton = screen.getByTestId('confirm-button')
+ fireEvent.click(confirmButton)
+ })
+
+ await waitFor(
+ () => {
+ expect(
+ screen.getByText('Usuari/s eliminat/s correctament')
+ ).toBeInTheDocument()
+
+ expect(defaultDeleteMultipleUsers.toggleModal).toHaveBeenCalled()
+ },
+ { timeout: 5000 }
+ )
+ })
+
+ it('handles multiple deletion error', async () => {
+ server.use(...errorHandlers)
+ render()
await waitFor(() => {
const confirmButton = screen.getByTestId('confirm-button')
@@ -56,7 +98,7 @@ describe('DeleteConfirmationModal', () => {
await waitFor(() => {
expect(
- screen.getByText(/Error en eliminar l'usuari/i)
+ screen.getByText(`Error en eliminar l'usuari/s`)
).toBeInTheDocument()
})
})
diff --git a/web/usuaris/src/__tests__/organisms/UsersTable.test.tsx b/web/usuaris/src/__tests__/organisms/UsersTable.test.tsx
index e52ab1539..ee57f5dd6 100644
--- a/web/usuaris/src/__tests__/organisms/UsersTable.test.tsx
+++ b/web/usuaris/src/__tests__/organisms/UsersTable.test.tsx
@@ -5,9 +5,15 @@ import { server } from '../../__mocks__/server'
import { UserStatus } from '../../types'
import { UserRole } from '../../types/types'
+const defaultProps = {
+ filtersSelected: {},
+ selectedStatus: undefined,
+ setSelectedStatus: vi.fn(),
+ handleSelectedUsers: vi.fn(),
+}
describe('UsersTable', () => {
it('renders loading spinner while fetching users', async () => {
- render()
+ render()
const spinner = screen.getByRole('status') as HTMLDivElement
expect(spinner).toBeInTheDocument()
@@ -19,7 +25,7 @@ describe('UsersTable', () => {
})
it('renders users correctly after fetching users succeeds', async () => {
- render()
+ render()
await waitFor(() => {
expect(screen.getByLabelText(/Ona Sitgar/i)).toBeInTheDocument()
@@ -65,6 +71,9 @@ describe('UsersTable', () => {
dni: 'marc',
role: UserRole.ADMIN,
}}
+ selectedStatus={undefined}
+ setSelectedStatus={vi.fn()}
+ handleSelectedUsers={vi.fn()}
/>
)
@@ -84,7 +93,7 @@ describe('UsersTable', () => {
it('renders error message when fetching users has error', async () => {
server.use(...errorHandlers)
- render()
+ render()
const spinner = screen.getByRole('status') as HTMLDivElement
expect(spinner).toBeInTheDocument()
@@ -97,7 +106,7 @@ describe('UsersTable', () => {
})
it('disables/enables users with different status when checkboxes are checked/unchecked', async () => {
- render()
+ render()
await waitFor(() => {
const checkboxPending = screen.getByLabelText(/Ona Sitgar/i)
@@ -147,7 +156,7 @@ describe('UsersTable', () => {
})
it('updates status and action button when user status changes', async () => {
- render()
+ render()
await waitFor(() => {
const acceptButton = screen.getByText('Acceptar')
@@ -165,7 +174,7 @@ describe('UsersTable', () => {
})
it('disables user when has been deleted', async () => {
- render()
+ render()
await waitFor(() => {
const userDeletedRow = screen.getByTestId('6')
diff --git a/web/usuaris/src/__tests__/pages/Home.test.tsx b/web/usuaris/src/__tests__/pages/Home.test.tsx
index 86f0bfb05..d303fceca 100644
--- a/web/usuaris/src/__tests__/pages/Home.test.tsx
+++ b/web/usuaris/src/__tests__/pages/Home.test.tsx
@@ -38,9 +38,11 @@ describe('Home page', () => {
const mainDiv = screen.getByRole('main')
const sideMenuElement = screen.getByText(/Mentors/i)
const filtersWidgetElement = screen.getByAltText(/Calendar/i)
+ const actionsDropdown = screen.getByTestId('actions-dropdown')
expect(mainDiv).toBeInTheDocument()
expect(sideMenuElement).toBeInTheDocument()
expect(filtersWidgetElement).toBeInTheDocument()
+ expect(actionsDropdown).toBeInTheDocument()
})
})
diff --git a/web/usuaris/src/components/molecules/ActionsDropdown.tsx b/web/usuaris/src/components/molecules/ActionsDropdown.tsx
new file mode 100644
index 000000000..068540322
--- /dev/null
+++ b/web/usuaris/src/components/molecules/ActionsDropdown.tsx
@@ -0,0 +1,99 @@
+import { FC, useEffect, useState } from 'react'
+import styled from 'styled-components'
+import {
+ colors,
+ dimensions,
+ Dropdown,
+ font,
+ type TDropdownOption,
+} from '@itacademy/ui'
+import { useTranslation } from 'react-i18next'
+import { UserStatus } from '../../types'
+
+type TStyledDropdown = {
+ disabled: boolean | undefined
+}
+
+const StyledDropdown = styled(Dropdown)`
+ && div {
+ cursor: ${({ disabled }) => (disabled && 'not-allowed') || 'pointer'};
+ }
+
+ && button {
+ width: 210px;
+ padding: ${dimensions.spacing.xxs};
+ font-size: ${font.xs};
+ font-weight: 500;
+ opacity: ${({ disabled }) => (disabled && '0.6') || '1'};
+ pointer-events: ${({ disabled }) => (disabled && 'none') || 'auto'};
+
+ &:hover {
+ background-color: ${colors.primary};
+ color: ${colors.white};
+ }
+
+ > span {
+ padding-left: ${dimensions.spacing.xxxs};
+ font-weight: 400;
+ }
+ }
+`
+
+type TActionsDropdown = {
+ selectedStatus: UserStatus | undefined
+ handleAction: (action: string | undefined) => void
+ isActionFinished: boolean
+}
+
+export const ActionsDropdown: FC = ({
+ selectedStatus,
+ handleAction,
+ isActionFinished,
+}) => {
+ const { t } = useTranslation()
+
+ const [actionsList, setActionsList] = useState([])
+
+ useEffect(() => {
+ switch (selectedStatus) {
+ case UserStatus.ACTIVE:
+ setActionsList([
+ { id: UserStatus.BLOCKED, name: t('Bloquear'), icon: 'block' },
+ { id: 'DELETE', name: t('Eliminar'), icon: 'delete_forever' },
+ ])
+ break
+ case UserStatus.PENDING:
+ setActionsList([
+ { id: UserStatus.ACTIVE, name: t('Aceptar'), icon: 'task_alt' },
+ { id: 'DELETE', name: t('Eliminar'), icon: 'delete_forever' },
+ ])
+ break
+ case UserStatus.BLOCKED:
+ setActionsList([
+ { id: UserStatus.ACTIVE, name: t('Desbloquear'), icon: 'task_alt' },
+ { id: 'DELETE', name: t('Eliminar'), icon: 'delete_forever' },
+ ])
+ break
+ default:
+ setActionsList([])
+ }
+ }, [selectedStatus, t])
+
+ const handleSelectedValue = (selectedOption: TDropdownOption | undefined) => {
+ handleAction(selectedOption?.id)
+ }
+
+ return (
+
+ )
+}
diff --git a/web/usuaris/src/components/molecules/DeleteConfirmationModal.tsx b/web/usuaris/src/components/molecules/DeleteConfirmationModal.tsx
index 38a6e4c43..cca4fb611 100644
--- a/web/usuaris/src/components/molecules/DeleteConfirmationModal.tsx
+++ b/web/usuaris/src/components/molecules/DeleteConfirmationModal.tsx
@@ -10,12 +10,13 @@ import {
import { FC, HTMLAttributes } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
-import { useDeleteUser } from '../../hooks'
+import { useDeleteMultipleUsers, useDeleteUser } from '../../hooks'
type TDeleteConfirmationModal = {
open: boolean
toggleModal: () => void
- idToDelete: string
+ idToDelete?: string
+ idsToDelete?: string[]
}
type TButton = HTMLAttributes & {
@@ -49,6 +50,7 @@ export const DeleteConfirmationModal: FC = ({
open,
toggleModal,
idToDelete,
+ idsToDelete,
}) => {
const { t } = useTranslation()
const { deleteUserMutation, isLoading, isSuccess, isError, reset } =
@@ -56,41 +58,68 @@ export const DeleteConfirmationModal: FC = ({
successCb: toggleModal,
})
+ const {
+ deleteMultipleUsersMutation,
+ isLoading: batchDeleteLoading,
+ isSuccess: batchDeleteSuccess,
+ isError: batchDeleteError,
+ reset: batchDeleteReset,
+ } = useDeleteMultipleUsers({
+ successCb: toggleModal,
+ })
+
+ const handleConfirm = () => {
+ if (idToDelete) {
+ deleteUserMutation(idToDelete)
+ } else if (idsToDelete) {
+ deleteMultipleUsersMutation(idsToDelete)
+ }
+ }
+
+ const handleCancel = () => {
+ toggleModal()
+ if (idToDelete) {
+ reset()
+ } else {
+ batchDeleteReset()
+ }
+ }
+
return (
{
toggleModal()
- reset()
+ if (idToDelete) {
+ reset()
+ } else {
+ batchDeleteReset()
+ }
}}
>
- {isSuccess && (
+ {(isSuccess || batchDeleteSuccess) && (
{t('Usuario eliminado correctamente')}
)}
- {isError && (
+ {(isError || batchDeleteError) && (
{t('Error al eliminar el usuario')}
)}
-