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')} )} - diff --git a/web/usuaris/src/components/molecules/SelectLanguage.tsx b/web/usuaris/src/components/molecules/SelectLanguage.tsx index 55bc8056c..aaf592fa8 100644 --- a/web/usuaris/src/components/molecules/SelectLanguage.tsx +++ b/web/usuaris/src/components/molecules/SelectLanguage.tsx @@ -56,7 +56,7 @@ export const SelectLanguage: FC = () => { diff --git a/web/usuaris/src/components/molecules/index.ts b/web/usuaris/src/components/molecules/index.ts index e757a3e00..fe4ebae8a 100644 --- a/web/usuaris/src/components/molecules/index.ts +++ b/web/usuaris/src/components/molecules/index.ts @@ -1,3 +1,4 @@ +export { ActionsDropdown } from './ActionsDropdown' export { DateRange, type TDateRange } from './DateRange' export { DeleteConfirmationModal } from './DeleteConfirmationModal' export { ItineraryDropdown } from './ItineraryDropdown' diff --git a/web/usuaris/src/components/organisms/FiltersWidget.tsx b/web/usuaris/src/components/organisms/FiltersWidget.tsx index 674531c33..47041a198 100644 --- a/web/usuaris/src/components/organisms/FiltersWidget.tsx +++ b/web/usuaris/src/components/organisms/FiltersWidget.tsx @@ -10,6 +10,7 @@ import { TRole } from '../../types/types' const FiltersContainer = styled(FlexBox)` width: 100%; + flex-wrap: wrap; ` type TFiltersWidget = { @@ -55,6 +56,7 @@ export const FiltersWidget: FC = ({ filters, setFilters }) => { diff --git a/web/usuaris/src/components/organisms/UsersTable/UsersTable.tsx b/web/usuaris/src/components/organisms/UsersTable/UsersTable.tsx index 4c3288206..ef6e1ba86 100644 --- a/web/usuaris/src/components/organisms/UsersTable/UsersTable.tsx +++ b/web/usuaris/src/components/organisms/UsersTable/UsersTable.tsx @@ -21,20 +21,24 @@ import { UserRole } from '../../../types/types' type TUsersTable = { filtersSelected: TFilters | Record + selectedStatus: UserStatus | undefined + setSelectedStatus: (selectedStatus: UserStatus | undefined) => void + handleSelectedUsers: (selectedUsersIds: string[]) => void } -export const UsersTable: FC = ({ filtersSelected }) => { +export const UsersTable: FC = ({ + filtersSelected, + selectedStatus, + setSelectedStatus, + handleSelectedUsers, +}) => { const { t } = useTranslation() const [filters, setFilters] = useState({}) const { isLoading, isError, data: users } = useGetUsers(filters) - const [selectedStatus, setSelectedStatus] = useState( - undefined - ) - - const [selectedUsers, setSelectedUsers] = useState([]) + const [selectedUsersIds, setSelectedUsersIds] = useState([]) const { changeUserStatus } = useUpdateUser() @@ -57,20 +61,24 @@ export const UsersTable: FC = ({ filtersSelected }) => { changeUserStatus.mutate(updatedUser) } - const handleSelectedUsers = (allSelectedUsers: TUserData[]) => { - // TO DO: Add action to do after selection - // eslint-disable-next-line no-console - console.log('allSelectedUsers', allSelectedUsers) - } - useEffect(() => { - if (selectedUsers?.length > 0) { - handleSelectedUsers(selectedUsers) + if (selectedUsersIds?.length > 0) { + handleSelectedUsers(selectedUsersIds) } else { handleSelectedUsers([]) setSelectedStatus(undefined) } - }, [selectedUsers]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedUsersIds]) + + useEffect(() => { + if (selectedStatus === undefined) setSelectedUsersIds([]) + }, [selectedStatus]) + + useEffect(() => { + setSelectedStatus(undefined) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filters]) const changeSelection = ( e: ChangeEvent, @@ -82,18 +90,20 @@ export const UsersTable: FC = ({ filtersSelected }) => { const userSelected: TUserData | undefined = users?.find( (user) => user.id === id ) - const addUsers = [...selectedUsers] + const addUsers = [...selectedUsersIds] if (userSelected) { - addUsers.push(userSelected) + addUsers.push(userSelected.id) } - setSelectedUsers(addUsers) + setSelectedUsersIds(addUsers) return addUsers } const userUnselected = users?.find((user) => user.id === id) - const removeUsers = selectedUsers.filter((user) => user !== userUnselected) - setSelectedUsers(removeUsers) + const removeUsers = selectedUsersIds.filter( + (user) => user !== userUnselected?.id + ) + setSelectedUsersIds(removeUsers) return removeUsers } @@ -108,13 +118,8 @@ export const UsersTable: FC = ({ filtersSelected }) => { const name: string = row.getValue('name') const status: UserStatus = row.getValue('status') const { deletedAt } = row.original - let isDisabled: boolean | undefined - - if ((selectedStatus && selectedStatus !== status) || deletedAt) { - isDisabled = true - } else { - isDisabled = undefined - } + const isDisabled = + (!!selectedStatus && selectedStatus !== status) || !!deletedAt return ( = ({ filtersSelected }) => { onChange={(e) => handleSelectedUsers(changeSelection(e, id, status)) } - defaultChecked={selectedUsers?.some( - (checkedUser) => checkedUser.id === id + defaultChecked={selectedUsersIds?.some( + (checkedUser) => checkedUser === id )} disabled={isDisabled} /> @@ -137,13 +142,8 @@ export const UsersTable: FC = ({ filtersSelected }) => { cell: ({ row }) => { const name: string = row.getValue('name') const status: UserStatus = row.getValue('status') - let isDisabled: boolean | undefined + const isDisabled = !!selectedStatus && selectedStatus !== status - if (selectedStatus && selectedStatus !== status) { - isDisabled = true - } else { - isDisabled = undefined - } return {name} }, }), @@ -152,13 +152,8 @@ export const UsersTable: FC = ({ filtersSelected }) => { cell: ({ row }) => { const dni: string = row.getValue('dni') const status: UserStatus = row.getValue('status') - let isDisabled: boolean | undefined + const isDisabled = !!selectedStatus && selectedStatus !== status - if (selectedStatus && selectedStatus !== status) { - isDisabled = true - } else { - isDisabled = undefined - } return {dni} }, }), @@ -167,13 +162,8 @@ export const UsersTable: FC = ({ filtersSelected }) => { cell: ({ row }) => { const itineraryName: string = row.getValue('itineraryName') const status: UserStatus = row.getValue('status') - let isDisabled: boolean | undefined + const isDisabled = !!selectedStatus && selectedStatus !== status - if (selectedStatus && selectedStatus !== status) { - isDisabled = true - } else { - isDisabled = undefined - } return ( {itineraryName} ) @@ -183,16 +173,10 @@ export const UsersTable: FC = ({ filtersSelected }) => { header: `${t('Estado')}`, cell: ({ row }) => { let status: UserStatus = row.getValue('status') - let isDisabled: boolean | undefined + const isDisabled = !!selectedStatus && selectedStatus !== status const { deletedAt } = row.original if (deletedAt) status = UserStatus.DELETED - if (selectedStatus && selectedStatus !== status) { - isDisabled = true - } else { - isDisabled = undefined - } - return ( @@ -207,14 +191,8 @@ export const UsersTable: FC = ({ filtersSelected }) => { cell: ({ row }) => { const createdAt = row.getValue('createdAt') const status: UserStatus = row.getValue('status') + const isDisabled = !!selectedStatus && selectedStatus !== status const formattedDate = new Date(createdAt as string).toLocaleDateString() - let isDisabled: boolean | undefined - - if (selectedStatus && selectedStatus !== status) { - isDisabled = true - } else { - isDisabled = undefined - } return ( {formattedDate} @@ -226,13 +204,7 @@ export const UsersTable: FC = ({ filtersSelected }) => { cell: ({ row }) => { const role: UserRole = row.getValue('role') const status: UserStatus = row.getValue('status') - let isDisabled: boolean | undefined - - if (selectedStatus && selectedStatus !== status) { - isDisabled = true - } else { - isDisabled = undefined - } + const isDisabled = !!selectedStatus && selectedStatus !== status return {t(role)} }, @@ -244,15 +216,9 @@ export const UsersTable: FC = ({ filtersSelected }) => { const status: UserStatus = row.getValue('status') const id: string = row.getValue('id') const { deletedAt } = row.original - let isDisabled: boolean | undefined + const isDisabled = !!selectedStatus || !!deletedAt let buttonTxt: string = '' - if ((selectedStatus && selectedStatus !== status) || deletedAt) { - isDisabled = true - } else { - isDisabled = undefined - } - if (deletedAt) { buttonTxt = t('Eliminado') } else if (status === UserStatus.PENDING) { diff --git a/web/usuaris/src/constants/urls.ts b/web/usuaris/src/constants/urls.ts index 42c695881..f5b3c0806 100644 --- a/web/usuaris/src/constants/urls.ts +++ b/web/usuaris/src/constants/urls.ts @@ -1,8 +1,10 @@ export const urls = { + changeUsersStatus: '/api/v1/dashboard/users/status', + deleteMultipleUsers: '/api/v1/dashboard/users/', + deleteUser: '/api/v1/dashboard/users/', getItineraries: '/api/v1/itineraries', + getMe: '/api/v1/dashboard/users/me', getUsers: '/api/v1/dashboard/users', logIn: '/api/v1/dashboard/auth/login', - getMe: '/api/v1/dashboard/users/me', patchUser: '/api/v1/dashboard/users/', - deleteUser: '/api/v1/dashboard/users/' } diff --git a/web/usuaris/src/helpers/fetchers.ts b/web/usuaris/src/helpers/fetchers.ts index b0eaeb918..898bca53d 100644 --- a/web/usuaris/src/helpers/fetchers.ts +++ b/web/usuaris/src/helpers/fetchers.ts @@ -1,5 +1,5 @@ import { urls } from '../constants' -import { TUpdatedUser } from '../types' +import { TUpdatedUser, TUpdatedUsersStatus } from '../types' export const getItineraries = async () => fetch(urls.getItineraries) @@ -13,7 +13,6 @@ export const getItineraries = async () => throw new Error(`Error fetching itineraries: ${err.message}`) }) - export const getUsers = async (filters: string) => { const response = await fetch(`${urls.getUsers}?${filters}`) if (!response.ok) { @@ -67,3 +66,30 @@ export const deleteUser = async (userId: string) => { } return response.status === 204 ? {} : response.json() } + +export const updateUsersStatus = async ( + updatedUsersStatus: TUpdatedUsersStatus +) => { + const response = await fetch(urls.changeUsersStatus, { + method: 'POST', + body: JSON.stringify(updatedUsersStatus), + headers: { 'Content-Type': 'application/json' }, + }) + if (!response.ok) { + throw new Error('Failed to update users status') + } + return response.status === 204 ? {} : response.json() +} + +export const deleteMultipleUsers = async (usersIds: string[]) => { + const response = await fetch(urls.deleteMultipleUsers, { + method: 'DELETE', + body: JSON.stringify({ ids: usersIds }), + headers: { 'Content-Type': 'application/json' }, + }) + + if (!response.ok) { + throw new Error('Failed to delete users') + } + return response.status === 204 ? {} : response.json() +} diff --git a/web/usuaris/src/helpers/index.ts b/web/usuaris/src/helpers/index.ts index 9b1fe7fb6..c4bb3bcba 100644 --- a/web/usuaris/src/helpers/index.ts +++ b/web/usuaris/src/helpers/index.ts @@ -3,6 +3,7 @@ export { getUsers, loginUserFetcher, patchUser, + updateUsersStatus, } from './fetchers' export { buildQueryString } from './filters' diff --git a/web/usuaris/src/hooks/index.ts b/web/usuaris/src/hooks/index.ts index 4d0e76fa8..10d478c2d 100644 --- a/web/usuaris/src/hooks/index.ts +++ b/web/usuaris/src/hooks/index.ts @@ -1,5 +1,7 @@ +export { useDeleteMultipleUsers } from './useDeleteMultipleUsers' export { useDeleteUser } from './useDeleteUser' export { useGetItineraries } from './useGetItineraries' export { useGetUsers } from './useGetUsers' export { useLogin } from './useLogin' export { useUpdateUser } from './useUpdateUser' +export { useUpdateUsersStatus } from './useUpdateUsersStatus' diff --git a/web/usuaris/src/hooks/useDeleteMultipleUsers.tsx b/web/usuaris/src/hooks/useDeleteMultipleUsers.tsx new file mode 100644 index 000000000..0a045fc6e --- /dev/null +++ b/web/usuaris/src/hooks/useDeleteMultipleUsers.tsx @@ -0,0 +1,44 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { deleteMultipleUsers } from '../helpers/fetchers' + +type TUseDeleteMultipleUsers = { + successCb?: () => void + errorCb?: () => void +} + +export const useDeleteMultipleUsers = (props?: TUseDeleteMultipleUsers) => { + const { successCb, errorCb } = props || {} + const queryClient = useQueryClient() + + const { + mutate: deleteMultipleUsersMutation, + isLoading, + isSuccess, + isError, + reset, + } = useMutation({ + mutationFn: (usersIds) => deleteMultipleUsers(usersIds), + onSuccess: () => { + queryClient.invalidateQueries(['users']) + if (successCb) { + setTimeout(() => { + successCb() + }, 2000) + setTimeout(() => { + reset() + }, 2200) + } + }, + onError: () => { + if (errorCb) errorCb() + }, + }) + + return { + deleteMultipleUsersMutation, + isLoading, + isSuccess, + isError, + reset, + } +} diff --git a/web/usuaris/src/hooks/useUpdateUsersStatus.tsx b/web/usuaris/src/hooks/useUpdateUsersStatus.tsx new file mode 100644 index 000000000..2fef07506 --- /dev/null +++ b/web/usuaris/src/hooks/useUpdateUsersStatus.tsx @@ -0,0 +1,21 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { updateUsersStatus } from '../helpers/fetchers' +import { TUpdatedUsersStatus } from '../types' + +export const useUpdateUsersStatus = () => { + const queryClient = useQueryClient() + + const changeUsersStatus = useMutation( + updateUsersStatus, + { + onSuccess: () => { + queryClient.invalidateQueries(['users']) + }, + } + ) + return { + changeUsersStatus, + error: changeUsersStatus.error, + isSuccess: changeUsersStatus.isSuccess, + } +} diff --git a/web/usuaris/src/locales/cat.json b/web/usuaris/src/locales/cat.json index 23b1d3ced..3cc2b6a55 100644 --- a/web/usuaris/src/locales/cat.json +++ b/web/usuaris/src/locales/cat.json @@ -123,10 +123,11 @@ "BLOCKED": "Bloquejat", "Aceptar": "Acceptar", "Bloquear": "Bloquejar", + "Desbloquear": "Desbloquejar", + "Eliminar": "Eliminar", "ADMIN": "Administrador", "REGISTERED": "Registrat", "MENTOR": "Mentor", - "Desbloquear": "Desbloquejar", "No hay usuarios para mostrar": "No hi ha usuaris per mostrar", "Mentores": "Mentors", "Connector": "Connector", @@ -139,7 +140,8 @@ "Buscar": "Cercar", "DELETED": "Eliminat", "Eliminado": "Eliminat", - "Eliminar usuario": "Estàs segur que vols eliminar aquest usuari?", + "Eliminar usuario": "Estàs segur que vols eliminar aquest/s usuari/s?", "Confirmar": "Confirmar", - "Error al eliminar el usuario": "Error en eliminar l'usuari" + "Usuario eliminado correctamente": "Usuari/s eliminat/s correctament", + "Error al eliminar el usuario": "Error en eliminar l'usuari/s" } diff --git a/web/usuaris/src/locales/en.json b/web/usuaris/src/locales/en.json index 92452bbe3..39c9c627c 100644 --- a/web/usuaris/src/locales/en.json +++ b/web/usuaris/src/locales/en.json @@ -124,6 +124,7 @@ "Aceptar": "Accept", "Bloquear": "Block", "Desbloquear": "Unblock", + "Eliminar": "Delete", "ADMIN": "Admin", "REGISTERED": "Registered", "MENTOR": "Mentor", @@ -139,7 +140,8 @@ "Buscar": "Search", "DELETED": "Deleted", "Eliminado": "Deleted", - "Eliminar usuario": "Are you sure you want to delete this user?", - "Confirmar" : "Confirm", - "Error al eliminar el usuario": "Failed to delete user" + "Eliminar usuario": "Are you sure you want to delete this/these user/s?", + "Confirmar": "Confirm", + "Usuario eliminado correctamente": "User/s successfully deleted", + "Error al eliminar el usuario": "Failed to delete user/s" } diff --git a/web/usuaris/src/locales/es.json b/web/usuaris/src/locales/es.json index 2ae4140d3..b53b73bad 100644 --- a/web/usuaris/src/locales/es.json +++ b/web/usuaris/src/locales/es.json @@ -124,6 +124,7 @@ "Aceptar": "Aceptar", "Bloquear": "Bloquear", "Desbloquear": "Desbloquear", + "Eliminar": "Eliminar", "ADMIN": "Administrador", "REGISTERED": "Registrado", "MENTOR": "Mentor", @@ -139,7 +140,8 @@ "Buscar": "Buscar", "Eliminado": "Eliminado", "DELETED": "Eliminado", - "Eliminar usuario": "¿Estás seguro de que deseas eliminar este usuario?", + "Eliminar usuario": "¿Estás seguro de que deseas eliminar este/os usuario/s?", "Confirmar": "Confirmar", - "Error al eliminar el usuario": "Error al eliminar el usuario" + "Usuario eliminado correctamente": "Usuario/s eliminado/s correctamente", + "Error al eliminar el usuario": "Error al eliminar el/los usuario/s" } diff --git a/web/usuaris/src/pages/Home.tsx b/web/usuaris/src/pages/Home.tsx index db67ecec7..3b3e7501d 100644 --- a/web/usuaris/src/pages/Home.tsx +++ b/web/usuaris/src/pages/Home.tsx @@ -1,4 +1,4 @@ -import { FC, useState } from 'react' +import { FC, useEffect, useState } from 'react' import styled from 'styled-components' import { FlexBox, colors, dimensions } from '@itacademy/ui' import { @@ -9,7 +9,12 @@ import { UsersTable, } from '../components/organisms' import { useAuth } from '../context/AuthProvider' -import { TFilters } from '../types' +import { TFilters, TUpdatedUsersStatus, UserStatus } from '../types' +import { + ActionsDropdown, + DeleteConfirmationModal, +} from '../components/molecules' +import { useUpdateUsersStatus } from '../hooks' const Container = styled(FlexBox)` width: 100%; @@ -33,6 +38,10 @@ const MainDiv = styled(FlexBox)` color: ${colors.gray.gray3}; ` +const ContainerFiltersActions = styled(FlexBox)` + width: 100%; +` + const LoginContainer = styled(FlexBox)` width: 50%; height: auto; @@ -43,8 +52,39 @@ const LoginContainer = styled(FlexBox)` export const Home: FC = () => { const { user } = useAuth() + const { changeUsersStatus, isSuccess: statusSuccess } = useUpdateUsersStatus() const [filters, setFilters] = useState({}) + const [selectedStatus, setSelectedStatus] = useState( + undefined + ) + const [selectedUsers, setSelectedUsers] = useState([]) + const [isActionFinished, setIsActionFinished] = useState(false) + const [isModalOpen, setIsModalOpen] = useState(false) + + const handleSelectedUsers = (users: string[]) => { + setSelectedUsers(users) + } + + const handleAction = (action: string | undefined) => { + setIsActionFinished(false) + if (action && action !== 'DELETE') { + const updatedUsersStatus: TUpdatedUsersStatus = { + ids: selectedUsers, + status: action, + } + changeUsersStatus.mutate(updatedUsersStatus) + } else { + setIsModalOpen(!isModalOpen) + } + } + + useEffect(() => { + if (statusSuccess) { + setSelectedStatus(undefined) + setIsActionFinished(true) + } + }, [statusSuccess]) if (!user) return ( @@ -62,20 +102,48 @@ export const Home: FC = () => { ) return ( - - - - - - - - - - + <> + + + + + + + + + + + + + + { + setIsModalOpen(false) + setSelectedStatus(undefined) + setIsActionFinished(true) + }} + idsToDelete={selectedUsers} + /> + ) } diff --git a/web/usuaris/src/types/index.tsx b/web/usuaris/src/types/index.tsx index 6f792c87b..77631dbae 100644 --- a/web/usuaris/src/types/index.tsx +++ b/web/usuaris/src/types/index.tsx @@ -2,4 +2,5 @@ export { UserStatus } from './types' export { type TFilters } from './types' export { type TItinerary } from './types' export { type TUpdatedUser } from './types' +export { type TUpdatedUsersStatus } from './types' export { type TUserData } from './types' diff --git a/web/usuaris/src/types/types.tsx b/web/usuaris/src/types/types.tsx index 40a7e23fd..4aea48e77 100644 --- a/web/usuaris/src/types/types.tsx +++ b/web/usuaris/src/types/types.tsx @@ -39,6 +39,11 @@ export type TUpdatedUser = { itineraryId?: string } +export type TUpdatedUsersStatus = { + ids: string[] + status: string +} + export type TUserData = { id: string name: string