diff --git a/docs/pages/dev/reference/guidelines/front-end/front-end.en.md b/docs/pages/dev/reference/guidelines/front-end/front-end.en.md index 7e3a7f1975..09dbd63218 100644 --- a/docs/pages/dev/reference/guidelines/front-end/front-end.en.md +++ b/docs/pages/dev/reference/guidelines/front-end/front-end.en.md @@ -21,9 +21,8 @@ Don't be afraid to split your code into smaller parts, using understandable nami ## Legacy -Class component, redux, provider are still old way to create features in IASO. +Class component, proptypes are still old way to create features in IASO. Please use `hooks`, `typescript` and `arrow component`. -Redux can still be used with state that needs to be available everywhere in the application (current user, UI constants and states, ...). We already have a lot of typing done in each domain of the application (forms, submissions, org units, ... ) ## Bluesquare-components @@ -37,7 +36,7 @@ To make it available too everybody you have to build new files with `npm run cle ## Architecture Main index file is located here: `hat/assets/js/apps/Iaso/index` -This is the entrypoint of the app, setting up providers, theme, react-query query client, custom plugins, redux,... +This is the entrypoint of the app, setting up providers, theme, react-query query client, custom plugins,... **`components`** Used to store generic components that can be used everywhere, like `inputComponent`, `buttons`, ... diff --git a/hat/assets/js/apps/Iaso/components/forms/Checkboxes.spec.js b/hat/assets/js/apps/Iaso/components/forms/Checkboxes.spec.js index 2f28a02b2b..68ae7611fa 100644 --- a/hat/assets/js/apps/Iaso/components/forms/Checkboxes.spec.js +++ b/hat/assets/js/apps/Iaso/components/forms/Checkboxes.spec.js @@ -1,9 +1,10 @@ -import React from 'react'; +import { Box } from '@mui/material'; import { expect } from 'chai'; import { mount } from 'enzyme'; -import { Box } from '@mui/material'; +import React from 'react'; +import { renderWithIntl } from '../../../../test/utils/intl'; +import { renderWithMuiTheme } from '../../../../test/utils/muiTheme'; import { Checkboxes } from './Checkboxes'; -import { renderWithStore } from '../../../../test/utils/redux'; import InputComponent from './InputComponent'; let component; @@ -40,8 +41,13 @@ const checkboxesProp = () => { }; const renderComponent = props => { component = mount( - renderWithStore( - , + renderWithMuiTheme( + renderWithIntl( + , + ), ), ); }; diff --git a/hat/assets/js/apps/Iaso/components/forms/EditableTextFields.spec.js b/hat/assets/js/apps/Iaso/components/forms/EditableTextFields.spec.js index 398a368521..94f44012e3 100644 --- a/hat/assets/js/apps/Iaso/components/forms/EditableTextFields.spec.js +++ b/hat/assets/js/apps/Iaso/components/forms/EditableTextFields.spec.js @@ -1,9 +1,8 @@ import React from 'react'; +import { renderWithIntl } from '../../../../test/utils/intl'; import { renderWithMuiTheme } from '../../../../test/utils/muiTheme'; import { EditableTextFields } from './EditableTextFields'; -import { renderWithIntl } from '../../../../test/utils/intl'; import InputComponent from './InputComponent.tsx'; -import { renderWithStore } from '../../../../test/utils/redux'; const onChange1 = sinon.spy(); const onChange2 = sinon.spy(); @@ -34,9 +33,7 @@ let inputs; const renderComponent = props => { return mount( renderWithIntl( - renderWithMuiTheme( - renderWithStore(), - ), + renderWithMuiTheme(), ), ); }; diff --git a/hat/assets/js/apps/Iaso/components/maps/markers/CircleMarkerComponent.js b/hat/assets/js/apps/Iaso/components/maps/markers/CircleMarkerComponent.js index a2499eb914..31faa570f4 100644 --- a/hat/assets/js/apps/Iaso/components/maps/markers/CircleMarkerComponent.js +++ b/hat/assets/js/apps/Iaso/components/maps/markers/CircleMarkerComponent.js @@ -31,7 +31,10 @@ const CircleMarkerComponent = props => { draggable={draggable} center={[item.latitude, item.longitude, item.altitude]} eventHandlers={{ - click: () => onClick(item), + click: e => { + e.originalEvent.stopPropagation(); + onClick(item); + }, dragend: e => onDragend(e.target), dblclick: e => onDblclick(e, item), }} diff --git a/hat/assets/js/apps/Iaso/components/maps/markers/MarkerComponent.js b/hat/assets/js/apps/Iaso/components/maps/markers/MarkerComponent.js index d26924ca31..d3a630f901 100644 --- a/hat/assets/js/apps/Iaso/components/maps/markers/MarkerComponent.js +++ b/hat/assets/js/apps/Iaso/components/maps/markers/MarkerComponent.js @@ -31,7 +31,10 @@ const MarkerComponent = props => { icon={marker || customMarker} position={[item.latitude, item.longitude, item.altitude]} eventHandlers={{ - click: () => onClick(item), + click: e => { + e.originalEvent.stopPropagation(); + onClick(item); + }, dragend: e => onDragend(e.target), dblclick: e => onDblclick(e, item), }} diff --git a/hat/assets/js/apps/Iaso/components/nav/AccountSwitch.tsx b/hat/assets/js/apps/Iaso/components/nav/AccountSwitch.tsx new file mode 100644 index 0000000000..d4de431107 --- /dev/null +++ b/hat/assets/js/apps/Iaso/components/nav/AccountSwitch.tsx @@ -0,0 +1,147 @@ +import { + ClickAwayListener, + Grow, + MenuItem, + MenuList, + Paper, + Popper, + Typography, +} from '@mui/material'; +import React, { FunctionComponent, useEffect, useRef, useState } from 'react'; + +import { useSwitchAccount } from '../../hooks/useSwitchAccount'; +import { useCurrentUser } from '../../utils/usersUtils'; + +type Props = { + color?: 'inherit' | 'primary' | 'secondary'; +}; + +export const AccountSwitch: FunctionComponent = ({ + color = 'inherit', +}) => { + const currentUser = useCurrentUser(); + + const [open, setOpen] = useState(false); + const anchorRef = useRef(null); + + const { mutateAsync: switchAccount } = useSwitchAccount(() => { + setOpen(false); + window.location.href = '/'; + }); + + const handleToggle = () => { + setOpen(prevOpen => !prevOpen); + }; + + const handleClose = (event: Event | React.SyntheticEvent) => { + if ( + anchorRef.current && + anchorRef.current.contains(event.target as HTMLElement) + ) { + return; + } + setOpen(false); + }; + + function handleListKeyDown(event: React.KeyboardEvent) { + if (event.key === 'Tab') { + event.preventDefault(); + setOpen(false); + } else if (event.key === 'Escape') { + setOpen(false); + } + } + + // Return focus to the button when we transitioned from !open -> open + const prevOpen = useRef(open); + useEffect(() => { + if (prevOpen.current === true && open === false) { + anchorRef.current?.focus(); + } + prevOpen.current = open; + }, [open]); + const menuListKeyDownHandler = React.useCallback(handleListKeyDown, []); + + if (currentUser.other_accounts.length === 0) { + return ( + theme.spacing(0), + fontSize: 16, + }} + > + {currentUser.account.name} + + ); + } + + return ( + <> + theme.spacing(0), + cursor: 'pointer', + fontSize: 16, + '&:hover': { + color: theme => theme.palette.secondary.main, + }, + }} + aria-controls={open ? 'account-menu' : undefined} + aria-expanded={open ? 'true' : undefined} + aria-haspopup="true" + > + {currentUser.account.name} + + + {({ TransitionProps }) => ( + + + + + {currentUser.other_accounts.map(account => ( + + switchAccount(account.id) + } + > + {account.name} + + ))} + + + + + )} + + + ); +}; diff --git a/hat/assets/js/apps/Iaso/components/nav/CurrentUser/index.tsx b/hat/assets/js/apps/Iaso/components/nav/CurrentUser/index.tsx index 413fe2c6a4..fd3d6a6b67 100644 --- a/hat/assets/js/apps/Iaso/components/nav/CurrentUser/index.tsx +++ b/hat/assets/js/apps/Iaso/components/nav/CurrentUser/index.tsx @@ -1,12 +1,12 @@ import React, { FunctionComponent, useState } from 'react'; -import { Popover, Typography } from '@mui/material'; +import { Box, Popover, Typography } from '@mui/material'; import { makeStyles } from '@mui/styles'; -import classnames from 'classnames'; import { useSafeIntl } from 'bluesquare-components'; +import MESSAGES from '../../../domains/app/components/messages'; import { getDefaultSourceVersion } from '../../../domains/dataSources/utils'; import { User } from '../../../utils/usersUtils'; -import MESSAGES from '../../../domains/app/components/messages'; +import { AccountSwitch } from '../AccountSwitch'; type Props = { currentUser: User; @@ -23,9 +23,11 @@ const useStyles = makeStyles(theme => ({ currentUserInfos: { display: 'block', textAlign: 'right', - }, - account: { - fontSize: 9, + cursor: 'pointer', + fontSize: 16, + '&:hover': { + color: theme.palette.secondary.main, + }, }, popOverInfos: { display: 'block', @@ -58,27 +60,17 @@ export const CurrentUserInfos: FunctionComponent = ({ return ( <> - - - {currentUser?.user_name} - - - - {currentUser?.account?.name} - - - + {currentUser?.user_name} + + - + ({ menuButton: { @@ -77,7 +77,7 @@ function TopBar(props) { container item direction="row" - xs={9} + xs={7} alignItems="center" > {!displayBackButton && displayMenuButton && ( @@ -111,7 +111,7 @@ function TopBar(props) { {currentUser && !isMobileLayout && ( - + + + diff --git a/hat/assets/js/apps/Iaso/domains/app/index.spec.js b/hat/assets/js/apps/Iaso/domains/app/index.spec.js index bec4c7e486..23c52ec6c2 100644 --- a/hat/assets/js/apps/Iaso/domains/app/index.spec.js +++ b/hat/assets/js/apps/Iaso/domains/app/index.spec.js @@ -1,16 +1,15 @@ import React from 'react'; +import { renderWithIntl } from '../../../../test/utils/intl'; +import { renderWithMuiTheme } from '../../../../test/utils/muiTheme'; import App from './index.tsx'; -import { renderWithStore } from '../../../../test/utils/redux'; describe('App', () => { it('render properly', () => { const wrapper = shallow( - renderWithStore(, { - subscribe: () => null, - dispatch: () => null, - getState: () => null, - }), + renderWithMuiTheme( + renderWithIntl(), + ), ); expect(wrapper.exists()).to.be.true; }); diff --git a/hat/assets/js/apps/Iaso/domains/app/translations/en.json b/hat/assets/js/apps/Iaso/domains/app/translations/en.json index c9b189f545..cb564ef273 100644 --- a/hat/assets/js/apps/Iaso/domains/app/translations/en.json +++ b/hat/assets/js/apps/Iaso/domains/app/translations/en.json @@ -1380,6 +1380,7 @@ "iaso.userRoles.dialogInfoTitle": "Warning on the new created user role", "iaso.userRoles.edit": "Edit user role", "iaso.userRoles.infoButton": "Got it!", + "iaso.userRoles.orgUnitWriteTypesInfos": "Select the org unit types the user role can edit", "iaso.userRoles.title": "User roles", "iaso.userRoles.userRolePermissions": "User role permissions", "iaso.users.addLocations": "Add location(s)", diff --git a/hat/assets/js/apps/Iaso/domains/app/translations/fr.json b/hat/assets/js/apps/Iaso/domains/app/translations/fr.json index f5d38f2ecd..772438f381 100644 --- a/hat/assets/js/apps/Iaso/domains/app/translations/fr.json +++ b/hat/assets/js/apps/Iaso/domains/app/translations/fr.json @@ -15,7 +15,6 @@ "blsq.treeview.label.selectSingle": "Sélectionner une unités d'organisation", "blsq.treeview.loading": "Chargement", "blsq.treeview.search.cancel": "Annuler", - "iaso.users.selectAllHelperText": "Laisser vide pour tout sélectionner", "blsq.treeview.search.confirm": "Confirmer", "blsq.treeview.search.inputLabelObject": "Rechercher", "blsq.treeview.search.options.label.clear": "Effacer", @@ -1381,6 +1380,7 @@ "iaso.userRoles.dialogInfoTitle": "Avertissement sur le nouveau rôle utilisateur créé", "iaso.userRoles.edit": "Editer un rôle d'utilisateur", "iaso.userRoles.infoButton": "Compris!", + "iaso.userRoles.orgUnitWriteTypesInfos": "Sélectionner les types d'unités d'org. que ce rôle d'utilisateur peut modifier", "iaso.userRoles.title": "Rôles des utilisateurs", "iaso.userRoles.userRolePermissions": "Permissions du rôle d'utilisateurs", "iaso.users.addLocations": "Ajouter la(les) localisation(s)", @@ -1440,6 +1440,7 @@ "iaso.users.removeProjects": "Retirer du(des) projet(s)", "iaso.users.removeRoles": "Retirer le(s) rôle(s)", "iaso.users.removeTeams": "Enlever de l'/des équipe(s)", + "iaso.users.selectAllHelperText": "Laisser vide pour tout sélectionner", "iaso.users.selectedOrgUnits": "Unité d'organisation sélectionnées", "iaso.users.update": "Mettre l'utilisateur à jour", "iaso.users.userPermissions": "Permissions d'utilisateur", diff --git a/hat/assets/js/apps/Iaso/domains/completenessStats/index.tsx b/hat/assets/js/apps/Iaso/domains/completenessStats/index.tsx index e07a3307e7..4598dd644d 100644 --- a/hat/assets/js/apps/Iaso/domains/completenessStats/index.tsx +++ b/hat/assets/js/apps/Iaso/domains/completenessStats/index.tsx @@ -14,7 +14,6 @@ import React, { useMemo, useState, } from 'react'; -import { useDispatch } from 'react-redux'; import { CsvButton } from '../../components/Buttons/CsvButton'; import TopBar from '../../components/nav/TopBarComponent'; import { openSnackBar } from '../../components/snackBars/EventDispatcher'; @@ -59,7 +58,6 @@ export const CompletenessStats: FunctionComponent = () => { ) as CompletenessRouterParams; const [tab, setTab] = useState<'list' | 'map'>(params.tab ?? 'list'); - const dispatch = useDispatch(); const redirectTo = useRedirectTo(); const { formatMessage } = useSafeIntl(); const { data: completenessStats, isFetching } = @@ -90,7 +88,7 @@ export const CompletenessStats: FunctionComponent = () => { closeSnackbar(snackbarKey); } }; - }, [dispatch, displayWarning]); + }, [displayWarning]); const csvUrl = useMemo( () => `/api/v2/completeness_stats.csv?${buildQueryString(params, true)}`, diff --git a/hat/assets/js/apps/Iaso/domains/entities/duplicates/list/AnalyseAction.tsx b/hat/assets/js/apps/Iaso/domains/entities/duplicates/list/AnalyseAction.tsx index 0c97c8db51..d638b3e6b3 100644 --- a/hat/assets/js/apps/Iaso/domains/entities/duplicates/list/AnalyseAction.tsx +++ b/hat/assets/js/apps/Iaso/domains/entities/duplicates/list/AnalyseAction.tsx @@ -39,8 +39,9 @@ export const AnalyseAction: FunctionComponent = ({ {!latestAnalysis && !isFetchingLatestAnalysis && formatMessage(MESSAGES.noAnalysis)} - {latestAnalysis && ( - <> + + <> + {latestAnalysis && ( @@ -67,7 +68,9 @@ export const AnalyseAction: FunctionComponent = ({ - + )} + + {latestAnalysis && ( - - - - - + )} + + + + - - )} + + ); }; diff --git a/hat/assets/js/apps/Iaso/domains/entities/hooks/useGetBeneficiaryFields.ts b/hat/assets/js/apps/Iaso/domains/entities/hooks/useGetBeneficiaryFields.ts index a3729a40f8..66a7d34bf9 100644 --- a/hat/assets/js/apps/Iaso/domains/entities/hooks/useGetBeneficiaryFields.ts +++ b/hat/assets/js/apps/Iaso/domains/entities/hooks/useGetBeneficiaryFields.ts @@ -1,14 +1,14 @@ -import { useMemo } from 'react'; import { useSafeIntl } from 'bluesquare-components'; +import { useMemo } from 'react'; import { useGetPossibleFields } from '../../forms/hooks/useGetPossibleFields'; +import MESSAGES from '../messages'; import { Beneficiary } from '../types/beneficiary'; import { Field } from '../types/fields'; -import MESSAGES from '../messages'; -import { useGetFields } from './useGetFields'; import { useGetBeneficiaryTypesDropdown } from './requests'; +import { useGetFields } from './useGetFields'; -export const useGetBeneficiaryFields = (beneficiary: Beneficiary) => { +export const useGetBeneficiaryFields = (beneficiary?: Beneficiary) => { const { formatMessage } = useSafeIntl(); const { data: beneficiaryTypes } = useGetBeneficiaryTypesDropdown(); diff --git a/hat/assets/js/apps/Iaso/domains/forms/components/FormActions.tsx b/hat/assets/js/apps/Iaso/domains/forms/components/FormActions.tsx index 1148dcf728..03af452f48 100644 --- a/hat/assets/js/apps/Iaso/domains/forms/components/FormActions.tsx +++ b/hat/assets/js/apps/Iaso/domains/forms/components/FormActions.tsx @@ -1,18 +1,17 @@ -import React, { FunctionComponent, useState } from 'react'; -import { IconButton } from 'bluesquare-components'; +import { Download } from '@mui/icons-material'; +import FormatListBulleted from '@mui/icons-material/FormatListBulleted'; import { Menu, MenuItem } from '@mui/material'; +import { IconButton } from 'bluesquare-components'; +import React, { FunctionComponent, useState } from 'react'; import { Link } from 'react-router-dom'; -import FormatListBulleted from '@mui/icons-material/FormatListBulleted'; -import { useDispatch } from 'react-redux'; -import { Download } from '@mui/icons-material'; +import DeleteDialog from '../../../components/dialogs/DeleteDialogComponent'; import { DisplayIfUserHasPerm } from '../../../components/DisplayIfUserHasPerm'; -import { CreateSubmissionModal } from './CreateSubmissionModal/CreateSubmissionModal'; import * as Permission from '../../../utils/permissions'; -import DeleteDialog from '../../../components/dialogs/DeleteDialogComponent'; -import MESSAGES from '../messages'; -import { useRestoreForm } from '../hooks/useRestoreForm'; import { createInstance } from '../../instances/actions'; import { useDeleteForm } from '../hooks/useDeleteForm'; +import { useRestoreForm } from '../hooks/useRestoreForm'; +import MESSAGES from '../messages'; +import { CreateSubmissionModal } from './CreateSubmissionModal/CreateSubmissionModal'; type Props = { settings: any; @@ -27,7 +26,6 @@ export const FormActions: FunctionComponent = ({ baseUrls, showDeleted, }) => { - const dispatch = useDispatch(); // XLS and XML download states and functions const [anchorEl, setAnchorEl] = useState(null); const open = Boolean(anchorEl); @@ -91,14 +89,7 @@ export const FormActions: FunctionComponent = ({ onCreateOrReAssign={( currentForm, payload, - ) => - dispatch( - createInstance( - currentForm, - payload, - ), - ) - } + ) => createInstance(currentForm, payload)} orgUnitTypes={ settings.row.original.org_unit_type_ids } diff --git a/hat/assets/js/apps/Iaso/domains/forms/components/FormVersionsDialogComponent.js b/hat/assets/js/apps/Iaso/domains/forms/components/FormVersionsDialogComponent.js index 4be9de5652..f51daf733c 100644 --- a/hat/assets/js/apps/Iaso/domains/forms/components/FormVersionsDialogComponent.js +++ b/hat/assets/js/apps/Iaso/domains/forms/components/FormVersionsDialogComponent.js @@ -1,17 +1,17 @@ import { Box, Grid, Typography } from '@mui/material'; +import { LoadingSpinner, useSafeIntl } from 'bluesquare-components'; import PropTypes from 'prop-types'; import React, { useCallback, useMemo, useState } from 'react'; -import { LoadingSpinner, useSafeIntl } from 'bluesquare-components'; import { useQueryClient } from 'react-query'; import ConfirmCancelDialogComponent from '../../../components/dialogs/ConfirmCancelDialogComponent'; import FileInputComponent from '../../../components/forms/FileInputComponent'; -import PeriodPicker from '../../periods/components/PeriodPicker.tsx'; +import { openSnackBar } from '../../../components/snackBars/EventDispatcher.ts'; +import { succesfullSnackBar } from '../../../constants/snackBars'; import { useFormState } from '../../../hooks/form'; import { createFormVersion, updateFormVersion } from '../../../utils/requests'; +import PeriodPicker from '../../periods/components/PeriodPicker.tsx'; import { errorTypes, getPeriodsErrors } from '../../periods/utils'; import MESSAGES from '../messages'; -import { openSnackBar } from '../../../components/snackBars/EventDispatcher.ts'; -import { succesfullSnackBar } from '../../../constants/snackBars'; const emptyVersion = (id = null) => ({ id, diff --git a/hat/assets/js/apps/Iaso/domains/forms/components/FormVersionsDialogComponent.spec.js b/hat/assets/js/apps/Iaso/domains/forms/components/FormVersionsDialogComponent.spec.js index efd3a2ee18..6902e79cfd 100644 --- a/hat/assets/js/apps/Iaso/domains/forms/components/FormVersionsDialogComponent.spec.js +++ b/hat/assets/js/apps/Iaso/domains/forms/components/FormVersionsDialogComponent.spec.js @@ -1,18 +1,17 @@ -import { AdapterMoment } from '@mui/x-date-pickers/AdapterMoment'; -import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import React from 'react'; import { act } from 'react-dom/test-utils'; import { IconButton as IconButtonComponent } from 'bluesquare-components'; import { expect } from 'chai'; import { withQueryClientProvider } from '../../../../../test/utils'; -import { renderWithStore } from '../../../../../test/utils/redux'; +import { renderWithIntl } from '../../../../../test/utils/intl'; +import { renderWithMuiTheme } from '../../../../../test/utils/muiTheme'; import ConfirmCancelDialogComponent from '../../../components/dialogs/ConfirmCancelDialogComponent'; import PeriodPicker from '../../periods/components/PeriodPicker.tsx'; import { PERIOD_TYPE_DAY } from '../../periods/constants'; import formVersionFixture from '../fixtures/formVersions.json'; import MESSAGES from '../messages'; -import FormVersionsDialog from './FormVersionsDialogComponent'; +import FormVersionsDialogComponent from './FormVersionsDialogComponent'; let connectedWrapper; @@ -34,15 +33,12 @@ const awaitUseEffect = async wrapper => { return Promise.resolve(); }; -const getConnectedWrapper = () => +const renderComponent = () => mount( - withQueryClientProvider( - renderWithStore( - - )} onConfirmed={() => null} periodType={PERIOD_TYPE_DAY} - /> - , - { - forms: { - current: undefined, - }, - }, + />, + ), ), ), ); @@ -71,27 +62,26 @@ describe('FormVersionsDialog connected component', () => { describe('with a new form version', () => { before(() => { connectedWrapper = mount( - withQueryClientProvider( - renderWithStore( - null} - periodType={PERIOD_TYPE_DAY} - renderTrigger={({ openDialog }) => ( - - )} - />, - { - forms: { - current: undefined, - }, - }, + renderWithIntl( + renderWithMuiTheme( + withQueryClientProvider( + null} + periodType={PERIOD_TYPE_DAY} + renderTrigger={({ openDialog }) => ( + + )} + />, + ), ), ), ); @@ -124,7 +114,7 @@ describe('FormVersionsDialog connected component', () => { describe('with a full form version', () => { before(() => { - connectedWrapper = getConnectedWrapper(); + connectedWrapper = renderComponent(); inputComponent = connectedWrapper.find('#open-dialog').at(0); inputComponent.props().onClick(); connectedWrapper.update(); @@ -191,7 +181,7 @@ describe('FormVersionsDialog connected component', () => { describe('onConfirm', () => { before(() => { - connectedWrapper = getConnectedWrapper(); + connectedWrapper = renderComponent(); inputComponent = connectedWrapper.find('#open-dialog').at(0); inputComponent.props().onClick(); connectedWrapper.update(); diff --git a/hat/assets/js/apps/Iaso/domains/home/HomeOnline.tsx b/hat/assets/js/apps/Iaso/domains/home/HomeOnline.tsx index 6193a22529..d285640cd0 100644 --- a/hat/assets/js/apps/Iaso/domains/home/HomeOnline.tsx +++ b/hat/assets/js/apps/Iaso/domains/home/HomeOnline.tsx @@ -129,8 +129,16 @@ export const HomeOnline: FunctionComponent = () => { version={(window as any).IASO_VERSION} /> - - + + + + diff --git a/hat/assets/js/apps/Iaso/domains/instances/actions.js b/hat/assets/js/apps/Iaso/domains/instances/actions.js index 8415caf599..cde321cd6f 100644 --- a/hat/assets/js/apps/Iaso/domains/instances/actions.js +++ b/hat/assets/js/apps/Iaso/domains/instances/actions.js @@ -1,78 +1,7 @@ -import { - getRequest, - patchRequest, - postRequest, - putRequest, -} from 'Iaso/libs/Api.ts'; +import { postRequest, putRequest } from 'Iaso/libs/Api.ts'; import { openSnackBar } from '../../components/snackBars/EventDispatcher.ts'; import { errorSnackBar, succesfullSnackBar } from '../../constants/snackBars'; -export const SET_INSTANCES_FETCHING = 'SET_INSTANCES_FETCHING'; -export const SET_CURRENT_INSTANCE = 'SET_CURRENT_INSTANCE'; -export const SET_INSTANCES_FILTER_UDPATED = 'SET_INSTANCES_FILTER_UDPATED'; - -export const setInstancesFilterUpdated = isUpdated => ({ - type: SET_INSTANCES_FILTER_UDPATED, - payload: isUpdated, -}); - -export const setInstancesFetching = isFetching => ({ - type: SET_INSTANCES_FETCHING, - payload: isFetching, -}); - -export const setCurrentInstance = instance => ({ - type: SET_CURRENT_INSTANCE, - payload: instance, -}); - -export const fetchEditUrl = (currentInstance, location) => dispatch => { - dispatch(setInstancesFetching(true)); - const url = `/api/enketo/edit/${currentInstance.uuid}?return_url=${location}`; - return getRequest(url) - .then(resp => { - window.location.href = resp.edit_url; - }) - .catch(err => { - openSnackBar(errorSnackBar('fetchEnketoError', null, err)); - }) - .then(() => { - dispatch(setInstancesFetching(false)); - }); -}; - -export const fetchInstanceDetail = instanceId => dispatch => { - dispatch(setInstancesFetching(true)); - return getRequest(`/api/instances/${instanceId}/`) - .then(res => { - dispatch(setCurrentInstance(res)); - return res; - }) - .catch(err => - openSnackBar(errorSnackBar('fetchInstanceError', null, err)), - ) - .then(res => { - dispatch(setInstancesFetching(false)); - return res; - }); -}; - -export const reAssignInstance = (currentInstance, payload) => dispatch => { - dispatch(setInstancesFetching(true)); - const effectivePayload = { ...payload }; - if (!payload.period) delete effectivePayload.period; - patchRequest(`/api/instances/${currentInstance.id}/`, effectivePayload) - .then(() => { - dispatch(fetchInstanceDetail(currentInstance.id)); - }) - .catch(err => - openSnackBar(errorSnackBar('assignInstanceError', null, err)), - ) - .then(() => { - dispatch(setInstancesFetching(false)); - }); -}; - /* Submission Creation workflow * 1. this function call backend create Instance in DB * 2. backend contact enketo to generate a Form page @@ -81,8 +10,7 @@ export const reAssignInstance = (currentInstance, payload) => dispatch => { * 5. After submission Enketo/Backend redirect to the submission detail page * See enketo/README.md for full details. */ -export const createInstance = (currentForm, payload) => dispatch => { - dispatch(setInstancesFetching(true)); +export const createInstance = (currentForm, payload) => { // if (!payload.period) delete payload.period; return postRequest('/api/enketo/create/', { org_unit_id: payload.org_unit, @@ -95,13 +23,11 @@ export const createInstance = (currentForm, payload) => dispatch => { }, err => { openSnackBar(errorSnackBar(null, 'Enketo', err)); - dispatch(setInstancesFetching(false)); }, ); }; -export const createExportRequest = (filterParams, selection) => dispatch => { - dispatch(setInstancesFetching(true)); +export const createExportRequest = (filterParams, selection) => { const filters = { ...filterParams, }; @@ -125,32 +51,25 @@ export const createExportRequest = (filterParams, selection) => dispatch => { ? `createExportRequestError${err.details.code}` : 'createExportRequestError'; openSnackBar(errorSnackBar(key, null, err)); - }) - .then(() => dispatch(setInstancesFetching(false))); + }); }; -export const bulkDelete = - (selection, filters, isUnDeleteAction, successFn) => dispatch => { - dispatch(setInstancesFetching(true)); - return postRequest('/api/instances/bulkdelete/', { - select_all: selection.selectAll, - selected_ids: selection.selectedItems.map(i => i.id), - unselected_ids: selection.unSelectedItems.map(i => i.id), - is_deletion: !isUnDeleteAction, - ...filters, +export const bulkDelete = (selection, filters, isUnDeleteAction, successFn) => { + return postRequest('/api/instances/bulkdelete/', { + select_all: selection.selectAll, + selected_ids: selection.selectedItems.map(i => i.id), + unselected_ids: selection.unSelectedItems.map(i => i.id), + is_deletion: !isUnDeleteAction, + ...filters, + }) + .then(res => { + openSnackBar(succesfullSnackBar('saveMultiEditOrgUnitsSuccesfull')); + successFn(); + return res; }) - .then(res => { - openSnackBar( - succesfullSnackBar('saveMultiEditOrgUnitsSuccesfull'), - ); - successFn(); - dispatch(setInstancesFetching(false)); - return res; - }) - .catch(error => { - openSnackBar( - errorSnackBar('saveMultiEditOrgUnitsError', null, error), - ); - dispatch(setInstancesFetching(false)); - }); - }; + .catch(error => { + openSnackBar( + errorSnackBar('saveMultiEditOrgUnitsError', null, error), + ); + }); +}; diff --git a/hat/assets/js/apps/Iaso/domains/instances/actions.spec.js b/hat/assets/js/apps/Iaso/domains/instances/actions.spec.js deleted file mode 100644 index 8596d60e9f..0000000000 --- a/hat/assets/js/apps/Iaso/domains/instances/actions.spec.js +++ /dev/null @@ -1,51 +0,0 @@ -import { - SET_CURRENT_INSTANCE, - SET_INSTANCES_FILTER_UDPATED, - setCurrentInstance, - setInstancesFilterUpdated, -} from './actions'; - -// const Api = require('iaso/libs/Api'); -// const snackBars = require('../../constants/snackBars'); - -// const formsActions = require('../../../redux/actions/formsActions'); - -// let actionStub; -describe('Instances actions', () => { - it('should create an action to set instance filter update', () => { - const payload = false; - const expectedAction = { - type: SET_INSTANCES_FILTER_UDPATED, - payload, - }; - const action = setInstancesFilterUpdated(payload); - expect(action).to.eql(expectedAction); - }); - it('should create an action to set current instance', () => { - const payload = { - id: 0, - name: 'LINK', - }; - const expectedAction = { - type: SET_CURRENT_INSTANCE, - payload, - }; - const action = setCurrentInstance(payload); - expect(action).to.eql(expectedAction); - }); - // it('should call getRequest on fetchEditUrl', () => { - // const resp = { - // edit_url: 'https://www.nintendo.be/', - // }; - // actionStub = sinon - // .stub(Api, 'getRequest') - // .returns(new Promise(resolve => resolve(resp))); - // fetchEditUrl({ - // uuid: 'KOKIRI', - // })(fn => fn); - // expect(actionStub.calledOnce).to.equal(true); - // }); - afterEach(() => { - sinon.restore(); - }); -}); diff --git a/hat/assets/js/apps/Iaso/domains/instances/components/CreateReAssignDialogComponent.tsx b/hat/assets/js/apps/Iaso/domains/instances/components/CreateReAssignDialogComponent.tsx index 237804c701..01b8e89165 100644 --- a/hat/assets/js/apps/Iaso/domains/instances/components/CreateReAssignDialogComponent.tsx +++ b/hat/assets/js/apps/Iaso/domains/instances/components/CreateReAssignDialogComponent.tsx @@ -6,12 +6,13 @@ import { useSafeIntl, } from 'bluesquare-components'; import React, { FunctionComponent, useState } from 'react'; +import { UseMutateAsyncFunction } from 'react-query'; import { OrgUnitTreeviewModal } from '../../orgUnits/components/TreeView/OrgUnitTreeviewModal'; import PeriodPicker from '../../periods/components/PeriodPicker'; import { Period } from '../../periods/models'; import { isValidPeriod } from '../../periods/utils'; +import { ReassignInstancePayload } from '../hooks/useReassignInstance'; import MESSAGES from '../messages'; -import { Instance } from '../types/instance'; type Props = { titleMessage: any; @@ -27,10 +28,12 @@ type Props = { // eslint-disable-next-line camelcase org_unit?: any; }; - onCreateOrReAssign: ( - instanceOrForm: Instance | { id: number }, - payload: { period: any; org_unit: any }, - ) => void; + onCreateOrReAssign: UseMutateAsyncFunction< + unknown, + unknown, + ReassignInstancePayload, + unknown + >; orgUnitTypes: number[]; isOpen: boolean; closeDialog: () => void; @@ -87,7 +90,8 @@ export const CreateReAssignDialogComponent: FunctionComponent = ({ const onConfirm = () => { const currentFormOrInstanceProp = currentInstance || formType; - onCreateOrReAssign(currentFormOrInstanceProp, { + onCreateOrReAssign({ + currentInstance: currentFormOrInstanceProp, period: fieldValue.period.value, org_unit: fieldValue.orgUnit.value?.id, }); diff --git a/hat/assets/js/apps/Iaso/domains/instances/components/DeleteInstanceDialog.js b/hat/assets/js/apps/Iaso/domains/instances/components/DeleteInstanceDialog.js index 5c1fa3fe18..b6bce64284 100644 --- a/hat/assets/js/apps/Iaso/domains/instances/components/DeleteInstanceDialog.js +++ b/hat/assets/js/apps/Iaso/domains/instances/components/DeleteInstanceDialog.js @@ -1,11 +1,10 @@ -import React, { useState } from 'react'; -import PropTypes from 'prop-types'; -import { FormattedMessage } from 'react-intl'; -import { useDispatch } from 'react-redux'; -import { DialogContentText } from '@mui/material'; -import { makeStyles } from '@mui/styles'; import DeleteIcon from '@mui/icons-material/Delete'; import RestoreFromTrashIcon from '@mui/icons-material/RestoreFromTrash'; +import { DialogContentText } from '@mui/material'; +import { makeStyles } from '@mui/styles'; +import PropTypes from 'prop-types'; +import React, { useState } from 'react'; +import { FormattedMessage } from 'react-intl'; import { bulkDelete } from '../actions'; import ConfirmCancelDialogComponent from '../../../components/dialogs/ConfirmCancelDialogComponent'; @@ -27,18 +26,15 @@ const DeleteInstanceDialog = ({ isUnDeleteAction, }) => { const classes = useStyles(); - const dispatch = useDispatch(); const [allowConfirm, setAllowConfirm] = useState(true); const onConfirm = closeDialog => { setAllowConfirm(false); - dispatch( - bulkDelete(selection, filters, isUnDeleteAction, () => { - closeDialog(); - resetSelection(); - setForceRefresh(); - setAllowConfirm(false); - }), - ); + bulkDelete(selection, filters, isUnDeleteAction, () => { + closeDialog(); + resetSelection(); + setForceRefresh(); + setAllowConfirm(false); + }); }; const renderTrigger = ({ openDialog }) => { diff --git a/hat/assets/js/apps/Iaso/domains/instances/components/ExportInstancesDialogComponent.js b/hat/assets/js/apps/Iaso/domains/instances/components/ExportInstancesDialogComponent.js index 2935b413ed..5ff6f271d4 100644 --- a/hat/assets/js/apps/Iaso/domains/instances/components/ExportInstancesDialogComponent.js +++ b/hat/assets/js/apps/Iaso/domains/instances/components/ExportInstancesDialogComponent.js @@ -1,19 +1,15 @@ -import React from 'react'; -import { bindActionCreators } from 'redux'; import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; +import React from 'react'; import { injectIntl } from 'bluesquare-components'; -import InputComponent from '../../../components/forms/InputComponent'; import ConfirmCancelDialogComponent from '../../../components/dialogs/ConfirmCancelDialogComponent'; -import { createExportRequest as createExportRequestAction } from '../actions'; +import InputComponent from '../../../components/forms/InputComponent'; +import { createExportRequest } from '../actions'; import MESSAGES from '../messages'; const ExportInstancesDialogComponent = ({ - isInstancesFilterUpdated, getFilters, - createExportRequest, renderTrigger, selection, }) => { @@ -38,9 +34,7 @@ const ExportInstancesDialogComponent = ({ } return ( - renderTrigger(openDialog, isInstancesFilterUpdated) - } + renderTrigger={({ openDialog }) => renderTrigger(openDialog)} titleMessage={title} onConfirm={onConfirm} confirmMessage={MESSAGES.export} @@ -65,28 +59,9 @@ ExportInstancesDialogComponent.defaultProps = { }; ExportInstancesDialogComponent.propTypes = { - isInstancesFilterUpdated: PropTypes.bool.isRequired, getFilters: PropTypes.func.isRequired, - createExportRequest: PropTypes.func.isRequired, renderTrigger: PropTypes.func.isRequired, selection: PropTypes.object, }; -const MapStateToProps = state => ({ - isInstancesFilterUpdated: state.instances.isInstancesFilterUpdated, -}); - -const MapDispatchToProps = dispatch => ({ - dispatch, - ...bindActionCreators( - { - createExportRequest: createExportRequestAction, - }, - dispatch, - ), -}); - -export default connect( - MapStateToProps, - MapDispatchToProps, -)(injectIntl(ExportInstancesDialogComponent)); +export default injectIntl(ExportInstancesDialogComponent); diff --git a/hat/assets/js/apps/Iaso/domains/instances/components/InstancePopUp/InstancePopUp.tsx b/hat/assets/js/apps/Iaso/domains/instances/components/InstancePopUp/InstancePopUp.tsx index 32a017be9a..e76b5db977 100644 --- a/hat/assets/js/apps/Iaso/domains/instances/components/InstancePopUp/InstancePopUp.tsx +++ b/hat/assets/js/apps/Iaso/domains/instances/components/InstancePopUp/InstancePopUp.tsx @@ -1,6 +1,12 @@ -import React, { FunctionComponent, useCallback, useMemo, useRef } from 'react'; +import { Theme } from '@mui/material/styles'; +import L from 'leaflet'; +import React, { + createRef, + FunctionComponent, + useCallback, + useMemo, +} from 'react'; import { Popup, useMap } from 'react-leaflet'; -import { useSelector } from 'react-redux'; import { Box, Card, CardContent, CardMedia, Grid } from '@mui/material'; import { makeStyles } from '@mui/styles'; @@ -17,13 +23,15 @@ import InstanceDetailsField from '../InstanceDetailsField'; import InstanceDetailsInfos from '../InstanceDetailsInfos'; import { baseUrls } from '../../../../constants/urls'; +import { usePopupState } from '../../../../utils/map/usePopupState'; import { getOrgUnitsTree } from '../../../orgUnits/utils'; +import { useGetInstance } from '../../../registry/hooks/useGetInstances'; import MESSAGES from '../../messages'; import { Instance } from '../../types/instance'; -const useStyles = makeStyles(theme => ({ - ...commonStyles(theme), - ...mapPopupStyles(theme), +const useStyles = makeStyles((theme: Theme) => ({ + ...(commonStyles(theme) as Record), + ...(mapPopupStyles(theme) as Record), actionBox: { padding: theme.spacing(1, 0, 0, 0), }, @@ -38,24 +46,28 @@ const useStyles = makeStyles(theme => ({ type Props = { replaceLocation?: (instance: Instance) => void; displayUseLocation?: boolean; + instanceId: number; }; export const InstancePopup: FunctionComponent = ({ replaceLocation = () => null, displayUseLocation = false, + instanceId, }) => { const { formatMessage } = useSafeIntl(); const classes: Record = useStyles(); - const popup: any = useRef(); + const popup = createRef(); + const isOpen = usePopupState(popup); const map = useMap(); - const currentInstance = useSelector( - (state: any) => state.instances.current, + const { data: currentInstance, isLoading } = useGetInstance( + isOpen ? instanceId : undefined, ); - const confirmDialog = useCallback(() => { - replaceLocation(currentInstance); - map.closePopup(popup.current); - }, [currentInstance, map, replaceLocation]); + if (currentInstance) { + replaceLocation(currentInstance); + map.closePopup(popup.current); + } + }, [currentInstance, map, popup, replaceLocation]); const hasHero = (currentInstance?.files?.length ?? 0) > 0; @@ -68,7 +80,7 @@ export const InstancePopup: FunctionComponent = ({ return ( - {!currentInstance && } + {isLoading && } {currentInstance && ( {hasHero && ( diff --git a/hat/assets/js/apps/Iaso/domains/instances/components/InstancesFiltersComponent.js b/hat/assets/js/apps/Iaso/domains/instances/components/InstancesFiltersComponent.js index 5427501582..39186bfd08 100644 --- a/hat/assets/js/apps/Iaso/domains/instances/components/InstancesFiltersComponent.js +++ b/hat/assets/js/apps/Iaso/domains/instances/components/InstancesFiltersComponent.js @@ -1,6 +1,5 @@ import PropTypes from 'prop-types'; import React, { useCallback, useMemo, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; import { Box, Button, Grid, Typography } from '@mui/material'; import { makeStyles } from '@mui/styles'; @@ -21,14 +20,13 @@ import { periodTypeOptions } from '../../periods/constants'; import { Period } from '../../periods/models.ts'; import { isValidPeriod } from '../../periods/utils'; -import { setInstancesFilterUpdated } from '../actions'; import { INSTANCE_STATUSES } from '../constants'; import { getInstancesFilterValues, useFormState } from '../../../hooks/form'; import { useGetFormDescriptor } from '../../forms/fields/hooks/useGetFormDescriptor.ts'; import { useGetQueryBuilderListToReplace } from '../../forms/fields/hooks/useGetQueryBuilderListToReplace.ts'; import { useGetQueryBuildersFields } from '../../forms/fields/hooks/useGetQueryBuildersFields.ts'; -import { useGetForms, useInstancesFiltersData } from '../hooks'; +import { useGetForms } from '../hooks'; import { parseJson } from '../utils/jsonLogicParse.ts'; import { Popper } from '../../forms/fields/components/Popper.tsx'; @@ -36,15 +34,16 @@ import { OrgUnitTreeviewModal } from '../../orgUnits/components/TreeView/OrgUnit import { useGetOrgUnit } from '../../orgUnits/components/TreeView/requests.ts'; import MESSAGES from '../messages'; -import { InputWithInfos } from '../../../components/InputWithInfos.tsx'; import { AsyncSelect } from '../../../components/forms/AsyncSelect.tsx'; +import { InputWithInfos } from '../../../components/InputWithInfos.tsx'; import { UserOrgUnitRestriction } from '../../../components/UserOrgUnitRestriction.tsx'; import { LocationLimit } from '../../../utils/map/LocationLimit'; +import { useGetOrgUnitTypes } from '../../orgUnits/hooks/requests/useGetOrgUnitTypes'; import { useGetPlanningsOptions } from '../../plannings/hooks/requests/useGetPlannings.ts'; +import { useGetProjectsDropdownOptions } from '../../projects/hooks/requests.ts'; import { getUsersDropDown } from '../hooks/requests/getUsersDropDown.tsx'; import { useGetProfilesDropdown } from '../hooks/useGetProfilesDropdown.tsx'; import { ColumnSelect } from './ColumnSelect.tsx'; -import { useGetProjectsDropdownOptions } from '../../projects/hooks/requests.ts'; export const instanceStatusOptions = INSTANCE_STATUSES.map(status => ({ value: status, @@ -69,7 +68,6 @@ const filterDefault = params => ({ }); const InstancesFiltersComponent = ({ - params: { formIds }, params, onSearch, possibleFields, @@ -82,12 +80,12 @@ const InstancesFiltersComponent = ({ tableColumns, tab, }) => { - const dispatch = useDispatch(); const { formatMessage } = useSafeIntl(); const classes = useStyles(); + const [isInstancesFilterUpdated, setIsInstancesFilterUpdated] = + useState(false); const [hasLocationLimitError, setHasLocationLimitError] = useState(false); - const [fetchingOrgUnitTypes, setFetchingOrgUnitTypes] = useState(false); const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); const defaultFilters = useMemo(() => { @@ -120,11 +118,8 @@ const InstancesFiltersComponent = ({ }); setInitialOrgUnitId(params?.levels); }, [defaultFilters]); - - const orgUnitTypes = useSelector(state => state.orgUnits.orgUnitTypes); - const isInstancesFilterUpdated = useSelector( - state => state.instances.isInstancesFilterUpdated, - ); + const { data: orgUnitTypes, isFetching: isFetchingOuTypes } = + useGetOrgUnitTypes(); const { data, isFetching: fetchingForms } = useGetForms(); const formsList = useMemo(() => data?.forms ?? [], [data]); const formId = @@ -139,10 +134,9 @@ const InstancesFiltersComponent = ({ fields, queryBuilderListToReplace, ); - useInstancesFiltersData(formIds, setFetchingOrgUnitTypes); const handleSearch = useCallback(() => { if (isInstancesFilterUpdated) { - dispatch(setInstancesFilterUpdated(false)); + setIsInstancesFilterUpdated(false); const searchParams = { ...params, ...getInstancesFilterValues(formState), @@ -167,7 +161,7 @@ const InstancesFiltersComponent = ({ } }, [ isInstancesFilterUpdated, - dispatch, + setIsInstancesFilterUpdated, params, formState, onSearch, @@ -192,9 +186,9 @@ const InstancesFiltersComponent = ({ if (key === 'levels') { setInitialOrgUnitId(value); } - dispatch(setInstancesFilterUpdated(true)); + setIsInstancesFilterUpdated(true); }, - [dispatch, setFormState, setFormIds], + [setFormState, setFormIds, setIsInstancesFilterUpdated], ); const startPeriodError = useMemo(() => { @@ -262,7 +256,6 @@ const InstancesFiltersComponent = ({ startPeriodError || endPeriodError || hasLocationLimitError; - return (
@@ -393,12 +386,9 @@ const InstancesFiltersComponent = ({ onChange={handleFormChange} value={formState.orgUnitTypeId.value || null} type="select" - options={orgUnitTypes.map(t => ({ - label: t.name, - value: t.id, - }))} + options={orgUnitTypes || []} label={MESSAGES.org_unit_type_id} - loading={fetchingOrgUnitTypes} + loading={isFetchingOuTypes} /> ({ root: { ...commonStyles(theme).mapContainer, @@ -55,30 +42,10 @@ export const InstancesMap: FunctionComponent = ({ }) => { const classes = useStyles(); const [isClusterActive, setIsClusterActive] = useState(true); - const [currentTile, setCurrentTile] = useState(tiles.osm); - const notifications = useSelector((state: PartialReduxState) => - state.snackBar ? state.snackBar.notifications : [], - ); - const dispatch = useDispatch(); - const dispatchInstance = useCallback( - instance => { - dispatch(setCurrentInstance(instance)); - }, - [dispatch], - ); - // We need redux here for the PopUp. Refactoring the pop up may be complex since it is used in MarkerClusterGroup, - // which is itself widely used - const fetchAndDispatchDetail = useCallback( - instance => { - dispatchInstance(null); - fetchInstanceDetail(instance.id).then((i: Instance) => - dispatchInstance(i), - ); - }, - [dispatchInstance], - ); - useShowWarning({ instances, notifications, fetching }); + const [currentTile, setCurrentTile] = useState(tiles.osm); + // This should be fixed, notification reducer as been removed a while ago + useShowWarning({ instances, notifications: [], fetching }); const bounds = useMemo(() => { if (instances) { @@ -89,7 +56,7 @@ export const InstancesMap: FunctionComponent = ({ if (fetching) return null; return ( - + = ({ iconCreateFunction={clusterCustomMarker} > ({ + instanceId: instance.id, + })} items={instances} - onMarkerClick={fetchAndDispatchDetail} PopupComponent={InstancePopup} /> @@ -129,7 +98,9 @@ export const InstancesMap: FunctionComponent = ({ {!isClusterActive && ( ({ + instanceId: instance.id, + })} PopupComponent={InstancePopup} /> )} diff --git a/hat/assets/js/apps/Iaso/domains/instances/components/InstancesMap/useShowWarning.ts b/hat/assets/js/apps/Iaso/domains/instances/components/InstancesMap/useShowWarning.ts index 19942b8115..a6907f4144 100644 --- a/hat/assets/js/apps/Iaso/domains/instances/components/InstancesMap/useShowWarning.ts +++ b/hat/assets/js/apps/Iaso/domains/instances/components/InstancesMap/useShowWarning.ts @@ -1,6 +1,5 @@ import { closeSnackbar } from 'notistack'; import { useEffect } from 'react'; -import { useDispatch } from 'react-redux'; import { openSnackBar } from '../../../../components/snackBars/EventDispatcher'; import { warningSnackBar } from '../../../../constants/snackBars'; import { getLatLngBounds } from '../../../../utils/map/mapUtils'; @@ -17,7 +16,6 @@ export const useShowWarning = ({ notifications, fetching, }: SetWarningParams): void => { - const dispatch = useDispatch(); const bounds = getLatLngBounds(instances); const isWarningDisplayed = notifications.find(n => n.id === snackbarKey); const shouldShowWarning = @@ -33,5 +31,5 @@ export const useShowWarning = ({ closeSnackbar(snackbarKey); } }; - }, [bounds, dispatch, isWarningDisplayed, shouldShowWarning]); + }, [bounds, isWarningDisplayed, shouldShowWarning]); }; diff --git a/hat/assets/js/apps/Iaso/domains/instances/components/SpeedDialInstance.tsx b/hat/assets/js/apps/Iaso/domains/instances/components/SpeedDialInstance.tsx index dac587b3b1..79bd36e33e 100644 --- a/hat/assets/js/apps/Iaso/domains/instances/components/SpeedDialInstance.tsx +++ b/hat/assets/js/apps/Iaso/domains/instances/components/SpeedDialInstance.tsx @@ -1,6 +1,7 @@ import React, { FunctionComponent } from 'react'; import { LoadingSpinner } from 'bluesquare-components'; +import { UseMutateAsyncFunction } from 'react-query'; import { hasFeatureFlag, SHOW_LINK_INSTANCE_REFERENCE, @@ -24,6 +25,7 @@ import { useLockAction, } from '../hooks/speedDialActions'; import { useGetFormDefForInstance } from '../hooks/speeddials'; +import { ReassignInstancePayload } from '../hooks/useReassignInstance'; import { Instance } from '../types/instance'; import SpeedDialInstanceActions from './SpeedDialInstanceActions'; @@ -33,6 +35,12 @@ type Props = { instanceId: string; referenceFormId?: string; }; + reassignInstance: UseMutateAsyncFunction< + unknown, + unknown, + ReassignInstancePayload, + unknown + >; }; const SpeedDialInstance: FunctionComponent = props => { @@ -44,6 +52,7 @@ const SpeedDialInstance: FunctionComponent = props => { is_instance_of_reference_form: isInstanceOfReferenceForm, is_reference_instance: isReferenceInstance, }, + reassignInstance, } = props; const { data: formDef } = useGetFormDefForInstance(formId); const currentUser = useCurrentUser(); @@ -79,7 +88,11 @@ const SpeedDialInstance: FunctionComponent = props => { const isLinkActionEnabled = hasfeatureFlag && isInstanceOfReferenceForm && hasOrgUnitPermission; - const baseActions = useBaseActions(currentInstance, formDef); + const baseActions = useBaseActions( + currentInstance, + reassignInstance, + formDef, + ); const editLocationWithInstanceGps = useEditLocationWithGpsAction(currentInstance); diff --git a/hat/assets/js/apps/Iaso/domains/instances/details.tsx b/hat/assets/js/apps/Iaso/domains/instances/details.tsx index ff8b65ff52..d158824e10 100644 --- a/hat/assets/js/apps/Iaso/domains/instances/details.tsx +++ b/hat/assets/js/apps/Iaso/domains/instances/details.tsx @@ -18,7 +18,10 @@ import WidgetPaper from '../../components/papers/WidgetPaperComponent'; import { baseUrls } from '../../constants/urls'; import { getRequest } from '../../libs/Api'; import { useSnackQuery } from '../../libs/apiHooks'; -import { useParamsObject } from '../../routing/hooks/useParamsObject'; +import { + ParamsWithAccountId, + useParamsObject, +} from '../../routing/hooks/useParamsObject'; import { ClassNames } from '../../types/utils'; import { BeneficiaryBaseInfo } from '../entities/components/BeneficiaryBaseInfo'; import { useGetBeneficiaryFields } from '../entities/hooks/useGetBeneficiaryFields'; @@ -30,6 +33,10 @@ import InstanceDetailsLocksHistory from './components/InstanceDetailsLocksHistor import InstanceFileContent from './components/InstanceFileContent'; import InstancesFilesList from './components/InstancesFilesListComponent'; import SpeedDialInstance from './components/SpeedDialInstance'; +import { + ReassignInstancePayload, + useReassignInstance, +} from './hooks/useReassignInstance'; import MESSAGES from './messages'; import { getInstancesFilesList } from './utils'; @@ -72,20 +79,26 @@ export const useGetInstanceLogs = ( const InstanceDetails: FunctionComponent = () => { const [showDial, setShowDial] = useState(true); + + const { mutateAsync: reassignInstance, isLoading: isReassigning } = + useReassignInstance(); + const { formatMessage } = useSafeIntl(); const classes: ClassNames = useStyles(); const goBack = useGoBack(baseUrls.instances); - - const params = useParamsObject(baseUrls.instanceDetail) as { + const params = useParamsObject( + baseUrls.instanceDetail, + ) as ParamsWithAccountId & { instanceId: string; }; const { instanceId } = params; const { data: currentInstance, isLoading: isLoadingInstance } = useGetInstance(instanceId); const { isLoading: isLoadingBeneficiaryFields, fields: beneficiaryFields } = - useGetBeneficiaryFields(currentInstance && currentInstance.entity); + useGetBeneficiaryFields(currentInstance?.entity); const isLoading = + isReassigning || isLoadingInstance || (currentInstance?.entity && isLoadingBeneficiaryFields); @@ -113,6 +126,7 @@ const InstanceDetails: FunctionComponent = () => { )} diff --git a/hat/assets/js/apps/Iaso/domains/instances/hooks.js b/hat/assets/js/apps/Iaso/domains/instances/hooks.js index 8972cb0764..9b866346e3 100644 --- a/hat/assets/js/apps/Iaso/domains/instances/hooks.js +++ b/hat/assets/js/apps/Iaso/domains/instances/hooks.js @@ -1,23 +1,7 @@ import { getRequest, patchRequest, postRequest } from 'Iaso/libs/Api.ts'; import { useSnackMutation, useSnackQuery } from 'Iaso/libs/apiHooks.ts'; -import { fetchOrgUnitsTypes } from '../../utils/requests'; -import { setOrgUnitTypes } from '../orgUnits/actions'; import MESSAGES from './messages'; -import { useFetchOnMount } from '../../hooks/fetchOnMount'; - -export const useInstancesFiltersData = (formId, setFetchingOrgUnitTypes) => { - const promisesArray = [ - { - fetch: fetchOrgUnitsTypes, - setFetching: setFetchingOrgUnitTypes, - setData: setOrgUnitTypes, - }, - ]; - - useFetchOnMount(promisesArray); -}; - export const useGetForms = () => { const params = { all: true, diff --git a/hat/assets/js/apps/Iaso/domains/instances/hooks/speedDialActions.tsx b/hat/assets/js/apps/Iaso/domains/instances/hooks/speedDialActions.tsx index 18e1476e90..5f123798ab 100644 --- a/hat/assets/js/apps/Iaso/domains/instances/hooks/speedDialActions.tsx +++ b/hat/assets/js/apps/Iaso/domains/instances/hooks/speedDialActions.tsx @@ -6,12 +6,11 @@ import LockIcon from '@mui/icons-material/Lock'; import RestoreFromTrashIcon from '@mui/icons-material/RestoreFromTrash'; import { DialogContentText } from '@mui/material'; import { ExportButton, useSafeIntl } from 'bluesquare-components'; -import React, { ReactElement, useCallback, useMemo } from 'react'; -import { useDispatch } from 'react-redux'; +import React, { ReactElement, useMemo } from 'react'; +import { UseMutateAsyncFunction } from 'react-query'; import ConfirmCancelDialogComponent from '../../../components/dialogs/ConfirmCancelDialogComponent'; import { Nullable } from '../../../types/utils'; import { useSaveOrgUnit } from '../../orgUnits/hooks'; -import { reAssignInstance } from '../actions'; import { ReAssignDialog } from '../components/CreateReAssignDialogComponent'; import EnketoIcon from '../components/EnketoIcon'; import ExportInstancesDialogComponent from '../components/ExportInstancesDialogComponent'; @@ -19,6 +18,7 @@ import { usePostLockInstance } from '../hooks'; import MESSAGES from '../messages'; import { Instance } from '../types/instance'; import { FormDef, useLinkOrgUnitToReferenceSubmission } from './speeddials'; +import { ReassignInstancePayload } from './useReassignInstance'; export type SpeedDialAction = { id: string; @@ -28,15 +28,14 @@ export type SpeedDialAction = { export const useBaseActions = ( currentInstance: Instance, + reassignInstance: UseMutateAsyncFunction< + unknown, + unknown, + ReassignInstancePayload, + unknown + >, formDef?: FormDef, ): SpeedDialAction[] => { - const dispatch = useDispatch(); - const onReAssignInstance = useCallback( - (...props) => { - dispatch(reAssignInstance(...props)); - }, - [dispatch], - ); return useMemo(() => { return [ { @@ -44,13 +43,9 @@ export const useBaseActions = ( icon: ( ( + renderTrigger={openDialog => ( )} @@ -71,13 +66,13 @@ export const useBaseActions = ( currentInstance={currentInstance} orgUnitTypes={formDef?.orgUnitTypeIds} formType={formDef} - onCreateOrReAssign={onReAssignInstance} + onCreateOrReAssign={reassignInstance} /> ), disabled: currentInstance && currentInstance.deleted, }, ]; - }, [currentInstance, formDef, onReAssignInstance]); + }, [currentInstance, formDef, reassignInstance]); }; export const useEditLocationWithGpsAction = ( diff --git a/hat/assets/js/apps/Iaso/domains/instances/hooks/useReassignInstance.tsx b/hat/assets/js/apps/Iaso/domains/instances/hooks/useReassignInstance.tsx new file mode 100644 index 0000000000..5a88c1eff6 --- /dev/null +++ b/hat/assets/js/apps/Iaso/domains/instances/hooks/useReassignInstance.tsx @@ -0,0 +1,30 @@ +import MESSAGES from '../../../components/snackBars/messages'; +import { patchRequest } from '../../../libs/Api'; +import { useSnackMutation } from '../../../libs/apiHooks'; + +const reassignInstance = (currentInstance, body) => { + const effectivePayload = { ...body }; + if (!body.period) delete effectivePayload.period; + return patchRequest( + `/api/instances/${currentInstance.id}/`, + effectivePayload, + ); +}; + +export type ReassignInstancePayload = { + currentInstance: { + id: number; + period?: string; + org_unit?: any; + }; + period?: string; + org_unit?: number; +}; + +export const useReassignInstance = () => + useSnackMutation({ + mutationFn: ({ currentInstance, period, org_unit }: T) => + reassignInstance(currentInstance, { period, org_unit }), + snackErrorMsg: MESSAGES.assignInstanceError, + invalidateQueryKey: ['instance'], + }); diff --git a/hat/assets/js/apps/Iaso/domains/instances/index.js b/hat/assets/js/apps/Iaso/domains/instances/index.js index 49e8bb613d..041d04e9e4 100644 --- a/hat/assets/js/apps/Iaso/domains/instances/index.js +++ b/hat/assets/js/apps/Iaso/domains/instances/index.js @@ -10,7 +10,6 @@ import { } from 'bluesquare-components'; import React, { useCallback, useMemo, useState } from 'react'; import { useQueryClient } from 'react-query'; -import { useDispatch } from 'react-redux'; import { DisplayIfUserHasPerm } from '../../components/DisplayIfUserHasPerm.tsx'; import DownloadButtonsComponent from '../../components/DownloadButtonsComponent.tsx'; import snackMessages from '../../components/snackBars/messages'; @@ -54,10 +53,8 @@ const Instances = () => { const params = useParamsObject(baseUrl); const classes = useStyles(); const { formatMessage } = useSafeIntl(); - const dispatch = useDispatch(); const queryClient = useQueryClient(); const redirectToReplace = useRedirectToReplace(); - const [selection, setSelection] = useState(selectionInitialState); const [tableColumns, setTableColumns] = useState([]); const [tab, setTab] = useState(params.tab ?? 'list'); @@ -199,12 +196,7 @@ const Instances = () => { currentForm, payload, ) => - dispatch( - createInstance( - currentForm, - payload, - ), - ) + createInstance(currentForm, payload) } /> diff --git a/hat/assets/js/apps/Iaso/domains/instances/reducer.js b/hat/assets/js/apps/Iaso/domains/instances/reducer.js deleted file mode 100644 index 642bd3e243..0000000000 --- a/hat/assets/js/apps/Iaso/domains/instances/reducer.js +++ /dev/null @@ -1,36 +0,0 @@ -import { - SET_INSTANCES_FETCHING, - SET_CURRENT_INSTANCE, - SET_INSTANCES_FILTER_UDPATED, -} from './actions'; - -export const instancesInitialState = { - fetching: true, - current: null, - isInstancesFilterUpdated: false, -}; - -export const instancesReducer = ( - state = instancesInitialState, - action = {}, -) => { - switch (action.type) { - case SET_INSTANCES_FETCHING: { - const fetching = action.payload; - return { ...state, fetching }; - } - - case SET_CURRENT_INSTANCE: { - const current = action.payload; - return { ...state, current }; - } - - case SET_INSTANCES_FILTER_UDPATED: { - const isInstancesFilterUpdated = action.payload; - return { ...state, isInstancesFilterUpdated }; - } - - default: - return state; - } -}; diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/actions.js b/hat/assets/js/apps/Iaso/domains/orgUnits/actions.js deleted file mode 100644 index 6ee64091ed..0000000000 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/actions.js +++ /dev/null @@ -1,18 +0,0 @@ -export const RESET_ORG_UNITS = 'RESET_ORG_UNITS'; -export const SET_SUB_ORG_UNIT = 'SET_SUB_ORG_UNIT'; -export const SET_ORG_UNIT_TYPES = 'SET_ORG_UNIT_TYPES'; -export const SET_SOURCES = 'SET_SOURCES'; - -export const resetOrgUnits = () => ({ - type: RESET_ORG_UNITS, -}); - -export const setCurrentSubOrgUnit = orgUnit => ({ - type: SET_SUB_ORG_UNIT, - payload: orgUnit, -}); - -export const setOrgUnitTypes = orgUnitTypes => ({ - type: SET_ORG_UNIT_TYPES, - payload: orgUnitTypes, -}); diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/components/OrgUnitPopupComponent.js b/hat/assets/js/apps/Iaso/domains/orgUnits/components/OrgUnitPopupComponent.js index cd619a04f2..11adce49f4 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/components/OrgUnitPopupComponent.js +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/components/OrgUnitPopupComponent.js @@ -1,29 +1,30 @@ -import React, { createRef } from 'react'; -import { useSelector } from 'react-redux'; -import PropTypes from 'prop-types'; -import { Popup } from 'react-leaflet'; -import classNames from 'classnames'; import { + Box, Card, CardContent, + Divider, Grid, - Box, Typography, - Divider, } from '@mui/material'; import { makeStyles } from '@mui/styles'; -import moment from 'moment'; import { - textPlaceholder, - useSafeIntl, + LinkButton, LoadingSpinner, commonStyles, mapPopupStyles, - LinkButton, + textPlaceholder, + useSafeIntl, } from 'bluesquare-components'; -import PopupItemComponent from '../../../components/maps/popups/PopupItemComponent'; +import classNames from 'classnames'; +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { createRef } from 'react'; +import { Popup } from 'react-leaflet'; import ConfirmDialog from '../../../components/dialogs/ConfirmDialogComponent'; +import PopupItemComponent from '../../../components/maps/popups/PopupItemComponent'; import { baseUrls } from '../../../constants/urls.ts'; +import { usePopupState } from '../../../utils/map/usePopupState'; +import { useGetOrgUnitDetail } from '../hooks/requests/useGetOrgUnitDetail'; import MESSAGES from '../messages.ts'; const useStyles = makeStyles(theme => ({ @@ -61,26 +62,26 @@ const OrgUnitPopupComponent = ({ displayUseLocation, replaceLocation, titleMessage, - currentOrgUnit, + orgUnitId, }) => { const { formatMessage } = useSafeIntl(); const classes = useStyles(); const popup = createRef(); - const reduxCurrentOrgUnit = useSelector( - state => state.orgUnits.currentSubOrgUnit, + const isOpen = usePopupState(popup); + const { data: currentOrgUnit } = useGetOrgUnitDetail( + isOpen ? orgUnitId : undefined, ); - const activeOrgUnit = currentOrgUnit || reduxCurrentOrgUnit; const confirmDialog = () => { - replaceLocation(activeOrgUnit); + replaceLocation(currentOrgUnit); }; let groups = null; - if (activeOrgUnit && activeOrgUnit.groups.length > 0) { - groups = activeOrgUnit.groups.map(g => g.name).join(', '); + if (currentOrgUnit && currentOrgUnit.groups.length > 0) { + groups = currentOrgUnit.groups.map(g => g.name).join(', '); } return ( - {!activeOrgUnit && } - {activeOrgUnit && ( + {!currentOrgUnit && } + {currentOrgUnit && ( - {!activeOrgUnit.has_geo_json && ( + {!currentOrgUnit.has_geo_json && ( <> )} @@ -167,7 +168,7 @@ const OrgUnitPopupComponent = ({ )} {}, titleMessage: null, }; OrgUnitPopupComponent.propTypes = { - currentOrgUnit: PropTypes.object, displayUseLocation: PropTypes.bool, replaceLocation: PropTypes.func, titleMessage: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), + orgUnitId: PropTypes.number.isRequired, }; export default OrgUnitPopupComponent; diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/components/OrgUnitsMap.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/components/OrgUnitsMap.tsx index 7ffab44c78..d2dcda0f8a 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/components/OrgUnitsMap.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/components/OrgUnitsMap.tsx @@ -15,17 +15,11 @@ import { } from 'react-leaflet'; import MarkerClusterGroup from 'react-leaflet-markercluster'; -// COMPONENTS - import MarkersListComponent from '../../../components/maps/markers/MarkersListComponent'; import { Tile } from '../../../components/maps/tools/TilesSwitchControl'; -import { innerDrawerStyles } from '../../../components/nav/InnerDrawer/styles'; import ErrorPaperComponent from '../../../components/papers/ErrorPaperComponent'; -import { OrgUnitsMapComments } from './orgUnitMap/OrgUnitsMapComments'; import OrgUnitPopupComponent from './OrgUnitPopupComponent'; -// COMPONENTS -// UTILS import { Bounds, circleColorMarkerOptions, @@ -33,16 +27,10 @@ import { getLatLngBounds, getShapesBounds, } from '../../../utils/map/mapUtils'; -// UTILS -// TYPES import { DropdownOptions } from '../../../types/utils'; import { OrgUnit } from '../types/orgUnit'; -// TYPES -// HOOKS -import { useGetOrgUnitDetail } from '../hooks/requests/useGetOrgUnitDetail'; -// HOOKS import { CustomTileLayer } from '../../../components/maps/tools/CustomTileLayer'; import { CustomZoomControl } from '../../../components/maps/tools/CustomZoomControl'; import { MapToggleCluster } from '../../../components/maps/tools/MapToggleCluster'; @@ -50,6 +38,7 @@ import { InnerDrawer } from '../../../components/nav/InnerDrawer/Index'; import tiles from '../../../constants/mapTiles'; import { useGetOrgUnitTypes } from '../hooks/requests/useGetOrgUnitTypes'; import MESSAGES from '../messages'; +import { OrgUnitsMapComments } from './orgUnitMap/OrgUnitsMapComments'; type OrgUnitWithSearchIndex = Omit & { search_index: number; @@ -64,21 +53,6 @@ type Props = { orgUnits: Locations; }; -const useStyles = makeStyles(theme => ({ - ...commonStyles(theme), - ...innerDrawerStyles(theme), - innerDrawerToolbar: { - ...innerDrawerStyles(theme).innerDrawerToolbar, - '& section': { - width: '100%', - }, - }, - commentContainer: { - height: '60vh', - overflowY: 'auto', - }, -})); - const getFullOrgUnits = orgUnits => { let fullOrUnits = []; Object.values(orgUnits).forEach(searchOrgUnits => { @@ -88,6 +62,14 @@ const getFullOrgUnits = orgUnits => { return fullOrUnits; }; +const useStyles = makeStyles(theme => ({ + ...commonStyles(theme), + commentContainer: { + height: '60vh', + overflowY: 'auto', + }, +})); + const getOrgUnitsBounds = (orgUnits: Locations): Bounds | undefined => { const orgUnitsLocations = getFullOrgUnits(orgUnits.locations); const locationsBounds = @@ -111,15 +93,14 @@ export const OrgUnitsMap: FunctionComponent = ({ getSearchColor, orgUnits, }) => { - const classes: Record = useStyles(); + const classes = useStyles(); const { data: orgUnitTypes } = useGetOrgUnitTypes(); const [currentTile, setCurrentTile] = useState(tiles.osm); const [isClusterActive, setIsClusterActive] = useState(true); - const [currentOrgUnitId, setCurrentOrgUnitId] = useState< - number | undefined - >(); - const { data: currentOrgUnit } = useGetOrgUnitDetail(currentOrgUnitId); + const [selectedOrgUnit, setSelectedOrgUnit] = useState< + OrgUnit | undefined + >(); const { formatMessage }: { formatMessage: IntlFormatMessage } = useSafeIntl(); @@ -151,9 +132,8 @@ export const OrgUnitsMap: FunctionComponent = ({ ...circleColorMarkerOptions(color), })} items={orgUnitsBySearch} - onMarkerClick={o => setCurrentOrgUnitId(o.id)} - popupProps={() => ({ - currentOrgUnit, + popupProps={instance => ({ + orgUnitId: instance.id, })} PopupComponent={OrgUnitPopupComponent} tooltipProps={e => ({ @@ -161,6 +141,7 @@ export const OrgUnitsMap: FunctionComponent = ({ })} TooltipComponent={Tooltip} isCircle + onMarkerClick={o => setSelectedOrgUnit(o)} /> @@ -181,9 +162,8 @@ export const OrgUnitsMap: FunctionComponent = ({ ), })} items={orgUnitsBySearch} - onMarkerClick={o => setCurrentOrgUnitId(o.id)} - popupProps={() => ({ - currentOrgUnit, + popupProps={instance => ({ + orgUnitId: instance.id, })} PopupComponent={OrgUnitPopupComponent} tooltipProps={e => ({ @@ -191,10 +171,11 @@ export const OrgUnitsMap: FunctionComponent = ({ })} TooltipComponent={Tooltip} isCircle + onMarkerClick={o => setSelectedOrgUnit(o)} /> )); - }, [currentOrgUnit, getSearchColor, isClusterActive, orgUnits.locations]); + }, [getSearchColor, isClusterActive, orgUnits.locations]); if (!bounds && orgUnitsTotal.length > 0) { return ( @@ -222,7 +203,7 @@ export const OrgUnitsMap: FunctionComponent = ({ } > @@ -266,13 +247,10 @@ export const OrgUnitsMap: FunctionComponent = ({ ), }} data={o.geo_json} - eventHandlers={{ - click: () => - setCurrentOrgUnitId(o.id), - }} + onClick={() => setSelectedOrgUnit(o)} > {o.name} @@ -305,13 +283,12 @@ export const OrgUnitsMap: FunctionComponent = ({ ), }} data={o.geo_json} - eventHandlers={{ - click: () => - setCurrentOrgUnitId(o.id), - }} + onClick={() => + setSelectedOrgUnit(o) + } > {o.name} diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/FormsMarkers.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/FormsMarkers.tsx index 9cd9245cd4..70946b7afe 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/FormsMarkers.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/FormsMarkers.tsx @@ -3,20 +3,17 @@ import { Pane } from 'react-leaflet'; import MarkerClusterGroup from 'react-leaflet-markercluster'; import { colorClusterCustomMarker } from '../../../../../utils/map/mapUtils'; import { InstancePopup } from '../../../../instances/components/InstancePopUp/InstancePopUp'; -import { Instance } from '../../../../instances/types/instance'; import { OrgUnit } from '../../../types/orgUnit'; import { clusterSize, orgunitsPane } from './constants'; import { MarkerList } from './MarkersList'; type Props = { forms: any[]; - fetchInstanceDetail: (instance: Instance) => void; updateOrgUnitLocation: (orgUnit: OrgUnit) => void; }; export const FormsMarkers: FunctionComponent = ({ forms, - fetchInstanceDetail, updateOrgUnitLocation, }) => { return ( @@ -35,12 +32,12 @@ export const FormsMarkers: FunctionComponent = ({ > fetchInstanceDetail(a)} color={f.color} keyId={f.id} - // The underlying Marker components are all js components, Ts compiler infers a lot and sees errors - // @ts-ignore PopupComponent={InstancePopup} + popupProps={o => ({ + orgUnitId: o.id, + })} updateOrgUnitLocation={updateOrgUnitLocation} /> diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/MarkersList.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/MarkersList.tsx index 3299ce6048..be2000c33f 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/MarkersList.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/MarkersList.tsx @@ -9,28 +9,28 @@ type Props = { locationsList: any[]; color?: string; keyId: string | number; - fetchDetail: (orgUnit: OrgUnit) => void; updateOrgUnitLocation: (orgUnit: OrgUnit) => void; + popupProps?: (orgUnit: OrgUnit) => Record; }; export const MarkerList: FunctionComponent = ({ locationsList, - fetchDetail, color = '#000000', keyId, updateOrgUnitLocation, PopupComponent = OrgUnitPopupComponent, + popupProps, }) => { return ( ({ + popupProps={orgUnit => ({ displayUseLocation: true, replaceLocation: selectedOrgUnit => updateOrgUnitLocation(selectedOrgUnit), + ...popupProps?.(orgUnit), })} isCircle markerProps={() => ({ diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/OrgUnitMap.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/OrgUnitMap.tsx index 3f40087627..c60c4dd6cf 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/OrgUnitMap.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/OrgUnitMap.tsx @@ -48,7 +48,6 @@ import { SourcesSelectedShapes } from './SourcesSelectedShapes'; import { buttonsInitialState } from './constants'; import { MappedOrgUnit } from './types'; import { useGetBounds } from './useGetBounds'; -import { useRedux } from './useRedux'; import { getAncestorWithGeojson, initialState } from './utils'; export const zoom = 5; @@ -106,7 +105,6 @@ export const OrgUnitMap: FunctionComponent = ({ const [state, setStateField, , setState] = useFormState( initialState(currentUser), ); - const { fetchInstanceDetail, fetchSubOrgUnitDetail } = useRedux(); const setAncestor = useCallback(() => { const ancestor = getAncestorWithGeojson(currentOrgUnit); if (ancestor) { @@ -540,8 +538,8 @@ export const OrgUnitMap: FunctionComponent = ({ titleMessage={formatMessage( MESSAGES.ouParent, )} - currentOrgUnit={ - state.ancestorWithGeoJson.value + orgUnitId={ + state.ancestorWithGeoJson.value.id } /> @@ -553,7 +551,6 @@ export const OrgUnitMap: FunctionComponent = ({ = ({ mappedOrgUnitTypesSelected } mappedSourcesSelected={mappedSourcesSelected} - fetchSubOrgUnitDetail={fetchSubOrgUnitDetail} updateOrgUnitLocation={updateOrgUnitLocation} /> @@ -570,18 +566,15 @@ export const OrgUnitMap: FunctionComponent = ({ <> diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/OrgUnitTypesSelectedShapes.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/OrgUnitTypesSelectedShapes.tsx index 92f96d12e0..cc5163099a 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/OrgUnitTypesSelectedShapes.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/OrgUnitTypesSelectedShapes.tsx @@ -13,7 +13,6 @@ type Props = { orgUnitTypes: (OrgunitType & { color: string })[]; mappedOrgUnitTypesSelected: MappedOrgUnit[]; mappedSourcesSelected: MappedOrgUnit[]; - fetchSubOrgUnitDetail: (orgUnit: OrgUnit) => void; updateOrgUnitLocation: (orgUnit: OrgUnit) => void; }; @@ -21,7 +20,6 @@ export const OrgUnitTypesSelectedShapes: FunctionComponent = ({ orgUnitTypes, mappedOrgUnitTypesSelected, mappedSourcesSelected, - fetchSubOrgUnitDetail, updateOrgUnitLocation, }) => { const { formatMessage } = useSafeIntl(); @@ -49,10 +47,6 @@ export const OrgUnitTypesSelectedShapes: FunctionComponent = ({ - fetchSubOrgUnitDetail(o), - }} style={() => ({ color: ot.color, })} @@ -62,6 +56,7 @@ export const OrgUnitTypesSelectedShapes: FunctionComponent = ({ MESSAGES.ouChild, )} displayUseLocation + orgUnitId={o.id} replaceLocation={selectedOrgUnit => updateOrgUnitLocation( selectedOrgUnit, @@ -78,9 +73,6 @@ export const OrgUnitTypesSelectedShapes: FunctionComponent = ({ shape={o} key={o.id} replaceLocation={updateOrgUnitLocation} - onClick={() => { - fetchSubOrgUnitDetail(o); - }} /> )), )} diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/SelectedMarkers.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/SelectedMarkers.tsx index 1e9888285b..872da8a534 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/SelectedMarkers.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/SelectedMarkers.tsx @@ -10,13 +10,11 @@ import { MappedOrgUnit } from './types'; type Props = { data: MappedOrgUnit[]; - fetchSubOrgUnitDetail: (orgUnit: OrgUnit) => void; updateOrgUnitLocation: (orgUnit: OrgUnit) => void; }; export const SelectedMarkers: FunctionComponent = ({ data, - fetchSubOrgUnitDetail, updateOrgUnitLocation, }) => { return ( @@ -39,7 +37,9 @@ export const SelectedMarkers: FunctionComponent = ({ > fetchSubOrgUnitDetail(a)} + popupProps={o => ({ + orgUnitId: o.id, + })} color={mappedOrgUnit.color} keyId={mappedOrgUnit.id} updateOrgUnitLocation={updateOrgUnitLocation} diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/SourceShape.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/SourceShape.tsx index e5500d995e..25eb58661c 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/SourceShape.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/SourceShape.tsx @@ -5,7 +5,6 @@ import MESSAGES from '../../../messages'; import OrgUnitPopupComponent from '../../OrgUnitPopupComponent'; type Props = { - onClick: () => void; replaceLocation: (orgUnit: any) => void; source: any; shape: any; @@ -14,7 +13,6 @@ type Props = { export const SourceShape: FunctionComponent = ({ source, shape, - onClick, replaceLocation, }) => { const { formatMessage } = useSafeIntl(); @@ -24,14 +22,12 @@ export const SourceShape: FunctionComponent = ({ color: source.color, }} data={shape.geo_json} - eventHandlers={{ - click: onClick, - }} > ); diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/SourcesSelectedShapes.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/SourcesSelectedShapes.tsx index dc8696f07b..4fdbf27c78 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/SourcesSelectedShapes.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/SourcesSelectedShapes.tsx @@ -7,13 +7,11 @@ import { MappedOrgUnit } from './types'; type Props = { mappedSourcesSelected: MappedOrgUnit[]; updateOrgUnitLocation: (orgUnit: OrgUnit) => void; - fetchSubOrgUnitDetail: (orgUnit: OrgUnit) => void; }; export const SourcesSelectedShapes: FunctionComponent = ({ mappedSourcesSelected, updateOrgUnitLocation, - fetchSubOrgUnitDetail, }) => { return ( <> @@ -30,9 +28,6 @@ export const SourcesSelectedShapes: FunctionComponent = ({ shape={o} key={o.id} replaceLocation={updateOrgUnitLocation} - onClick={() => { - fetchSubOrgUnitDetail(o); - }} /> ))} diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/useRedux.ts b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/useRedux.ts deleted file mode 100644 index 25366f5de5..0000000000 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/useRedux.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { useCallback } from 'react'; -import { useDispatch } from 'react-redux'; -import { - fetchInstanceDetail as fetchInstanceDetailRequest, - fetchOrgUnitDetail, -} from '../../../../../utils/requests'; -import { setCurrentInstance as setCurrentInstanceAction } from '../../../../instances/actions'; -import { setCurrentSubOrgUnit as setCurrentSubOrgUnitAction } from '../../../actions'; - -export const useRedux = () => { - const dispatch = useDispatch(); - const setCurrentSubOrgUnit = useCallback( - o => dispatch(setCurrentSubOrgUnitAction(o)), - [dispatch], - ); - - const setCurrentInstance = useCallback( - i => dispatch(setCurrentInstanceAction(i)), - [dispatch], - ); - - const fetchSubOrgUnitDetail = useCallback( - orgUnit => { - setCurrentSubOrgUnit(null); - fetchOrgUnitDetail(orgUnit.id).then(subOrgUnit => - setCurrentSubOrgUnit(subOrgUnit), - ); - }, - [setCurrentSubOrgUnit], - ); - - const fetchInstanceDetail = useCallback( - instance => { - setCurrentInstance(null); - fetchInstanceDetailRequest(instance.id).then(i => - setCurrentInstance(i), - ); - }, - [setCurrentInstance], - ); - - return { - fetchSubOrgUnitDetail, - fetchInstanceDetail, - }; -}; diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitsMapComments.js b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitsMapComments.js index 23ec435aea..61c71750e5 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitsMapComments.js +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitsMapComments.js @@ -10,7 +10,6 @@ import React, { useCallback, useState } from 'react'; import { useSnackMutation } from 'Iaso/libs/apiHooks.ts'; import PropTypes from 'prop-types'; -import { useSelector } from 'react-redux'; import { sendComment, useGetComments } from '../../../../utils/requests'; import MESSAGES from '../../messages.ts'; @@ -54,18 +53,13 @@ const OrgUnitsMapComments = ({ className, maxPages, inlineTextAreaButton, - getOrgUnitFromStore, }) => { const { formatMessage } = useSafeIntl(); const classes = useStyles(); - const globalStateOrgUnit = useSelector( - state => state.orgUnits.currentSubOrgUnit, - ); - const orgUnitToUse = getOrgUnitFromStore ? globalStateOrgUnit : orgUnit; const [offset, setOffset] = useState(null); const [pageSize] = useState(maxPages); const commentsParams = { - orgUnitId: orgUnitToUse?.id, + orgUnitId: orgUnit?.id, offset, limit: pageSize, }; @@ -83,11 +77,11 @@ const OrgUnitsMapComments = ({ parent: id, comment: text, content_type: 'iaso-orgunit', - object_pk: orgUnitToUse?.id, + object_pk: orgUnit?.id, }; await postComment(requestBody); }, - [orgUnitToUse, postComment], + [orgUnit, postComment], ); const formatComment = comment => { const mainComment = adaptComment(comment); @@ -114,24 +108,23 @@ const OrgUnitsMapComments = ({ async text => { const comment = { comment: text, - object_pk: orgUnitToUse?.id, + object_pk: orgUnit?.id, parent: null, // content_type: 57, content_type: 'iaso-orgunit', }; await postComment(comment); }, - [orgUnitToUse, postComment], + [orgUnit, postComment], ); return ( <> - {orgUnitToUse?.name ?? - formatMessage(MESSAGES.selectOrgUnit)} + {orgUnit?.name ?? formatMessage(MESSAGES.selectOrgUnit)} - {orgUnitToUse && ( + {orgUnit && ( { const classes = useStyles(); const params = useParamsObject(baseUrl); const goBack = useGoBack(baseUrls.orgUnits); - const dispatch = useDispatch(); const { mutateAsync: saveOu, isLoading: savingOu } = useSaveOrgUnit(); const queryClient = useQueryClient(); const { formatMessage } = useSafeIntl(); @@ -247,12 +244,11 @@ const OrgUnitDetail = () => { const group_ids = mappedRevision.groups.map(g => g.id); mappedRevision.groups = group_ids; saveOu(mappedRevision).then(res => { - dispatch(resetOrgUnits()); refreshOrgUnitQueryCache(res); onSuccess(); }); }, - [currentOrgUnit, dispatch, refreshOrgUnitQueryCache, saveOu], + [currentOrgUnit, refreshOrgUnitQueryCache, saveOu], ); const handleSaveOrgUnit = useCallback( @@ -270,7 +266,6 @@ const OrgUnitDetail = () => { .then(ou => { setCurrentOrgUnit(ou); setOrgUnitLocationModified(false); - dispatch(resetOrgUnits()); if (isNewOrgunit) { redirectToReplace(baseUrl, { ...params, @@ -284,7 +279,6 @@ const OrgUnitDetail = () => { }, [ currentOrgUnit, - dispatch, isNewOrgunit, params, redirectToReplace, @@ -327,7 +321,7 @@ const OrgUnitDetail = () => { } } } - }, [originalOrgUnit, dispatch, isNewOrgunit, params, redirectToReplace]); + }, [originalOrgUnit, isNewOrgunit, params, redirectToReplace]); // Set selected sources for current org unit useEffect(() => { @@ -359,14 +353,7 @@ const OrgUnitDetail = () => { fetch(); } } - }, [ - originalOrgUnit, - dispatch, - links, - sources, - isNewOrgunit, - sourcesSelected, - ]); + }, [originalOrgUnit, links, sources, isNewOrgunit, sourcesSelected]); return (
diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/reducer.js b/hat/assets/js/apps/Iaso/domains/orgUnits/reducer.js deleted file mode 100644 index 6b5ae3c368..0000000000 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/reducer.js +++ /dev/null @@ -1,44 +0,0 @@ -import { - RESET_ORG_UNITS, - SET_ORG_UNIT_TYPES, - SET_SOURCES, - SET_SUB_ORG_UNIT, -} from './actions'; - -export const orgUnitsInitialState = { - currentSubOrgUnit: null, - orgUnitTypes: [], - sources: null, - orgUnitLevel: [], - filtersUpdated: false, - groups: [], -}; - -export const orgUnitsReducer = (state = orgUnitsInitialState, action = {}) => { - switch (action.type) { - case SET_SUB_ORG_UNIT: { - const currentSubOrgUnit = action.payload; - return { ...state, currentSubOrgUnit }; - } - - case SET_ORG_UNIT_TYPES: { - const orgUnitTypes = action.payload; - return { ...state, orgUnitTypes }; - } - case SET_SOURCES: { - const sources = action.payload; - return { ...state, sources, orgUnitLevel: [] }; - } - - case RESET_ORG_UNITS: { - return { - ...state, - orgUnitsPage: orgUnitsInitialState.orgUnitsPage, - orgUnitsLocations: orgUnitsInitialState.orgUnitsLocations, - }; - } - - default: - return state; - } -}; diff --git a/hat/assets/js/apps/Iaso/domains/registry/hooks/useGetInstances.tsx b/hat/assets/js/apps/Iaso/domains/registry/hooks/useGetInstances.tsx index 07b7258970..f4815ff4f2 100644 --- a/hat/assets/js/apps/Iaso/domains/registry/hooks/useGetInstances.tsx +++ b/hat/assets/js/apps/Iaso/domains/registry/hooks/useGetInstances.tsx @@ -1,5 +1,5 @@ -import { UseQueryResult } from 'react-query'; import { getSort } from 'bluesquare-components'; +import { UseQueryResult } from 'react-query'; import { getRequest } from '../../../libs/Api'; import { useSnackQuery } from '../../../libs/apiHooks'; diff --git a/hat/assets/js/apps/Iaso/domains/tasks/actions.js b/hat/assets/js/apps/Iaso/domains/tasks/actions.js deleted file mode 100644 index 592d63628f..0000000000 --- a/hat/assets/js/apps/Iaso/domains/tasks/actions.js +++ /dev/null @@ -1,13 +0,0 @@ -import { saveAction } from '../../redux/actions/formsActions'; - -const apiKey = 'tasks'; - -export const killTask = task => dispatch => - saveAction( - dispatch, - task, - apiKey, - 'patchTaskSuccess', - 'patchTaskError', - null, - ).then(res => res); diff --git a/hat/assets/js/apps/Iaso/domains/userRoles/components/CreateEditUserRole.tsx b/hat/assets/js/apps/Iaso/domains/userRoles/components/CreateEditUserRole.tsx index 981be34f8c..8946fd0813 100644 --- a/hat/assets/js/apps/Iaso/domains/userRoles/components/CreateEditUserRole.tsx +++ b/hat/assets/js/apps/Iaso/domains/userRoles/components/CreateEditUserRole.tsx @@ -1,28 +1,31 @@ -import React, { FunctionComponent, useState } from 'react'; -import { useFormik, FormikProvider } from 'formik'; import { AddButton, - useSafeIntl, ConfirmCancelModal, makeFullModal, + useSafeIntl, } from 'bluesquare-components'; +import { FormikProvider, useFormik } from 'formik'; import { isEqual } from 'lodash'; +import React, { FunctionComponent, useState } from 'react'; import { useQueryClient } from 'react-query'; +import { EditIconButton } from '../../../components/Buttons/EditIconButton'; +import InputComponent from '../../../components/forms/InputComponent'; +import { + useApiErrorValidation, + useTranslatedErrors, +} from '../../../libs/validation'; +import { hasFeatureFlag, SHOW_DEV_FEATURES } from '../../../utils/featureFlags'; +import { useCurrentUser } from '../../../utils/usersUtils'; import { SaveUserRoleQuery, useSaveUserRole, } from '../hooks/requests/useSaveUserRole'; import MESSAGES from '../messages'; +import { Permission } from '../types/userRoles'; import { useUserRoleValidation } from '../validation'; -import { - useApiErrorValidation, - useTranslatedErrors, -} from '../../../libs/validation'; -import InputComponent from '../../../components/forms/InputComponent'; +import { OrgUnitWriteTypes } from './OrgUnitWriteTypes'; import { PermissionsAttribution } from './PermissionsAttribution'; -import { EditIconButton } from '../../../components/Buttons/EditIconButton'; import UserRoleDialogInfoComponent from './UserRoleDialogInfoComponent'; -import { Permission } from '../types/userRoles'; type ModalMode = 'create' | 'edit'; type Props = Partial & { @@ -38,6 +41,7 @@ export const CreateEditUserRole: FunctionComponent = ({ isOpen, id, name, + editable_org_unit_type_ids, permissions = [], }) => { const [userRolePermissions, setUserRolePermissoins] = @@ -68,6 +72,7 @@ export const CreateEditUserRole: FunctionComponent = ({ initialValues: { id, name, + editable_org_unit_type_ids, permissions, }, enableReinitialize: true, @@ -109,7 +114,8 @@ export const CreateEditUserRole: FunctionComponent = ({ setUserRolePermissoins(newPermissions); setFieldValue('permissions', newPermissions); }; - + const currentUser = useCurrentUser(); + const hasDevFeatures = hasFeatureFlag(currentUser, SHOW_DEV_FEATURES); return ( <> {dialogType === 'create' && ( @@ -149,6 +155,17 @@ export const CreateEditUserRole: FunctionComponent = ({ label={MESSAGES.name} required /> + {hasDevFeatures && ( + { + onChange( + 'editable_org_unit_type_ids', + newEditableOrgUnitTypeIds, + ); + }} + /> + )} { @@ -172,5 +189,6 @@ const editUserRoleModalWithIcon = makeFullModal( export { createUserRoleModalWithButton as CreateUserRoleDialog, - editUserRoleModalWithIcon as EditUserRoleDialog, + editUserRoleModalWithIcon as EditUserRoleDialog }; + diff --git a/hat/assets/js/apps/Iaso/domains/userRoles/components/OrgUnitWriteTypes.tsx b/hat/assets/js/apps/Iaso/domains/userRoles/components/OrgUnitWriteTypes.tsx new file mode 100644 index 0000000000..2795d7f6b9 --- /dev/null +++ b/hat/assets/js/apps/Iaso/domains/userRoles/components/OrgUnitWriteTypes.tsx @@ -0,0 +1,45 @@ +import { Box } from '@mui/material'; +import { useSafeIntl } from 'bluesquare-components'; +import React, { FunctionComponent } from 'react'; +import InputComponent from '../../../components/forms/InputComponent'; +import { InputWithInfos } from '../../../components/InputWithInfos'; +import { commaSeparatedIdsToArray } from '../../../utils/forms'; +import { useGetOrgUnitTypesDropdownOptions } from '../../orgUnits/orgUnitTypes/hooks/useGetOrgUnitTypesDropdownOptions'; +import { SaveUserRoleQuery } from '../hooks/requests/useSaveUserRole'; +import MESSAGES from '../messages'; + +type Props = { + userRole: Partial; + handleChange: (ouTypesIds: number[]) => void; +}; + +export const OrgUnitWriteTypes: FunctionComponent = ({ + userRole, + handleChange, +}) => { + const { formatMessage } = useSafeIntl(); + const { data: orgUnitTypes, isLoading: isLoadingOrgUitTypes } = + useGetOrgUnitTypesDropdownOptions(undefined, true); + return ( + + + + handleChange(commaSeparatedIdsToArray(value)) + } + loading={isLoadingOrgUitTypes} + value={userRole.editable_org_unit_type_ids ?? []} + type="select" + options={orgUnitTypes} + label={MESSAGES.orgUnitWriteTypes} + helperText={formatMessage(MESSAGES.selectAllHelperText)} + /> + + + ); +}; diff --git a/hat/assets/js/apps/Iaso/domains/userRoles/config.tsx b/hat/assets/js/apps/Iaso/domains/userRoles/config.tsx index 77b3879d06..5f83fd4b67 100644 --- a/hat/assets/js/apps/Iaso/domains/userRoles/config.tsx +++ b/hat/assets/js/apps/Iaso/domains/userRoles/config.tsx @@ -46,6 +46,10 @@ export const useGetUserRolesColumns = ( id={settings.row.original.id} name={settings.row.original.name} permissions={settings.row.original.permissions} + editable_org_unit_type_ids={ + settings.row.original + .editable_org_unit_type_ids + } iconProps={{}} /> ; }; @@ -34,9 +34,15 @@ export const useGetUserRoles = ( options: UserRoleParams | UserRolesFilterParams, ): UseQueryResult => { const { select } = options as Record; + const safeParams = { + ...options, + }; + if (safeParams?.accountId) { + delete safeParams.accountId; + } return useSnackQuery({ queryKey: ['userRoles', options], - queryFn: () => getUserRoles(options), + queryFn: () => getUserRoles(safeParams), snackErrorMsg: undefined, options: { staleTime: 1000 * 60 * 15, // in MS diff --git a/hat/assets/js/apps/Iaso/domains/userRoles/hooks/requests/useSaveUserRole.ts b/hat/assets/js/apps/Iaso/domains/userRoles/hooks/requests/useSaveUserRole.ts index 06e8eb63b0..edd9cb0f14 100644 --- a/hat/assets/js/apps/Iaso/domains/userRoles/hooks/requests/useSaveUserRole.ts +++ b/hat/assets/js/apps/Iaso/domains/userRoles/hooks/requests/useSaveUserRole.ts @@ -1,6 +1,6 @@ -import { UseMutationResult } from 'react-query'; import { isEmpty } from 'lodash'; -import { putRequest, postRequest } from '../../../../libs/Api'; +import { UseMutationResult } from 'react-query'; +import { postRequest, putRequest } from '../../../../libs/Api'; import { useSnackMutation } from '../../../../libs/apiHooks'; import { Permission } from '../../types/userRoles'; @@ -8,14 +8,16 @@ export type SaveUserRoleQuery = { id?: number; name: string; permissions?: Array; + editable_org_unit_type_ids?: number[]; }; -const convertToApi = data => { +const convertToApi = (data: Partial) => { const { permissions, ...converted } = data; - if (!isEmpty(permissions)) { - converted.permissions = permissions; - } - return converted; + return { + ...converted, + permissions: !isEmpty(permissions) ? permissions : undefined, + editable_org_unit_types: converted.editable_org_unit_type_ids, + }; }; const endpoint = '/api/userroles/'; diff --git a/hat/assets/js/apps/Iaso/domains/userRoles/messages.ts b/hat/assets/js/apps/Iaso/domains/userRoles/messages.ts index b2515ee8b3..f40a2243e5 100644 --- a/hat/assets/js/apps/Iaso/domains/userRoles/messages.ts +++ b/hat/assets/js/apps/Iaso/domains/userRoles/messages.ts @@ -86,6 +86,18 @@ const MESSAGES = defineMessages({ defaultMessage: 'An error occurred while fetching user roles', id: 'iaso.snackBar.fetchUserRoles', }, + orgUnitWriteTypesInfos: { + id: 'iaso.userRoles.orgUnitWriteTypesInfos', + defaultMessage: 'Select the org unit types the user role can edit', + }, + selectAllHelperText: { + id: 'iaso.users.selectAllHelperText', + defaultMessage: 'Leave empty to select all', + }, + orgUnitWriteTypes: { + id: 'iaso.users.orgUnitWriteTypes', + defaultMessage: 'Org unit write types', + }, }); export default MESSAGES; diff --git a/hat/assets/js/apps/Iaso/domains/userRoles/types/userRoles.ts b/hat/assets/js/apps/Iaso/domains/userRoles/types/userRoles.ts index 1f6bda28e9..fdb45bd1ed 100644 --- a/hat/assets/js/apps/Iaso/domains/userRoles/types/userRoles.ts +++ b/hat/assets/js/apps/Iaso/domains/userRoles/types/userRoles.ts @@ -5,9 +5,11 @@ export type UserRole = { name: string; created_at: string; updated_at?: string; + editable_org_unit_type_ids?: number[]; }; export type UserRolesFilterParams = { name?: string; + accountId?: number; }; export type UserRoleParams = UrlParams & diff --git a/hat/assets/js/apps/Iaso/domains/users/components/ProtectedRoute.spec.js b/hat/assets/js/apps/Iaso/domains/users/components/ProtectedRoute.spec.js deleted file mode 100644 index 0cd80caf04..0000000000 --- a/hat/assets/js/apps/Iaso/domains/users/components/ProtectedRoute.spec.js +++ /dev/null @@ -1,145 +0,0 @@ -import { expect } from 'chai'; -import React from 'react'; -import nock from 'nock'; -import { ErrorBoundary, theme } from 'bluesquare-components'; -import { ThemeProvider } from '@mui/material'; -import { shallow } from 'enzyme'; -import { - renderWithMutableStore, - mockedStore, -} from '../../../../../test/utils/redux'; -import { mockRequest } from '../../../../../test/utils/requests'; -import ProtectedRoute from './ProtectedRoute'; -import { redirectTo as redirectToAction } from '../../../routing/actions.ts'; -import SidebarMenu from '../../app/components/SidebarMenuComponent'; -import * as Permission from '../../../utils/permissions.ts'; - -const cookieStub = require('../../../utils/cookies'); - -let component; - -const user = { - id: 40, - first_name: '', - user_name: 'son', - last_name: '', - email: '', - permissions: [], - is_superuser: true, - org_units: [], - language: '', -}; - -const updatedUser = { - ...user, - language: 'en', -}; -const unauthorizedUser = { - ...user, - permissions: [Permission.USERS_ADMIN, Permission.USERS_MANAGEMENT], - is_superuser: false, - language: 'en', -}; - -const makeMockedStore = value => - mockedStore({ - app: { locale: { code: 'fr', label: 'Version française' } }, - users: { current: value }, - }); - -const store = makeMockedStore(user); - -const updatedStore = makeMockedStore(updatedUser); - -const storeWithUnauthorizedUser = makeMockedStore(unauthorizedUser); - -const storeWithNoUser = makeMockedStore(null); - -const setCookieSpy = sinon.spy(cookieStub, 'setCookie'); - -const updatedDispatchSpy = sinon.spy(updatedStore, 'dispatch'); - -const unauthorizedDispatchSpy = sinon.spy( - storeWithUnauthorizedUser, - 'dispatch', -); -const redirectSpy = sinon.spy(redirectToAction); - -const fakeGetItem = key => { - if (key === 'django_language') { - return 'fr'; - } - return ''; -}; -const getCookieStub = sinon - .stub(cookieStub, 'getCookie') - .callsFake(fakeGetItem); - -const stubComponent = () =>
I am a stub
; - -const renderComponent = () => { - component = shallow( - renderWithMutableStore( - - - - - , - store, - ), - ); -}; - -describe('ProtectedRoutes', () => { - beforeEach(() => { - setCookieSpy.resetHistory(); - updatedDispatchSpy.resetHistory(); - unauthorizedDispatchSpy.resetHistory(); - redirectSpy.resetHistory(); - nock.cleanAll(); - nock.abortPendingRequests(); - mockRequest('get', '/api/profiles/me', user); - renderComponent(); - component.update(); - }); - - it('renders', () => { - expect(component.exists()).to.equal(true); - }); - it('uses the languages option in cookies if it exists', async () => { - // updating store to trigger componentDidUpdate - component.setProps({ store: updatedStore }); - await component.update(); - expect(setCookieSpy).to.not.have.been.called; - }); - // Passes when not run as part of the test suite - it.skip('uses the language option from backend if none exist in cookies', async () => { - getCookieStub.returns(null); - component.setProps({ store: updatedStore }); - await component.update(); - expect(updatedDispatchSpy).to.have.been.calledOnce; - // expect 2 calls because cookies is set again by action dispatched - expect(setCookieSpy).to.have.been.calledTwice; - }); - // Passes when not run as part of the test suite - it.skip('redirects unauthorized user to first authorized route', async () => { - // put a value in cookies to prevent saving language option (which would trigger a second dispatch call) - getCookieStub.returns('en'); - component.setProps({ store: storeWithUnauthorizedUser }); - await component.update(); - - // Spying on dispatch as imported functions cannot be stubbed or spied on - expect(unauthorizedDispatchSpy).to.have.been.calledOnce; - }); - it('does not render anything if there is no user', async () => { - component.setProps({ store: storeWithNoUser }); - await component.update(); - const element = component.find(SidebarMenu).at(0); - expect(element.exists()).to.equal(false); - }); -}); diff --git a/hat/assets/js/apps/Iaso/domains/users/components/UserOrgUnitWriteTypes.tsx b/hat/assets/js/apps/Iaso/domains/users/components/UserOrgUnitWriteTypes.tsx index 978d29e963..51349a3ef9 100644 --- a/hat/assets/js/apps/Iaso/domains/users/components/UserOrgUnitWriteTypes.tsx +++ b/hat/assets/js/apps/Iaso/domains/users/components/UserOrgUnitWriteTypes.tsx @@ -28,7 +28,7 @@ export const UserOrgUnitWriteTypes: FunctionComponent = ({ handleChange(commaSeparatedIdsToArray(value)) } diff --git a/hat/assets/js/apps/Iaso/domains/users/components/UsersInfos.spec.js b/hat/assets/js/apps/Iaso/domains/users/components/UsersInfos.spec.js index 71abff225a..291ff82a79 100644 --- a/hat/assets/js/apps/Iaso/domains/users/components/UsersInfos.spec.js +++ b/hat/assets/js/apps/Iaso/domains/users/components/UsersInfos.spec.js @@ -1,12 +1,11 @@ -import React from 'react'; -import { Select, PasswordInput } from 'bluesquare-components'; +import { PasswordInput, Select } from 'bluesquare-components'; import { expect } from 'chai'; -import { renderWithMuiTheme } from '../../../../../test/utils/muiTheme'; +import React from 'react'; +import { withQueryClientProvider } from '../../../../../test/utils'; import { renderWithIntl } from '../../../../../test/utils/intl'; -import UsersInfos from './UsersInfos'; +import { renderWithMuiTheme } from '../../../../../test/utils/muiTheme'; import MESSAGES from '../messages'; -import { renderWithStore } from '../../../../../test/utils/redux'; -import { withQueryClientProvider } from '../../../../../test/utils'; +import UsersInfos from './UsersInfos'; let component; let inputs; @@ -33,13 +32,11 @@ const renderComponent = initialData => { renderWithIntl( renderWithMuiTheme( withQueryClientProvider( - renderWithStore( - , - ), + , ), ), ), diff --git a/hat/assets/js/apps/Iaso/hooks/fetchOnMount.js b/hat/assets/js/apps/Iaso/hooks/fetchOnMount.js deleted file mode 100644 index 445138b00c..0000000000 --- a/hat/assets/js/apps/Iaso/hooks/fetchOnMount.js +++ /dev/null @@ -1,17 +0,0 @@ -import { useEffect } from 'react'; -import { useDispatch } from 'react-redux'; - -const useFetchOnMount = promisesArray => { - const dispatch = useDispatch(); - useEffect(() => { - promisesArray.forEach(p => { - p.setFetching(true); - p.fetch(...[dispatch, ...(p.args ?? [])]).then(data => { - p.setFetching(false); - p.setData && dispatch(p.setData(data)); - }); - }); - }, []); -}; - -export { useFetchOnMount }; diff --git a/hat/assets/js/apps/Iaso/hooks/useSwitchAccount.tsx b/hat/assets/js/apps/Iaso/hooks/useSwitchAccount.tsx new file mode 100644 index 0000000000..783538fd5a --- /dev/null +++ b/hat/assets/js/apps/Iaso/hooks/useSwitchAccount.tsx @@ -0,0 +1,13 @@ +import { UseMutationResult } from 'react-query'; +import { patchRequest } from '../libs/Api'; +import { useSnackMutation } from '../libs/apiHooks'; + +export const useSwitchAccount = ( + onSuccess?: () => void, +): UseMutationResult => + useSnackMutation({ + mutationFn: accountId => + patchRequest('/api/accounts/switch/', { account_id: accountId }), + options: { onSuccess: onSuccess || (() => null) }, + showSucessSnackBar: false, + }); diff --git a/hat/assets/js/apps/Iaso/index.tsx b/hat/assets/js/apps/Iaso/index.tsx index 36adc76738..5f12e5fedd 100644 --- a/hat/assets/js/apps/Iaso/index.tsx +++ b/hat/assets/js/apps/Iaso/index.tsx @@ -4,7 +4,6 @@ import { theme } from 'bluesquare-components'; import React from 'react'; import ReactDOM from 'react-dom'; import { QueryClient, QueryClientProvider } from 'react-query'; -import { Provider } from 'react-redux'; import './libs/polyfills'; @@ -19,7 +18,6 @@ import { } from './domains/app/contexts/ThemeConfigContext'; import App from './domains/app/index'; import { Plugin } from './domains/app/types'; -import { store } from './redux/store.js'; import { getGlobalOverrides, getOverriddenTheme } from './styles'; import { PluginsContext, getPlugins } from './utils'; @@ -56,27 +54,24 @@ const iasoApp = (element, enabledPluginsName, themeConfig, userHomePage) => { - - - - - - - - - + + + + + + + diff --git a/hat/assets/js/apps/Iaso/redux/actions/formsActions.js b/hat/assets/js/apps/Iaso/redux/actions/formsActions.js deleted file mode 100644 index a17b41d0a2..0000000000 --- a/hat/assets/js/apps/Iaso/redux/actions/formsActions.js +++ /dev/null @@ -1,268 +0,0 @@ -import { - deleteRequest, - getRequest, - patchRequest, - postRequest, - putRequest, -} from 'Iaso/libs/Api.ts'; -import { openSnackBar } from '../../components/snackBars/EventDispatcher.ts'; -import { errorSnackBar, succesfullSnackBar } from '../../constants/snackBars'; - -/** - * Fetch action to get a list of items - * @param {Function} dispatch Redux function to trigger an action - * @param {String} apiPath The endpoint path used - * @param {Function} setAction Set action to put the list in redux - * @param {String} errorKeyMessage The key of the error message used by the snackbar - * @param {String} resultKey The key of the list returned by the api ({ groups: [...], ...}) - * @param {Object} params Url params used for the pagination - * @param {Function} setIsLoading The loading action to display the loading state - */ -export const fetchAction = ( - dispatch, - apiPath, - setAction, - errorKeyMessage, - resultKey = null, - params = null, - setIsLoading = null, -) => { - let url = `/api/${apiPath}/`; - if (params) { - url += `?order=${params.order}&limit=${params.pageSize}&page=${params.page}`; - if (params.search) { - url += `&search=${params.search}`; - } - - if (setIsLoading !== null) { - dispatch(setIsLoading(true)); - } - } - return getRequest(url) - .then(res => { - const result = resultKey ? res[resultKey] : res; - return dispatch( - setAction( - result, - params - ? { count: res.count, pages: res.pages } - : { count: result.length, pages: 1 }, - ), - ); - }) - .catch(err => openSnackBar(errorSnackBar(errorKeyMessage, null, err))) - .then(() => { - if (params && setIsLoading !== null) { - dispatch(setIsLoading(false)); - } - }); -}; - -/** - * Fetch action to get a list of items - * @param {Function} dispatch Redux function to trigger an action - * @param {String} apiPath The endpoint path used - * @param {Number|String} itemId The resource id (or a string in very specific cases, such as "me") - * @param {Function} setAction Set action to put the list in redux - * @param {String} errorKeyMessage The key of the error message used by the snackbar - * @param {Function} setIsLoading The loading action to display the loading state - */ -export const retrieveAction = ( - dispatch, - apiPath, - itemId, - setAction, - errorKeyMessage, - setIsLoading = null, -) => { - const url = `/api/${apiPath}/${itemId}/`; - if (setIsLoading !== null) { - dispatch(setIsLoading(true)); - } - - return getRequest(url) - .then(res => dispatch(setAction(res))) - .catch(err => openSnackBar(errorSnackBar(errorKeyMessage, null, err))) - .then(() => { - if (setIsLoading !== null) { - dispatch(setIsLoading(false)); - } - }); -}; - -/** - * Save action to update one item - * @param {Function} dispatch Redux function to trigger an action - * @param {Object} item The item to save - * @param {String} apiPath The endpoint path used - * @param {String} successKeyMessage The key of the success message used by the snackbar - * @param {String} errorKeyMessage The key of the error message used by the snackbar - * @param {Function} setIsLoading The loading action to display the loading state - * @param {Array} ignoredErrorCodes array of status error code to ignore while displaying snackbars - */ - -export const updateAction = ( - dispatch, - item, - apiPath, - successKeyMessage, - errorKeyMessage, - setIsLoading = null, - ignoredErrorCodes, -) => { - if (setIsLoading !== null) { - dispatch(setIsLoading(true)); - } - return putRequest(`/api/${apiPath}/${item.id}/`, item) - .then(res => { - openSnackBar(succesfullSnackBar(successKeyMessage)); - return res; - }) - .catch(err => { - if ( - !ignoredErrorCodes || - (ignoredErrorCodes && !ignoredErrorCodes.includes(err.status)) - ) { - openSnackBar(errorSnackBar(errorKeyMessage, null, err)); - } - throw err; - }) - .finally(() => { - if (setIsLoading !== null) { - dispatch(setIsLoading(false)); - } - }); -}; - -export const saveAction = ( - dispatch, - item, - apiPath, - successKeyMessage, - errorKeyMessage, - setIsLoading = null, - ignoredErrorCodes, - setResultFunction = null, -) => { - if (setIsLoading !== null) { - dispatch(setIsLoading(true)); - } - return patchRequest(`/api/${apiPath}/${item.id}/`, item) - .then(res => { - openSnackBar(succesfullSnackBar(successKeyMessage)); - return res; - }) - .then(res => { - if (setResultFunction) { - dispatch(setResultFunction(res)); - } - return res; - }) - - .catch(err => { - if ( - !ignoredErrorCodes || - (ignoredErrorCodes && !ignoredErrorCodes.includes(err.status)) - ) { - openSnackBar(errorSnackBar(errorKeyMessage, null, err)); - } - throw err; - }) - .finally(() => { - if (setIsLoading !== null) { - dispatch(setIsLoading(false)); - } - }); -}; - -/** - * Save action to create one item - * @param {Function} dispatch Redux function to trigger an action - * @param {Object} item The item to create - * @param {String} apiPath The endpoint path used - * @param {String} successKeyMessage The key of the success message used by the snackbar - * @param {String} errorKeyMessage The key of the error message used by the snackbar - * @param {Function} setIsLoading The loading action to display the loading state - * @param {Array} ignoredErrorCodes array of status error code to ignore while displaying snackbars - */ -export const createAction = ( - dispatch, - item, - apiPath, - successKeyMessage, - errorKeyMessage, - setIsLoading = null, - ignoredErrorCodes, -) => { - if (setIsLoading !== null) { - dispatch(setIsLoading(true)); - } - return postRequest(`/api/${apiPath}/`, item) - .then(res => { - openSnackBar(succesfullSnackBar(successKeyMessage)); - return res; - }) - .catch(err => { - if ( - !ignoredErrorCodes || - (ignoredErrorCodes && !ignoredErrorCodes.includes(err.status)) - ) { - openSnackBar(errorSnackBar(errorKeyMessage, null, err)); - } - throw err; - }) - .finally(() => { - if (setIsLoading !== null) { - dispatch(setIsLoading(false)); - } - }); -}; - -/** - * Delete action to delete one item - * @param {Function} dispatch Redux function to trigger an action - * @param {Object} item The item to delete - * @param {String} apiPath The endpoint path used - * @param {Function} setAction Set action to put the list in redux - * @param {String} successKeyMessage The key of the success message used by the snackbar - * @param {String} errorKeyMessage The key of the error message used by the snackbar - * @param {String} resultKey The key of the list returned by the api ({ groups: [...], ...}) - * @param {Object} params Url params used for the pagination - * @param {Function} setIsLoading The loading action to display the loading state - */ -export const deleteAction = ( - dispatch, - item, - apiPath, - setAction, - successKeyMessage, - errorKeyMessage, - resultKey = null, - params = null, - setIsLoading = null, -) => { - if (setIsLoading !== null) { - dispatch(setIsLoading(true)); - } - return deleteRequest(`/api/${apiPath}/${item.id}/`) - .then(res => { - openSnackBar(succesfullSnackBar(successKeyMessage)); - fetchAction( - dispatch, - apiPath, - setAction, - errorKeyMessage, - resultKey, - params, - setIsLoading, - ); - return res; - }) - .catch(err => { - openSnackBar(errorSnackBar(errorKeyMessage, null, err)); - if (setIsLoading !== null) { - dispatch(setIsLoading(false)); - } - throw err; - }); -}; diff --git a/hat/assets/js/apps/Iaso/redux/createStore.js b/hat/assets/js/apps/Iaso/redux/createStore.js deleted file mode 100644 index d3b54fc48f..0000000000 --- a/hat/assets/js/apps/Iaso/redux/createStore.js +++ /dev/null @@ -1,34 +0,0 @@ -import { - combineReducers, - createStore as _createStore, - applyMiddleware, - compose, -} from 'redux'; -import { routerReducer } from 'react-router-redux'; - -const createReducer = (appReducers, pluginReducers) => { - return combineReducers({ - routing: routerReducer, - ...appReducers, - ...pluginReducers, - }); -}; - -export default (initialState = {}, reducers = {}, middleWare = []) => { - const store = _createStore( - createReducer(reducers), - initialState, - compose( - applyMiddleware(...middleWare), - window.__REDUX_DEVTOOLS_EXTENSION__ - ? window.__REDUX_DEVTOOLS_EXTENSION__() - : f => f, - ), - ); - store.pluginReducers = {}; - store.injectReducer = (key, pluginReducer) => { - store.pluginReducers[key] = pluginReducer; - store.replaceReducer(createReducer(reducers, store.pluginReducers)); - }; - return store; -}; diff --git a/hat/assets/js/apps/Iaso/redux/store.js b/hat/assets/js/apps/Iaso/redux/store.js deleted file mode 100644 index b794436243..0000000000 --- a/hat/assets/js/apps/Iaso/redux/store.js +++ /dev/null @@ -1,34 +0,0 @@ -// import { useRouterHistory } from 'react-router'; -// import { syncHistoryWithStore, routerMiddleware } from 'react-router-redux'; -import thunk from 'redux-thunk'; -// import { createHistory } from 'history'; - -import createStore from './createStore'; - -import { - instancesInitialState, - instancesReducer, -} from '../domains/instances/reducer'; -import { - orgUnitsInitialState, - orgUnitsReducer, -} from '../domains/orgUnits/reducer'; - -const store = createStore( - { - orgUnits: orgUnitsInitialState, - instances: instancesInitialState, - }, - { - orgUnits: orgUnitsReducer, - instances: instancesReducer, - }, - [ - // routerMiddleware(storeHistory), - thunk, - ], -); - -const { dispatch } = store; - -export { dispatch, store }; diff --git a/hat/assets/js/apps/Iaso/redux/useInjectedStore.ts b/hat/assets/js/apps/Iaso/redux/useInjectedStore.ts deleted file mode 100644 index b86d5c7305..0000000000 --- a/hat/assets/js/apps/Iaso/redux/useInjectedStore.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { useContext, useEffect, useMemo, useState } from 'react'; -import { ReactReduxContext } from 'react-redux'; -/** - * - * @deprecated Redux - * This is adding a list of reducers to the existing one's. - * As using redux is a practice we are trying to avoid, try not to use this hook - * Also do not use same reducer key as the existing one's (see hat/assets/js/apps/Iaso/redux/store.js) - * - * Example how to use in a component: - * const store = useInjectedStore([ - { - reducerKey: 'reducerKey', - reducer: IMPORTED_REDUCER, - }, - ]); - if (!store) return null; - return ( - - - - ); - * - */ - -type Reducer = { - reducerKey: string; - reducer: (state: any, action: Record) => Record; -}; - -type Reducers = Reducer[]; - -export const useInjectedStore = (reducers: Reducers): any => { - const { store }: { store: any } = useContext(ReactReduxContext); - const existingKeys = useMemo(() => { - return Object.keys(store.getState()); - }, [store]); - const [injectedStore, setInjectedStore] = useState(false); - useEffect(() => { - if (store && !injectedStore) { - for (let i = 0; i < reducers.length; i += 1) { - const { reducerKey, reducer } = reducers[i]; - - if (existingKeys.includes(reducerKey)) { - console.warn( - `A reducer with this key ("${reducerKey}") already exists in the store`, - ); - } else { - store.injectReducer(reducerKey, reducer); - } - } - setInjectedStore(true); - } - }, [store, injectedStore, reducers, existingKeys]); - if (!injectedStore) return undefined; - - return store; -}; diff --git a/hat/assets/js/apps/Iaso/routing/actions.ts b/hat/assets/js/apps/Iaso/routing/actions.ts deleted file mode 100644 index 2cffaa4d0b..0000000000 --- a/hat/assets/js/apps/Iaso/routing/actions.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { push, replace } from 'react-router-redux'; -import { createUrl } from 'bluesquare-components'; - -export const redirectTo = - (key: string, params: Record = {}): ((dispatch) => any) => - dispatch => - dispatch(push(`${key}${createUrl(params, '')}`)); - -export const redirectToReplace = - (key: string, params: Record = {}): ((dispatch) => any) => - dispatch => - dispatch(replace(`${key}${createUrl(params, '')}`)); diff --git a/hat/assets/js/apps/Iaso/utils/map/mapUtils.ts b/hat/assets/js/apps/Iaso/utils/map/mapUtils.ts index 18fdc923c3..21ac882b5f 100644 --- a/hat/assets/js/apps/Iaso/utils/map/mapUtils.ts +++ b/hat/assets/js/apps/Iaso/utils/map/mapUtils.ts @@ -115,7 +115,7 @@ export const customMarker = L.divIcon(customMarkerOptions); export const circleColorMarkerOptions = ( color: string, - radius = 8, + radius = 10, ): Record => ({ className: 'marker-custom color circle-marker', pathOptions: { diff --git a/hat/assets/js/apps/Iaso/utils/map/usePopupState.ts b/hat/assets/js/apps/Iaso/utils/map/usePopupState.ts new file mode 100644 index 0000000000..e9b9801a72 --- /dev/null +++ b/hat/assets/js/apps/Iaso/utils/map/usePopupState.ts @@ -0,0 +1,34 @@ +import L from 'leaflet'; +import { RefObject, useEffect, useState } from 'react'; +import { useMap } from 'react-leaflet'; + +export const usePopupState = (popupRef: RefObject): boolean => { + const [isOpen, setIsOpen] = useState(false); + const map = useMap(); + + useEffect(() => { + if (!map || !popupRef.current) return; + + const onPopupOpen = (e: L.PopupEvent): void => { + if (e.popup === popupRef.current) { + setIsOpen(true); + } + }; + + const onPopupClose = (e: L.PopupEvent): void => { + if (e.popup === popupRef.current) { + setIsOpen(false); + } + }; + + map.on('popupopen', onPopupOpen); + map.on('popupclose', onPopupClose); + + () => { + map.off('popupopen', onPopupOpen); + map.off('popupclose', onPopupClose); + }; + }, [map, popupRef]); + + return isOpen; +}; diff --git a/hat/assets/js/apps/Iaso/utils/requests.js b/hat/assets/js/apps/Iaso/utils/requests.js index cec8337eef..9c5ccd8ddb 100644 --- a/hat/assets/js/apps/Iaso/utils/requests.js +++ b/hat/assets/js/apps/Iaso/utils/requests.js @@ -43,16 +43,8 @@ export const fetchAssociatedOrgUnits = ( }); }; -export const fetchOrgUnitDetail = orgUnitId => - getRequest(`/api/orgunits/${orgUnitId}/`) - .then(orgUnit => orgUnit) - .catch(error => { - openSnackBar(errorSnackBar('fetchOrgUnitError', null, error)); - console.error('Error while fetching org unit detail:', error); - }); - export const fetchInstanceDetail = instanceId => - getRequest(`/api/instances/${instanceId}`) + getRequest(`/api/instances/${instanceId}/`) .then(instance => instance) .catch(error => { openSnackBar(errorSnackBar('fetchInstanceError', null, error)); diff --git a/hat/assets/js/apps/Iaso/utils/usersUtils.ts b/hat/assets/js/apps/Iaso/utils/usersUtils.ts index 2801437744..15da01c30f 100644 --- a/hat/assets/js/apps/Iaso/utils/usersUtils.ts +++ b/hat/assets/js/apps/Iaso/utils/usersUtils.ts @@ -52,6 +52,15 @@ export type SourceVersion = { version?: DataSource; }; +export type Account = { + name: string; + id: number; + created_at: number; + updated_at: number; + default_version?: DefaultVersion; + feature_flags: string[]; +}; + export type User = { id: number; first_name: string; @@ -59,14 +68,8 @@ export type User = { username?: string; user_name?: string; email: string; - account: { - name: string; - id: number; - created_at: number; - updated_at: number; - default_version?: DefaultVersion; - feature_flags: string[]; - }; + account: Account; + other_accounts: Account[]; permissions: string[]; is_staff?: boolean; is_superuser: boolean; @@ -79,6 +82,8 @@ export type User = { user_id: number; dhis2_id?: string; editable_org_unit_type_ids?: number[]; + user_roles: number[]; + user_roles_editable_org_unit_type_ids?: number[]; }; export const getDisplayName = ( @@ -117,14 +122,17 @@ export const useCheckUserHasWriteTypePermission = (): (( ) => boolean) => { const currentUser = useCurrentUser(); return (orgUnitTypeId?: number) => { - return Boolean( - currentUser && - (!currentUser.editable_org_unit_type_ids || - currentUser.editable_org_unit_type_ids?.length === 0 || - (orgUnitTypeId && - currentUser.editable_org_unit_type_ids?.includes( - orgUnitTypeId, - ))), + if (!currentUser) return false; + + const editableTypeIds = [ + ...(currentUser.editable_org_unit_type_ids ?? []), + ...(currentUser.user_roles_editable_org_unit_type_ids ?? []), + ]; + + return ( + editableTypeIds.length === 0 || + (orgUnitTypeId !== undefined && + editableTypeIds.includes(orgUnitTypeId)) ); }; }; diff --git a/hat/assets/js/test/utils.js b/hat/assets/js/test/utils.js index 3c71f1ca3f..10d759ce2e 100644 --- a/hat/assets/js/test/utils.js +++ b/hat/assets/js/test/utils.js @@ -1,8 +1,6 @@ import React from 'react'; -import ReactDOM from 'react-dom'; import TestUtils, { act } from 'react-dom/test-utils'; import { IntlProvider } from 'react-intl'; -import { Provider } from 'react-redux'; import { QueryClient, QueryClientProvider } from 'react-query'; import { BrowserRouter, Route } from 'react-router-dom'; import InputComponent from '../apps/Iaso/components/forms/InputComponent.tsx'; @@ -31,18 +29,6 @@ export function withRouter(component) { return ; } -export function renderWithStore(store, component, node = null) { - const wrappedComp = ( - - {component} - - ); - if (!node) { - node = document.createElement('div'); // eslint-disable-line - } - return ReactDOM.render(wrappedComp, node); // eslint-disable-line -} - export const awaitUseEffect = async wrapper => { await act(async () => { await Promise.resolve(wrapper); diff --git a/hat/assets/js/test/utils/redux.js b/hat/assets/js/test/utils/redux.js deleted file mode 100644 index 79a90c3fba..0000000000 --- a/hat/assets/js/test/utils/redux.js +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import { Provider } from 'react-redux'; -import configureStore from 'redux-mock-store'; -import thunk from 'redux-thunk'; -import { instancesInitialState } from '../../apps/Iaso/domains/instances/reducer'; -import { orgUnitsInitialState } from '../../apps/Iaso/domains/orgUnits/reducer'; - -import { renderWithIntl } from './intl'; - -import { renderWithMuiTheme } from './muiTheme'; - -const middlewares = [thunk]; -const mockStore = configureStore(middlewares); - -const getMockedStore = storeObject => mockStore(storeObject); - -const initialState = { - orgUnits: orgUnitsInitialState, - instances: instancesInitialState, -}; - -export const renderWithStore = (component, state = null) => ( - - {renderWithMuiTheme(renderWithIntl(component))} - -); - -export const mockedStore = state => - getMockedStore({ ...initialState, ...state }); - -export const renderWithMutableStore = (component, store) => ( - {renderWithIntl(component)} -); diff --git a/hat/settings.py b/hat/settings.py index c892f60310..457a0b8a8c 100644 --- a/hat/settings.py +++ b/hat/settings.py @@ -414,7 +414,11 @@ def is_superuser(u): ), } -SIMPLE_JWT = {"ACCESS_TOKEN_LIFETIME": timedelta(days=3650), "REFRESH_TOKEN_LIFETIME": timedelta(days=3651)} +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(days=3650), + "REFRESH_TOKEN_LIFETIME": timedelta(days=3651), + "TOKEN_OBTAIN_SERIALIZER": "iaso.serializers.CustomTokenObtainPairSerializer", +} AWS_S3_REGION_NAME = os.getenv("AWS_S3_REGION_NAME", "eu-central-1") AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID") diff --git a/hat/webpack.dev.js b/hat/webpack.dev.js index cb3756e52a..a1c60da7ee 100644 --- a/hat/webpack.dev.js +++ b/hat/webpack.dev.js @@ -152,7 +152,6 @@ module.exports = { 'moment', 'leaflet', 'leaflet-draw', - 'react-redux', 'prop-types', 'typescript', 'video.js', diff --git a/iaso/admin.py b/iaso/admin.py index 90ee14c2ac..9348f35b39 100644 --- a/iaso/admin.py +++ b/iaso/admin.py @@ -1,15 +1,17 @@ from copy import copy from typing import Any, Protocol -import requests from django import forms as django_forms -from django.contrib.admin import RelatedOnlyFieldListFilter, widgets +from django.contrib import admin +from django.contrib.admin import widgets +from django.contrib.admin.widgets import AutocompleteSelect from django.contrib.gis import admin, forms from django.contrib.gis.db import models as geomodels from django.contrib.postgres.fields import ArrayField from django.db import models -from django.http import HttpResponseRedirect -from django.urls import reverse +from django.db.models import Count, Q +from django.http import HttpResponseRedirect, JsonResponse +from django.urls import path, reverse from django.utils.html import format_html, format_html_join from django.utils.safestring import mark_safe from django_json_widget.widgets import JSONEditorWidget @@ -71,6 +73,7 @@ StorageLogEntry, StoragePassword, Task, + TenantUser, UserRole, Workflow, WorkflowChange, @@ -987,6 +990,85 @@ class GroupSetAdmin(admin.ModelAdmin): autocomplete_fields = ["source_version"] +@admin.register(TenantUser) +class TenantUserAdmin(admin.ModelAdmin): + list_display = ( + "main_user", + "account_user", + "account", + "created_at", + "updated_at", + "all_accounts_count", + "is_self_account", + ) + list_filter = ("account_user__iaso_profile__account",) + search_fields = ("main_user__username", "account_user__username", "account_user__iaso_profile__account__name") + raw_id_fields = ("main_user", "account_user") + readonly_fields = ("created_at", "updated_at", "account", "all_account_users", "other_accounts") + + def get_urls(self): + urls = super().get_urls() + custom_urls = [ + path("main-user-autocomplete/", self.main_user_autocomplete, name="main-user-autocomplete"), + ] + return custom_urls + urls + + def main_user_autocomplete(self, request): + term = request.GET.get("term", "") + queryset = ( + TenantUser.objects.filter(Q(main_user__username__icontains=term) | Q(main_user__email__icontains=term)) + .values_list("main_user__id", "main_user__username") + .distinct()[:10] + ) + results = [{"id": user_id, "text": username} for user_id, username in queryset] + return JsonResponse({"results": results}) + + def formfield_for_foreignkey(self, db_field, request, **kwargs): + if db_field.name == "main_user": + kwargs["widget"] = AutocompleteSelect( + db_field.remote_field, self.admin_site, attrs={"data-ajax--url": "main-user-autocomplete/"} + ) + return super().formfield_for_foreignkey(db_field, request, **kwargs) + + def account(self, obj): + return obj.account + + account.admin_order_field = "account_user__iaso_profile__account" + account.short_description = "Account" + + def all_accounts_count(self, obj): + return obj.main_user.tenant_users.count() + + all_accounts_count.short_description = "Total Accounts" + + def is_self_account(self, obj): + return obj.main_user == obj.account_user + + is_self_account.boolean = True + is_self_account.short_description = "Self Account" + + def all_account_users(self, obj): + users = obj.get_all_account_users() + return format_html("
".join(user.username for user in users)) + + all_account_users.short_description = "All Account Users" + + def other_accounts(self, obj): + accounts = obj.get_other_accounts() + return format_html("
".join(str(account) for account in accounts)) + + other_accounts.short_description = "Other Accounts" + + def get_queryset(self, request): + return super().get_queryset(request).select_related("main_user", "account_user__iaso_profile__account") + + class Media: + js = ("admin/js/vendor/select2/select2.full.min.js", "admin/js/autocomplete.js") + css = { + "all": ("admin/css/vendor/select2/select2.min.css",), + } + + admin.site.register(AccountFeatureFlag) admin.site.register(Device) admin.site.register(DeviceOwnership) diff --git a/iaso/api/accounts.py b/iaso/api/accounts.py index 0d20a90041..562c07f493 100644 --- a/iaso/api/accounts.py +++ b/iaso/api/accounts.py @@ -1,11 +1,17 @@ """This api is only there so the default version on an account can be modified""" -from .common import ModelViewSet, HasPermission -from iaso.models import Account, SourceVersion -from rest_framework import serializers, permissions +from django.contrib.auth import login +from django.contrib.auth.models import User +from rest_framework import permissions, serializers, status +from rest_framework.decorators import action from rest_framework.generics import get_object_or_404 from rest_framework.request import Request +from rest_framework.response import Response + from hat.menupermissions import models as permission +from iaso.models import Account, SourceVersion + +from .common import HasPermission, ModelViewSet class AccountSerializer(serializers.ModelSerializer): @@ -60,4 +66,24 @@ class AccountViewSet(ModelViewSet): results_key = "accounts" queryset = Account.objects.all() # FIXME: USe a PATCH in the future, it make more sense regarding HTTP method semantic - http_method_names = ["put"] + http_method_names = ["patch", "put"] + + @action(detail=False, methods=["patch"], url_path="switch") + def switch(self, request): + # TODO: Make sure the account_id is present + self.permission_classes = [permissions.IsAuthenticated, HasAccountPermission] + self.check_permissions(request) + account_id = request.data.get("account_id", None) + + current_user = request.user + account_users = current_user.tenant_user.get_all_account_users() + user_to_login = next( + (u for u in account_users if u.iaso_profile and u.iaso_profile.account_id == account_id), None + ) + + if user_to_login: + user_to_login.backend = "django.contrib.auth.backends.ModelBackend" + login(request, user_to_login) + return Response(user_to_login.iaso_profile.account.as_dict()) + else: + return Response(status=status.HTTP_404_NOT_FOUND) diff --git a/iaso/api/org_unit_change_request_configurations/views_mobile.py b/iaso/api/org_unit_change_request_configurations/views_mobile.py index a20e4feb28..59a36f1e2a 100644 --- a/iaso/api/org_unit_change_request_configurations/views_mobile.py +++ b/iaso/api/org_unit_change_request_configurations/views_mobile.py @@ -1,8 +1,9 @@ +from itertools import chain + import django_filters from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema -from rest_framework import filters from rest_framework import viewsets from rest_framework.mixins import ListModelMixin from rest_framework.request import Request @@ -17,12 +18,12 @@ ) from iaso.api.query_params import APP_ID from iaso.api.serializers import AppIdSerializer -from iaso.models import OrgUnitChangeRequestConfiguration +from iaso.models import OrgUnitChangeRequestConfiguration, Project class MobileOrgUnitChangeRequestConfigurationViewSet(ListModelMixin, viewsets.GenericViewSet): permission_classes = [HasOrgUnitsChangeRequestConfigurationReadPermission] - filter_backends = [filters.OrderingFilter, django_filters.rest_framework.DjangoFilterBackend] + filter_backends = [django_filters.rest_framework.DjangoFilterBackend] serializer_class = MobileOrgUnitChangeRequestConfigurationListSerializer pagination_class = OrgUnitChangeRequestConfigurationPagination @@ -42,9 +43,62 @@ def get_queryset(self): .prefetch_related( "possible_types", "possible_parent_types", "group_sets", "editable_reference_forms", "other_groups" ) - .order_by("id") + .order_by("org_unit_type_id") ) @swagger_auto_schema(manual_parameters=[app_id_param]) def list(self, request: Request, *args, **kwargs) -> Response: - return super().list(request, *args, **kwargs) + """ + Because some Org Unit Type restrictions are also configurable at the `Profile` level, + we implement the following logic in the list view: + + 1. If `Profile.editable_org_unit_types` empty: + + - return `OrgUnitChangeRequestConfiguration` content + + 2. If `Profile.editable_org_unit_types` not empty: + + a. for org_unit_type not in `Profile.editable_org_unit_types`: + + - return a dynamic configuration that says `org_units_editable: False` + - regardless of any existing `OrgUnitChangeRequestConfiguration` + + b. for org_unit_type in `Profile.editable_org_unit_types`: + + - return either the existing `OrgUnitChangeRequestConfiguration` content or nothing + + """ + app_id = AppIdSerializer(data=self.request.query_params).get_app_id(raise_exception=True) + queryset = self.get_queryset() + + user_editable_org_unit_type_ids = self.request.user.iaso_profile.get_editable_org_unit_type_ids() + + if user_editable_org_unit_type_ids: + project_org_unit_types = set(Project.objects.filter(app_id=app_id).values_list("unit_types__id", flat=True)) + non_editable_org_unit_type_ids = project_org_unit_types - user_editable_org_unit_type_ids + + dynamic_configurations = [ + OrgUnitChangeRequestConfiguration(org_unit_type_id=org_unit_type_id, org_units_editable=False) + for org_unit_type_id in non_editable_org_unit_type_ids + ] + + # Because we're merging unsaved instances with a queryset (which is a representation of a database query), + # we have to sort the resulting list manually to keep the pagination working properly. + queryset = list( + chain( + queryset.exclude(org_unit_type__in=non_editable_org_unit_type_ids), + dynamic_configurations, + ) + ) + # Unsaved instances do not have an `id`, so we're sorting on `org_unit_type_id` in all cases. + queryset = sorted(queryset, key=lambda item: item.org_unit_type_id) + + queryset = self.filter_queryset(queryset) + + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) diff --git a/iaso/api/org_units.py b/iaso/api/org_units.py index 762ebc105b..e77825e47c 100644 --- a/iaso/api/org_units.py +++ b/iaso/api/org_units.py @@ -423,9 +423,18 @@ def treesearch(self, request, **kwargs): def partial_update(self, request, pk=None): errors = [] org_unit = get_object_or_404(self.get_queryset(), id=pk) + profile = request.user.iaso_profile self.check_object_permissions(request, org_unit) + if org_unit.org_unit_type and not profile.has_org_unit_write_permission(org_unit.org_unit_type.pk): + errors.append( + { + "errorKey": "org_unit_type_id", + "errorMessage": _("You cannot create or edit an Org unit of this type"), + } + ) + original_copy = deepcopy(org_unit) if "name" in request.data: @@ -528,7 +537,6 @@ def partial_update(self, request, pk=None): org_unit.parent = parent_org_unit else: # User that are restricted to parts of the hierarchy cannot create root orgunit - profile = request.user.iaso_profile if profile.org_units.all(): errors.append( { @@ -674,8 +682,6 @@ def create_org_unit(self, request): else: org_unit.validation_status = validation_status - org_unit_type_id = request.data.get("org_unit_type_id", None) - reference_instance_id = request.data.get("reference_instance_id", None) parent_id = request.data.get("parent_id", None) @@ -697,9 +703,19 @@ def create_org_unit(self, request): if latitude and longitude: org_unit.location = Point(x=longitude, y=latitude, z=altitude, srid=4326) + org_unit_type_id = request.data.get("org_unit_type_id", None) + if not org_unit_type_id: errors.append({"errorKey": "org_unit_type_id", "errorMessage": _("Org unit type is required")}) + if not profile.has_org_unit_write_permission(org_unit_type_id): + errors.append( + { + "errorKey": "org_unit_type_id", + "errorMessage": _("You cannot create or edit an Org unit of this type"), + } + ) + if parent_id: parent_org_unit = get_object_or_404(self.get_queryset(), id=parent_id) if org_unit.version_id != parent_org_unit.version_id: diff --git a/iaso/api/profiles/profiles.py b/iaso/api/profiles/profiles.py index 4588277d53..ab6004a88a 100644 --- a/iaso/api/profiles/profiles.py +++ b/iaso/api/profiles/profiles.py @@ -1,7 +1,8 @@ +import copy from typing import Any, List, Optional, Union from django.conf import settings -from django.contrib.auth import models, update_session_auth_hash +from django.contrib.auth import login, models, update_session_auth_hash from django.contrib.auth.models import Permission, User from django.contrib.auth.tokens import PasswordResetTokenGenerator from django.core.exceptions import BadRequest, ObjectDoesNotExist @@ -27,7 +28,7 @@ from iaso.api.common import CONTENT_TYPE_CSV, CONTENT_TYPE_XLSX, FileFormatEnum from iaso.api.profiles.audit import ProfileAuditLogger from iaso.api.profiles.bulk_create_users import BULK_CREATE_USER_COLUMNS_LIST -from iaso.models import OrgUnit, OrgUnitType, Profile, Project, UserRole +from iaso.models import OrgUnit, OrgUnitType, Profile, Project, TenantUser, UserRole from iaso.utils import is_mobile_request from iaso.utils.module_permissions import account_module_permissions @@ -220,7 +221,7 @@ class ProfilesViewSet(viewsets.ViewSet): def get_queryset(self): account = self.request.user.iaso_profile.account - return Profile.objects.filter(account=account) + return Profile.objects.filter(account=account).with_editable_org_unit_types() def list(self, request): limit = request.GET.get("limit", None) @@ -260,11 +261,12 @@ def list(self, request): teams=teams, managed_users_only=managed_users_only, ids=ids, - ) + ).order_by("id") queryset = queryset.prefetch_related( "user", "user_roles", + "user__tenant_user", "org_units", "org_units__version", "org_units__version__data_source", @@ -275,6 +277,7 @@ def list(self, request): "org_units__parent__org_unit_type", "org_units__parent__parent__org_unit_type", "projects", + "editable_org_unit_types", ) if request.GET.get("csv"): return self.list_export(queryset=queryset, file_format=FileFormatEnum.CSV) @@ -354,11 +357,18 @@ def get_row(profile: Profile, **_) -> List[Any]: def retrieve(self, request, *args, **kwargs): pk = kwargs.get("pk") if pk == PK_ME: + # if the user is a main_user, login as an account user + # TODO: This is not a clean side-effect and should be improved. + if request.user.tenant_users.exists(): + account_user = request.user.tenant_users.first().account_user + account_user.backend = "django.contrib.auth.backends.ModelBackend" + login(request, account_user) + try: profile = request.user.iaso_profile profile_dict = profile.as_dict() return Response(profile_dict) - except ObjectDoesNotExist: + except Profile.DoesNotExist: return Response( { "first_name": request.user.first_name, @@ -500,13 +510,11 @@ def validate_projects(self, request, profile): return result def validate_editable_org_unit_types(self, request): - result = [] - editable_org_unit_type_ids = request.data.get("editable_org_unit_type_ids", None) - if editable_org_unit_type_ids: - for editable_org_unit_type_id in editable_org_unit_type_ids: - item = get_object_or_404(OrgUnitType, pk=editable_org_unit_type_id) - result.append(item) - return result + editable_org_unit_type_ids = request.data.get("editable_org_unit_type_ids", []) + editable_org_unit_types = OrgUnitType.objects.filter(pk__in=editable_org_unit_type_ids) + if editable_org_unit_types.count() != len(editable_org_unit_type_ids): + raise ValidationError("Invalid editable org unit type submitted.") + return editable_org_unit_types @staticmethod def update_user_own_profile(request): @@ -659,17 +667,57 @@ def get_subject_by_language(self, language="en"): return self.EMAIL_SUBJECT_FR if language == "fr" else self.EMAIL_SUBJECT_EN def create(self, request): + current_profile = request.user.iaso_profile + current_account = current_profile.account + username = request.data.get("user_name") password = request.data.get("password", "") send_email_invitation = request.data.get("send_email_invitation") if not username: return JsonResponse({"errorKey": "user_name", "errorMessage": _("Nom d'utilisateur requis")}, status=400) + else: + existing_user = User.objects.filter(username__iexact=username) + if existing_user: + return JsonResponse( + {"errorKey": "user_name", "errorMessage": _("Nom d'utilisateur existant")}, status=400 + ) if not password and not send_email_invitation: return JsonResponse({"errorKey": "password", "errorMessage": _("Mot de passe requis")}, status=400) - existing_user = User.objects.filter(username__iexact=username) - if existing_user: - return JsonResponse({"errorKey": "user_name", "errorMessage": _("Nom d'utilisateur existant")}, status=400) + main_user = None + try: + existing_user = User.objects.get(username__iexact=username) + user_in_same_account = False + try: + if existing_user.iaso_profile and existing_user.iaso_profile.account == current_account: + user_in_same_account = True + except User.iaso_profile.RelatedObjectDoesNotExist: + # User doesn't have an iaso_profile, so they're not in the same account + pass + + if user_in_same_account: + return JsonResponse( + {"errorKey": "user_name", "errorMessage": _("Nom d'utilisateur existant")}, status=400 + ) + else: + # TODO: invitation + # TODO what if already main user? + old_username = username + username = f"{username}_{current_account.name.lower().replace(' ', '_')}" + + # duplicate existing_user into main user and account user + main_user = copy.copy(existing_user) + + existing_user.username = f"{old_username}_{'unknown' if not hasattr(existing_user, 'iaso_profile') else existing_user.iaso_profile.account.name.lower().replace(' ', '_')}" + existing_user.set_unusable_password() + existing_user.save() + + main_user.pk = None + main_user.save() + + TenantUser.objects.create(main_user=main_user, account_user=existing_user) + except User.DoesNotExist: + pass # no existing user, simply create a new user user = User() user.first_name = request.data.get("first_name", "") @@ -678,14 +726,15 @@ def create(self, request): user.email = request.data.get("email", "") permissions = request.data.get("user_permissions", []) - current_profile = request.user.iaso_profile - current_account = current_profile.account - modules_permissions = self.module_permissions(current_account) if password != "": user.set_password(password) user.save() + + if main_user: + TenantUser.objects.create(main_user=main_user, account_user=user) + for permission_codename in permissions: if permission_codename in modules_permissions: permission = get_object_or_404(Permission, codename=permission_codename) @@ -695,7 +744,6 @@ def create(self, request): # Create an Iaso profile for the new user and attach it to the same account # as the currently authenticated user - current_profile = request.user.iaso_profile user.profile = Profile.objects.create( user=user, account=current_account, @@ -715,7 +763,7 @@ def create(self, request): user_roles = request.data.get("user_roles", []) for user_role_id in user_roles: # Get only a user role linked to the account's user - user_role_item = get_object_or_404(UserRole, pk=user_role_id, account=current_profile.account) + user_role_item = get_object_or_404(UserRole, pk=user_role_id, account=current_account) user_group_item = get_object_or_404(models.Group, pk=user_role_item.group.id) profile.user.groups.add(user_group_item) profile.user_roles.add(user_role_item) diff --git a/iaso/api/user_roles.py b/iaso/api/user_roles.py index 039605fdb5..181009df5a 100644 --- a/iaso/api/user_roles.py +++ b/iaso/api/user_roles.py @@ -1,13 +1,13 @@ -from typing import Any -from django.conf import settings -from django.shortcuts import get_object_or_404 -from rest_framework.response import Response from rest_framework import permissions, serializers, status +from rest_framework.response import Response + from django.contrib.auth.models import Permission, Group from django.db.models import Q, QuerySet -from iaso.models import UserRole +from django.shortcuts import get_object_or_404 + from .common import TimestampField, ModelViewSet from hat.menupermissions import models as permission +from iaso.models import Project, OrgUnitType, UserRole class HasUserRolePermission(permissions.BasePermission): @@ -26,42 +26,38 @@ class Meta: class UserRoleSerializer(serializers.ModelSerializer): permissions = serializers.SerializerMethodField("get_permissions") name = serializers.CharField(source="group.name") + created_at = TimestampField(read_only=True) + updated_at = TimestampField(read_only=True) + editable_org_unit_type_ids = serializers.PrimaryKeyRelatedField( + source="editable_org_unit_types", + queryset=OrgUnitType.objects.all(), + required=False, + allow_null=True, + many=True, + ) class Meta: model = UserRole - fields = ["id", "name", "permissions", "created_at", "updated_at"] + fields = ["id", "name", "permissions", "editable_org_unit_type_ids", "created_at", "updated_at"] def to_representation(self, instance): user_role = super().to_representation(instance) account_id = user_role["name"].split("_")[0] - user_role["name"] = self.remove_prefix_from_str(user_role["name"], account_id + "_") + user_role["name"] = user_role["name"].removeprefix(f"{account_id}_") return user_role - created_at = TimestampField(read_only=True) - updated_at = TimestampField(read_only=True) - - # This method will remove a given prefix from a string - def remove_prefix_from_str(self, str, prefix): - if str.startswith(prefix): - return str[len(prefix) :] - return str - def get_permissions(self, obj): return [permission["codename"] for permission in PermissionSerializer(obj.group.permissions, many=True).data] def create(self, validated_data): - account = self.context["request"].user.iaso_profile.account request = self.context["request"] + account = request.user.iaso_profile.account group_name = str(account.id) + "_" + request.data.get("name") permissions = request.data.get("permissions", []) - - # check if the user role name has been given - if not group_name: - raise serializers.ValidationError({"name": "User role name is required"}) + editable_org_unit_types = validated_data.get("editable_org_unit_types", []) # check if a user role with the same name already exists - group_exists = Group.objects.filter(name__iexact=group_name) - if group_exists: + if Group.objects.filter(name__iexact=group_name).exists(): raise serializers.ValidationError({"name": "User role already exists"}) group = Group(name=group_name) @@ -73,21 +69,20 @@ def create(self, validated_data): group.permissions.add(permission) group.save() - userRole = UserRole.objects.create(group=group, account=account) - userRole.save() - return userRole + user_role = UserRole.objects.create(group=group, account=account) + user_role.save() + user_role.editable_org_unit_types.set(editable_org_unit_types) + return user_role def update(self, user_role, validated_data): - account = self.context["request"].user.iaso_profile.account - group_name = str(account.id) + "_" + self.context["request"].data.get("name", None) - permissions = self.context["request"].data.get("permissions", None) + request = self.context["request"] + account = request.user.iaso_profile.account group = user_role.group + group_name = str(account.id) + "_" + validated_data.get("group", {}).get("name") + permissions = request.data.get("permissions", None) + editable_org_unit_types = validated_data.get("editable_org_unit_types", []) - if group_name is not None: - group.name = group_name - # check if a user role with the same name already exists other than the current user role - group_exists = Group.objects.filter(~Q(pk=group.id), name__iexact=group_name) - if group_exists: + if Group.objects.filter(~Q(pk=group.id), name__iexact=group_name).exists(): raise serializers.ValidationError({"name": "User role already exists"}) if permissions is not None: @@ -98,8 +93,19 @@ def update(self, user_role, validated_data): group.save() user_role.save() + user_role.editable_org_unit_types.set(editable_org_unit_types) return user_role + def validate_editable_org_unit_type_ids(self, editable_org_unit_types) -> QuerySet[OrgUnitType]: + account = self.context.get("request").user.iaso_profile.account + project_org_unit_types = set(Project.objects.filter(account=account).values_list("unit_types__id", flat=True)) + for org_unit_type in editable_org_unit_types: + if org_unit_type.pk not in project_org_unit_types: + raise serializers.ValidationError( + f"`{org_unit_type.name} ({org_unit_type.pk})` is not a valid Org Unit Type for this account." + ) + return editable_org_unit_types + class UserRolesViewSet(ModelViewSet): f"""Roles API @@ -120,7 +126,9 @@ class UserRolesViewSet(ModelViewSet): def get_queryset(self) -> QuerySet[UserRole]: user = self.request.user - queryset = UserRole.objects.filter(account=user.iaso_profile.account) # type: ignore + queryset = UserRole.objects.filter(account=user.iaso_profile.account).prefetch_related( + "group__permissions", "editable_org_unit_types" + ) search = self.request.GET.get("search", None) orders = self.request.GET.get("order", "group__name").split(",") if search: diff --git a/iaso/migrations/0295_tenantuser_tenantuser_main_user_user_constraint.py b/iaso/migrations/0295_tenantuser_tenantuser_main_user_user_constraint.py new file mode 100644 index 0000000000..7846f007fb --- /dev/null +++ b/iaso/migrations/0295_tenantuser_tenantuser_main_user_user_constraint.py @@ -0,0 +1,57 @@ +# Generated by Django 4.2.14 on 2024-08-19 07:54 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("iaso", "0294_project_redirection_url"), + ] + + operations = [ + migrations.CreateModel( + name="TenantUser", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "account_user", + models.OneToOneField( + on_delete=django.db.models.deletion.PROTECT, + related_name="tenant_user", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "main_user", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="tenant_users", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "indexes": [ + models.Index(fields=["created_at"], name="iaso_tenant_created_003e12_idx"), + models.Index(fields=["updated_at"], name="iaso_tenant_updated_31049b_idx"), + ], + }, + ), + migrations.AddConstraint( + model_name="tenantuser", + constraint=models.UniqueConstraint(fields=("main_user", "account_user"), name="main_user_user_constraint"), + ), + ] diff --git a/iaso/migrations/0300_merge_20240916_1441.py b/iaso/migrations/0300_merge_20240916_1441.py new file mode 100644 index 0000000000..de1ebf25c8 --- /dev/null +++ b/iaso/migrations/0300_merge_20240916_1441.py @@ -0,0 +1,12 @@ +# Generated by Django 4.2.14 on 2024-09-16 14:41 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("iaso", "0295_tenantuser_tenantuser_main_user_user_constraint"), + ("iaso", "0299_merge_0297_entity_merged_to_0298_profile_organization"), + ] + + operations = [] diff --git a/iaso/migrations/0304_auto_20241015_1017.py b/iaso/migrations/0304_auto_20241015_1017.py new file mode 100644 index 0000000000..74acbfe92c --- /dev/null +++ b/iaso/migrations/0304_auto_20241015_1017.py @@ -0,0 +1,72 @@ +from django.db import migrations + + +# region IA-3550 +def remove_org_unit_change_request_feature_flag(apps, schema_editor): + FeatureFlag = apps.get_model("iaso", "FeatureFlag") + FeatureFlag.objects.get(code="ORG_UNIT_CHANGE_REQUEST").delete() + + +def re_add_org_unit_change_request_feature_flag(apps, schema_editor): + FeatureFlag = apps.get_model("iaso", "FeatureFlag") + FeatureFlag.objects.create(code="ORG_UNIT_CHANGE_REQUEST", name="Request changes to org units.") + + +# endregion + + +# region IA-3553 +def merge_require_authentication_flags(apps, schema_editor): + FeatureFlag = apps.get_model("iaso", "FeatureFlag") + Project = apps.get_model("iaso", "Project") + try: + needs_authentication = FeatureFlag.objects.get(code="NEEDS_AUTHENTICATION") + require_authentication = FeatureFlag.objects.get(code="REQUIRE_AUTHENTICATION") + for project in Project.objects.filter(feature_flags__code="NEEDS_AUTHENTICATION"): + project.feature_flags.remove(needs_authentication) + project.feature_flags.add(require_authentication) + project.save() + needs_authentication.delete() + except FeatureFlag.DoesNotExist: + pass + + +def re_add_authentication_flag(apps, schema_editor): + FeatureFlag = apps.get_model("iaso", "FeatureFlag") + FeatureFlag.objects.create(code="NEEDS_AUTHENTICATION", name="Mobile: Enforce authentication") + + +# endregion + + +# region IA-3556 +def make_change_request_require_authentication(apps, schema_editor): + FeatureFlag = apps.get_model("iaso", "FeatureFlag") + ff = FeatureFlag.objects.get(code="MOBILE_ORG_UNIT_REGISTRY") + ff.requires_authentication = True + ff.save() + + +def unmake_change_request_require_authentication(apps, schema_editor): + FeatureFlag = apps.get_model("iaso", "FeatureFlag") + ff = FeatureFlag.objects.get(code="MOBILE_ORG_UNIT_REGISTRY") + ff.requires_authentication = False + ff.save() + + +# endregion + + +class Migration(migrations.Migration): + dependencies = [ + ("iaso", "0303_merge_20241003_1256"), + ] + + operations = [ + # IA-3550 + migrations.RunPython(remove_org_unit_change_request_feature_flag, re_add_org_unit_change_request_feature_flag), + # IA-3553 + migrations.RunPython(merge_require_authentication_flags, re_add_authentication_flag), + # IA-3556 + migrations.RunPython(make_change_request_require_authentication, unmake_change_request_require_authentication), + ] diff --git a/iaso/migrations/0304_merge_20241009_1240.py b/iaso/migrations/0304_merge_20241009_1240.py new file mode 100644 index 0000000000..37fdd81209 --- /dev/null +++ b/iaso/migrations/0304_merge_20241009_1240.py @@ -0,0 +1,12 @@ +# Generated by Django 4.2.14 on 2024-10-09 12:40 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("iaso", "0300_merge_20240916_1441"), + ("iaso", "0303_merge_20241003_1256"), + ] + + operations = [] diff --git a/iaso/migrations/0305_userrole_editable_org_unit_types.py b/iaso/migrations/0305_userrole_editable_org_unit_types.py new file mode 100644 index 0000000000..b17617a8c7 --- /dev/null +++ b/iaso/migrations/0305_userrole_editable_org_unit_types.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.16 on 2024-10-16 13:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("iaso", "0304_profile_org_unit_types"), + ] + + operations = [ + migrations.AddField( + model_name="userrole", + name="editable_org_unit_types", + field=models.ManyToManyField(blank=True, related_name="editable_by_user_role_set", to="iaso.orgunittype"), + ), + ] diff --git a/iaso/migrations/0306_merge_20241024_0902.py b/iaso/migrations/0306_merge_20241024_0902.py new file mode 100644 index 0000000000..5f5be018ec --- /dev/null +++ b/iaso/migrations/0306_merge_20241024_0902.py @@ -0,0 +1,12 @@ +# Generated by Django 4.2.16 on 2024-10-24 09:02 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("iaso", "0304_auto_20241015_1017"), + ("iaso", "0305_userrole_editable_org_unit_types"), + ] + + operations = [] diff --git a/iaso/migrations/0307_merge_20241024_1145.py b/iaso/migrations/0307_merge_20241024_1145.py new file mode 100644 index 0000000000..a3cc0438d6 --- /dev/null +++ b/iaso/migrations/0307_merge_20241024_1145.py @@ -0,0 +1,12 @@ +# Generated by Django 4.2.14 on 2024-10-24 11:45 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("iaso", "0304_merge_20241009_1240"), + ("iaso", "0306_merge_20241024_0902"), + ] + + operations = [] diff --git a/iaso/models/__init__.py b/iaso/models/__init__.py index 83d8441891..7966341e52 100644 --- a/iaso/models/__init__.py +++ b/iaso/models/__init__.py @@ -15,3 +15,4 @@ from .deduplication import EntityDuplicateAnalyzis, EntityDuplicate from .microplanning import Planning, Team from .payments import Payment, PotentialPayment, PaymentLot +from .tenant_users import TenantUser diff --git a/iaso/models/base.py b/iaso/models/base.py index 668cfd90a3..c3f9bdcc6f 100644 --- a/iaso/models/base.py +++ b/iaso/models/base.py @@ -1,5 +1,3 @@ -import datetime -import mimetypes import operator import random import re @@ -15,7 +13,6 @@ from bs4 import BeautifulSoup as Soup # type: ignore from django import forms as dj_forms from django.contrib import auth -from django.contrib.auth import models as authModels from django.contrib.auth.models import AnonymousUser, User from django.contrib.gis.db.models.fields import PointField from django.contrib.gis.geos import Point @@ -33,7 +30,6 @@ from phonenumber_field.modelfields import PhoneNumberField from phonenumbers.phonenumberutil import region_code_for_number -from hat.audit.models import INSTANCE_API, log_modification from hat.menupermissions.constants import MODULES from iaso.models.data_source import DataSource, SourceVersion from iaso.models.org_unit import OrgUnit, OrgUnitReferenceInstance @@ -43,7 +39,6 @@ from .. import periods from ..utils.emoji import fix_emoji from ..utils.jsonlogic import jsonlogic_to_q -from ..utils.models.common import get_creator_name from .device import Device, DeviceOwnership from .forms import Form, FormVersion from .project import Project @@ -1410,6 +1405,21 @@ def as_dict(self): } +class ProfileQuerySet(models.QuerySet): + def with_editable_org_unit_types(self): + qs = self + return qs.annotate( + annotated_editable_org_unit_types_ids=ArrayAgg( + "editable_org_unit_types__id", distinct=True, filter=Q(editable_org_unit_types__isnull=False) + ), + annotated_user_roles_editable_org_unit_type_ids=ArrayAgg( + "user_roles__editable_org_unit_types__id", + distinct=True, + filter=Q(user_roles__editable_org_unit_types__isnull=False), + ), + ) + + class Profile(models.Model): account = models.ForeignKey(Account, on_delete=models.CASCADE) user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="iaso_profile") @@ -1429,12 +1439,24 @@ class Profile(models.Model): "OrgUnitType", related_name="editable_by_iaso_profile_set", blank=True ) + objects = models.Manager.from_queryset(ProfileQuerySet)() + class Meta: constraints = [models.UniqueConstraint(fields=["dhis2_id", "account"], name="dhis2_id_constraint")] def __str__(self): return "%s -- %s" % (self.user, self.account) + def get_user_roles_editable_org_unit_type_ids(self): + try: + return self.annotated_user_roles_editable_org_unit_type_ids + except AttributeError: + return list( + self.user_roles.values_list("editable_org_unit_types__id", flat=True) + .distinct("id") + .exclude(editable_org_unit_types__id__isnull=True) + ) + def as_dict(self, small=False): user_roles = self.user_roles.all() user_group_permissions = list( @@ -1445,66 +1467,77 @@ def as_dict(self, small=False): ) all_permissions = user_group_permissions + user_permissions permissions = list(set(all_permissions)) - if not small: - return { - "id": self.id, - "first_name": self.user.first_name, - "user_name": self.user.username, - "last_name": self.user.last_name, - "email": self.user.email, - "account": self.account.as_small_dict(), - "permissions": permissions, - "user_permissions": user_permissions, - "is_superuser": self.user.is_superuser, - "org_units": [o.as_small_dict() for o in self.org_units.all().order_by("name")], - "user_roles": list(role.id for role in user_roles), - "user_roles_permissions": list(role.as_dict() for role in user_roles), - "language": self.language, - "organization": self.organization, - "user_id": self.user.id, - "dhis2_id": self.dhis2_id, - "home_page": self.home_page, - "phone_number": self.phone_number.as_e164 if self.phone_number else None, - "country_code": region_code_for_number(self.phone_number).lower() if self.phone_number else None, - "projects": [p.as_dict() for p in self.projects.all().order_by("name")], - "editable_org_unit_type_ids": list(self.editable_org_unit_types.values_list("id", flat=True)), + try: + editable_org_unit_type_ids = self.annotated_editable_org_unit_types_ids + except AttributeError: + editable_org_unit_type_ids = [out.pk for out in self.editable_org_unit_types.all()] + + user_roles_editable_org_unit_type_ids = self.get_user_roles_editable_org_unit_type_ids() + + other_accounts = [] + user_infos = self.user + if hasattr(self.user, "tenant_user"): + other_accounts = self.user.tenant_user.get_other_accounts() + user_infos = self.user.tenant_user.main_user + + result = { + "id": self.id, + "first_name": user_infos.first_name, + "user_name": user_infos.username, + "last_name": user_infos.last_name, + "email": user_infos.email, + "permissions": permissions, + "user_permissions": user_permissions, + "is_superuser": self.user.is_superuser, + "user_roles": list(role.id for role in user_roles), + "user_roles_permissions": list(role.as_dict() for role in user_roles), + "language": self.language, + "organization": self.organization, + "user_id": self.user.id, + "dhis2_id": self.dhis2_id, + "home_page": self.home_page, + "phone_number": self.phone_number.as_e164 if self.phone_number else None, + "country_code": region_code_for_number(self.phone_number).lower() if self.phone_number else None, + "projects": [p.as_dict() for p in self.projects.all().order_by("name")], + "other_accounts": [account.as_dict() for account in other_accounts], + "editable_org_unit_type_ids": editable_org_unit_type_ids, + "user_roles_editable_org_unit_type_ids": user_roles_editable_org_unit_type_ids, + } + + if small: + return result | { + "org_units": [o.as_very_small_dict() for o in self.org_units.all()], } else: - return { - "id": self.id, - "first_name": self.user.first_name, - "user_name": self.user.username, - "last_name": self.user.last_name, - "email": self.user.email, - "permissions": permissions, - "user_permissions": user_permissions, - "is_superuser": self.user.is_superuser, - "org_units": [o.as_very_small_dict() for o in self.org_units.all()], - "user_roles": list(role.id for role in user_roles), - "user_roles_permissions": list(role.as_dict() for role in user_roles), - "language": self.language, - "user_id": self.user.id, - "dhis2_id": self.dhis2_id, - "home_page": self.home_page, - "organization": self.organization, - "phone_number": self.phone_number.as_e164 if self.phone_number else None, - "country_code": region_code_for_number(self.phone_number).lower() if self.phone_number else None, - "projects": [p.as_dict() for p in self.projects.all()], - "editable_org_unit_type_ids": list(self.editable_org_unit_types.values_list("id", flat=True)), + return result | { + "account": self.account.as_small_dict(), + "org_units": [o.as_small_dict() for o in self.org_units.all().order_by("name")], } def as_short_dict(self): + try: + editable_org_unit_type_ids = self.annotated_editable_org_unit_types_ids + except AttributeError: + editable_org_unit_type_ids = [out.pk for out in self.editable_org_unit_types.all()] + + user_roles_editable_org_unit_type_ids = self.get_user_roles_editable_org_unit_type_ids() + + user_infos = self.user + if hasattr(self.user, "tenant_user") and self.user.tenant_user: + user_infos = self.user.tenant_user.main_user + return { "id": self.id, - "first_name": self.user.first_name, - "user_name": self.user.username, - "last_name": self.user.last_name, - "email": self.user.email, + "first_name": user_infos.first_name, + "user_name": user_infos.username, + "last_name": user_infos.last_name, + "email": user_infos.email, "language": self.language, "user_id": self.user.id, "phone_number": self.phone_number.as_e164 if self.phone_number else None, "country_code": region_code_for_number(self.phone_number).lower() if self.phone_number else None, - "editable_org_unit_type_ids": list(self.editable_org_unit_types.values_list("id", flat=True)), + "editable_org_unit_type_ids": editable_org_unit_type_ids, + "user_roles_editable_org_unit_type_ids": user_roles_editable_org_unit_type_ids, } def has_a_team(self): @@ -1513,6 +1546,19 @@ def has_a_team(self): return True return False + def get_editable_org_unit_type_ids(self) -> set[int]: + ids_in_user_roles = set(self.user_roles.values_list("editable_org_unit_types", flat=True)) + ids_in_user_profile = set(self.editable_org_unit_types.values_list("id", flat=True)) + return ids_in_user_profile.union(ids_in_user_roles) + + def has_org_unit_write_permission( + self, org_unit_type_id: int, prefetched_editable_org_unit_type_ids: set[int] = None + ) -> bool: + editable_org_unit_type_ids = prefetched_editable_org_unit_type_ids or self.get_editable_org_unit_type_ids() + if not editable_org_unit_type_ids: + return True + return org_unit_type_id in editable_org_unit_type_ids + class ExportRequest(models.Model): id = models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID") @@ -1669,6 +1715,11 @@ class UserRole(models.Model): group = models.OneToOneField(auth.models.Group, on_delete=models.CASCADE, related_name="iaso_user_role") created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + # Each user can have restricted write access to OrgUnits, based on their type. + # By default, empty `editable_org_unit_types` means access to everything. + editable_org_unit_types = models.ManyToManyField( + "OrgUnitType", related_name="editable_by_user_role_set", blank=True + ) def __str__(self) -> str: return self.group.name diff --git a/iaso/models/tenant_users.py b/iaso/models/tenant_users.py new file mode 100644 index 0000000000..a753d61fc9 --- /dev/null +++ b/iaso/models/tenant_users.py @@ -0,0 +1,53 @@ +from django.contrib.auth.models import User +from django.db import models + + +class TenantUser(models.Model): + main_user = models.ForeignKey(User, on_delete=models.PROTECT, related_name="tenant_users") + account_user = models.OneToOneField(User, on_delete=models.PROTECT, related_name="tenant_user") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=["main_user", "account_user"], name="main_user_user_constraint"), + ] + indexes = [ + models.Index(fields=["created_at"]), + models.Index(fields=["updated_at"]), + ] + + @property + def account(self): + try: + return self.account_user.iaso_profile.account if self.account_user.iaso_profile else None + except User.iaso_profile.RelatedObjectDoesNotExist: + return None + + def get_all_account_users(self): + return [tu.account_user for tu in self.main_user.tenant_users.all()] + + def get_other_accounts(self): + return [tu.account for tu in self.main_user.tenant_users.exclude(pk=self.pk) if tu.account] + + def __str__(self): + account_name = "Unknown" + try: + if self.account_user.iaso_profile: + account_name = self.account_user.iaso_profile.account + except User.iaso_profile.RelatedObjectDoesNotExist: + pass + return f"{self.main_user} -- {self.account_user} ({account_name})" + + def as_dict(self): + account_dict = None + try: + if self.account_user.iaso_profile: + account_dict = self.account_user.iaso_profile.account.as_dict() + except User.iaso_profile.RelatedObjectDoesNotExist: + pass + return { + "id": self.id, + "main_user_id": self.main_user_id, + "account": account_dict, + } diff --git a/iaso/serializers.py b/iaso/serializers.py new file mode 100644 index 0000000000..5a8a4ae9dc --- /dev/null +++ b/iaso/serializers.py @@ -0,0 +1,45 @@ +from django.contrib.auth.models import User + +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer +from rest_framework_simplejwt.exceptions import AuthenticationFailed + +from iaso.models import Project + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + def validate(self, attrs): + """ + Override this method to be able to handle multi-account users. + + If the user is a multi-account user, we return a token for the correct + "account user" (based on the `app_id` query params) instead of the + main user that was used for logging in. + """ + data = super().validate(attrs) + + err_msg = "No active account found with the given credentials" + + if self.user.tenant_users.exists(): + request = self.context.get("request") + if request: + app_id = request.query_params.get("app_id", None) + + try: + project = Project.objects.get(app_id=app_id) + except Project.DoesNotExist: + raise AuthenticationFailed(err_msg) + + account_user = User.objects.filter( + tenant_user__main_user=self.user, + iaso_profile__account=project.account, + ).first() + + if account_user is None: + raise AuthenticationFailed(err_msg) + + refresh = self.get_token(account_user) + + data["refresh"] = str(refresh) + data["access"] = str(refresh.access_token) + + return data diff --git a/iaso/tasks/org_units_bulk_update.py b/iaso/tasks/org_units_bulk_update.py index 781fd42c1a..6598e21b0d 100644 --- a/iaso/tasks/org_units_bulk_update.py +++ b/iaso/tasks/org_units_bulk_update.py @@ -81,10 +81,25 @@ def org_units_bulk_update( raise Exception("Modification on read only source are not allowed") total = queryset.count() + editable_org_unit_type_ids = user.iaso_profile.get_editable_org_unit_type_ids() + skipped_messages = [] # FIXME Task don't handle rollback properly if task is killed by user or other error with transaction.atomic(): for index, org_unit in enumerate(queryset.iterator()): + if org_unit.org_unit_type and not user.iaso_profile.has_org_unit_write_permission( + org_unit_type_id=org_unit.org_unit_type.pk, + prefetched_editable_org_unit_type_ids=editable_org_unit_type_ids, + ): + skipped_messages.append( + ( + f"Org unit `{org_unit.name}` (#{org_unit.pk}) silently skipped " + f"because user `{user.username}` (#{user.pk}) cannot edit " + f"an org unit of type `{org_unit.org_unit_type.name}` (#{org_unit.org_unit_type.pk})." + ) + ) + continue + res_string = "%.2f sec, processed %i org units" % (time() - start, index) task.report_progress_and_stop_if_killed(progress_message=res_string, end_value=total, progress_value=index) update_single_unit_from_bulk( @@ -96,4 +111,10 @@ def org_units_bulk_update( groups_ids_removed=groups_ids_removed, ) - task.report_success(message="%d modified" % total) + message = f"{total} modified" + if skipped_messages: + message = f"{total - len(skipped_messages)} modified\n" + message += f"{len(skipped_messages)} skipped\n" + message += "\n".join(skipped_messages) + + task.report_success(message=message) diff --git a/iaso/tests/api/org_unit_change_request_configurations/test_org_unit_change_request_configs_mobile.py b/iaso/tests/api/org_unit_change_request_configurations/test_org_unit_change_request_configs_mobile.py index dba5815b7b..06ba38e56e 100644 --- a/iaso/tests/api/org_unit_change_request_configurations/test_org_unit_change_request_configs_mobile.py +++ b/iaso/tests/api/org_unit_change_request_configurations/test_org_unit_change_request_configs_mobile.py @@ -1,5 +1,8 @@ from rest_framework import status +from django.contrib.auth.models import Group + +from iaso import models as m from iaso.tests.api.org_unit_change_request_configurations.common_base_with_setup import OUCRCAPIBase @@ -12,19 +15,143 @@ class MobileOrgUnitChangeRequestConfigurationAPITestCase(OUCRCAPIBase): def test_list_ok(self): self.client.force_authenticate(self.user_ash_ketchum) - with self.assertNumQueries(7): + with self.assertNumQueries(9): # get_queryset - # 1. COUNT(*) OrgUnitChangeRequestConfiguration - # 2. SELECT OrgUnitChangeRequestConfiguration - # 3. PREFETCH OrgUnitChangeRequestConfiguration.possible_types - # 4. PREFETCH OrgUnitChangeRequestConfiguration.possible_parent_types - # 5. PREFETCH OrgUnitChangeRequestConfiguration.group_sets - # 6. PREFETCH OrgUnitChangeRequestConfiguration.editable_reference_forms - # 7. PREFETCH OrgUnitChangeRequestConfiguration.other_groups + # 1. SELECT user_editable_org_unit_type_ids + # 2. SELECT user_roles_editable_org_unit_type_ids + # 3. COUNT(*) OrgUnitChangeRequestConfiguration + # 4. SELECT OrgUnitChangeRequestConfiguration + # 5. PREFETCH OrgUnitChangeRequestConfiguration.possible_types + # 6. PREFETCH OrgUnitChangeRequestConfiguration.possible_parent_types + # 7. PREFETCH OrgUnitChangeRequestConfiguration.group_sets + # 8. PREFETCH OrgUnitChangeRequestConfiguration.editable_reference_forms + # 9. PREFETCH OrgUnitChangeRequestConfiguration.other_groups response = self.client.get(f"{self.MOBILE_OUCRC_API_URL}?app_id={self.app_id}") self.assertJSONResponse(response, status.HTTP_200_OK) self.assertEqual(3, len(response.data["results"])) # the 3 OUCRCs from setup + def test_list_ok_with_restricted_write_permission_for_user(self): + # Add new Org Unit Types. + new_org_unit_type_1 = m.OrgUnitType.objects.create(name="Hospital") + new_org_unit_type_1.projects.add(self.project_johto) + new_org_unit_type_2 = m.OrgUnitType.objects.create(name="Health facility") + new_org_unit_type_2.projects.add(self.project_johto) + new_org_unit_type_3 = m.OrgUnitType.objects.create(name="District") + new_org_unit_type_3.projects.add(self.project_johto) + self.assertEqual(self.project_johto.unit_types.count(), 6) + + # Restrict write permissions on Org Units at the "Profile" level. + self.user_ash_ketchum.iaso_profile.editable_org_unit_types.set( + # Only org units of this type are now writable for this user. + [self.ou_type_fire_pokemons] + ) + + # Restrict write permissions on Org Units at the "Role" level. + group = Group.objects.create(name="Group") + user_role = m.UserRole.objects.create(group=group, account=self.account_pokemon) + user_role.editable_org_unit_types.set( + # Only org units of this type are now writable for this user. + [new_org_unit_type_3] + ) + self.user_ash_ketchum.iaso_profile.user_roles.set([user_role]) + + self.client.force_authenticate(self.user_ash_ketchum) + + with self.assertNumQueries(9): + response = self.client.get(f"{self.MOBILE_OUCRC_API_URL}?app_id={self.app_id}") + self.assertJSONResponse(response, status.HTTP_200_OK) + + results = response.data["results"] + self.assertEqual(5, len(results)) + + # `new_org_unit_type_3` should not be in the response because the user + # have full write permission on it: + # - the user has write access on `new_org_unit_type_3` at his Profile level + # - there is no existing configuration for `new_org_unit_type_3` + configs_org_unit_type_ids = [config["org_unit_type_id"] for config in results] + self.assertNotIn(new_org_unit_type_3.pk, configs_org_unit_type_ids) + + self.assertEqual( + results, + [ + # The user has write access on `ou_type_fire_pokemons` at his Profile level, + # and there is an existing configuration for `ou_type_fire_pokemons`, so we + # return the configuration. + { + "org_unit_type_id": self.ou_type_fire_pokemons.pk, + "org_units_editable": True, + "editable_fields": ["name", "aliases", "location", "opening_date", "closing_date"], + "possible_type_ids": list(self.oucrc_type_fire.possible_types.values_list("id", flat=True)), + "possible_parent_type_ids": list( + self.oucrc_type_fire.possible_parent_types.values_list("id", flat=True) + ), + "group_set_ids": list(self.oucrc_type_fire.group_sets.values_list("id", flat=True)), + "editable_reference_form_ids": list( + self.oucrc_type_fire.editable_reference_forms.values_list("id", flat=True) + ), + "other_group_ids": list(self.oucrc_type_fire.other_groups.values_list("id", flat=True)), + "created_at": self.oucrc_type_fire.created_at.timestamp(), + "updated_at": self.oucrc_type_fire.updated_at.timestamp(), + }, + # Because of the configuration of his Profile, the user can't write on `ou_type_rock_pokemons`, + # so we override the existing configuration. + { + "org_unit_type_id": self.ou_type_rock_pokemons.pk, + "org_units_editable": False, + "editable_fields": [], + "possible_type_ids": [], + "possible_parent_type_ids": [], + "group_set_ids": [], + "editable_reference_form_ids": [], + "other_group_ids": [], + "created_at": None, + "updated_at": None, + }, + # Because of the configuration of his Profile, the user can't write on `ou_type_water_pokemons`, + # so we override the existing configuration. + { + "org_unit_type_id": self.ou_type_water_pokemons.pk, + "org_units_editable": False, + "editable_fields": [], + "possible_type_ids": [], + "possible_parent_type_ids": [], + "group_set_ids": [], + "editable_reference_form_ids": [], + "other_group_ids": [], + "created_at": None, + "updated_at": None, + }, + # Because of the configuration of his Profile, the user can't write on `new_org_unit_type_1`, + # and since there is no existing configuration, we add a dynamic one. + { + "org_unit_type_id": new_org_unit_type_1.pk, + "org_units_editable": False, + "editable_fields": [], + "possible_type_ids": [], + "possible_parent_type_ids": [], + "group_set_ids": [], + "editable_reference_form_ids": [], + "other_group_ids": [], + "created_at": None, + "updated_at": None, + }, + # Because of the configuration of his Profile, the user can't write on `new_org_unit_type_2`, + # and since there is no existing configuration, we add a dynamic one. + { + "org_unit_type_id": new_org_unit_type_2.pk, + "org_units_editable": False, + "editable_fields": [], + "possible_type_ids": [], + "possible_parent_type_ids": [], + "group_set_ids": [], + "editable_reference_form_ids": [], + "other_group_ids": [], + "created_at": None, + "updated_at": None, + }, + ], + ) + def test_list_without_auth(self): response = self.client.get(f"{self.MOBILE_OUCRC_API_URL}?app_id={self.app_id}") self.assertJSONResponse(response, status.HTTP_401_UNAUTHORIZED) diff --git a/iaso/tests/api/test_account.py b/iaso/tests/api/test_account.py index 0d87ba6f25..5b6efecbfa 100644 --- a/iaso/tests/api/test_account.py +++ b/iaso/tests/api/test_account.py @@ -24,35 +24,35 @@ def setUpTestData(cls): cls.wha_version = m.SourceVersion.objects.create(data_source=wha_datasource, number=1) def test_account_list_without_auth(self): - """GET /projects/ without auth should result in a 403 (before the method not authorized?)""" + """GET /account/ without auth should result in a 403 (before the method not authorized?)""" self.client.force_authenticate(self.jim) response = self.client.get("/api/accounts/") self.assertJSONResponse(response, 403) def test_account_list_with_auth(self): - """GET /projects/ with auth should result in a 405 as method is not allowed""" + """GET /account/ with auth should result in a 405 as method is not allowed""" self.client.force_authenticate(self.jane) response = self.client.get("/api/accounts/") self.assertJSONResponse(response, 405) def test_account_delete_forbidden(self): - """DELETE /projects/ with auth should result in a 405 as method is not allowed""" + """DELETE /account/ with auth should result in a 405 as method is not allowed""" self.client.force_authenticate(self.jane) response = self.client.delete("/api/accounts/") self.assertJSONResponse(response, 405) def test_account_post_forbidden(self): - """POST /projects/ with auth should result in a 405 as method is not allowed""" + """POST /account/ with auth should result in a 405 as method is not allowed""" self.client.force_authenticate(self.jane) response = self.client.post("/api/accounts/", {"default_version": self.ghi_version}) self.assertJSONResponse(response, 405) def test_account_detail_forbidden(self): - """POST /projects/ with auth should result in a 405 as method is not allowed""" + """POST /account/ with auth should result in a 405 as method is not allowed""" self.client.force_authenticate(self.jane) response = self.client.get(f"/api/accounts/{self.ghi.pk}/") diff --git a/iaso/tests/api/test_org_units_bulk_update.py b/iaso/tests/api/test_org_units_bulk_update.py index ee24bedafc..e5f193f5ed 100644 --- a/iaso/tests/api/test_org_units_bulk_update.py +++ b/iaso/tests/api/test_org_units_bulk_update.py @@ -60,7 +60,7 @@ def setUpTestData(cls): location=cls.mock_point, validation_status=m.OrgUnit.VALIDATION_VALID, ) - cls.jedi_squad_endor = m.OrgUnit.objects.create( + cls.jedi_squad_endor_1 = m.OrgUnit.objects.create( parent=cls.jedi_council_endor, org_unit_type=cls.jedi_squad, version=sw_version_1, @@ -72,7 +72,7 @@ def setUpTestData(cls): validation_status=m.OrgUnit.VALIDATION_VALID, source_ref="F9w3VW1cQmb", ) - cls.jedi_squad_endor = m.OrgUnit.objects.create( + cls.jedi_squad_endor_2 = m.OrgUnit.objects.create( parent=cls.jedi_council_endor, org_unit_type=cls.jedi_squad, version=sw_version_1, @@ -246,6 +246,47 @@ def test_org_unit_bulkupdate_select_all(self): self.assertEqual(5, am.Modification.objects.count()) + @tag("iaso_only") + def test_org_unit_bulkupdate_select_all_with_restricted_write_permission_for_user(self): + """ + Check that we cannot bulk edit all org units if writing rights are limited + by a set of org unit types that we are allowed to modify. + """ + self.yoda.iaso_profile.editable_org_unit_types.set( + # Only org units of this type are now writable. + [self.jedi_squad] + ) + self.client.force_authenticate(self.yoda) + + response = self.client.post( + f"/api/tasks/create/orgunitsbulkupdate/", + data={"select_all": True, "validation_status": m.OrgUnit.VALIDATION_REJECTED}, + format="json", + ) + self.assertJSONResponse(response, 201) + data = response.json() + task = self.assertValidTaskAndInDB(data["task"], status="QUEUED", name="org_unit_bulk_update") + self.assertEqual(task.launcher, self.yoda) + + # Run the task. + self.runAndValidateTask(task, "SUCCESS") + + self.assertEqual(2, am.Modification.objects.count()) + + task.refresh_from_db() + self.assertIn("2 modified", task.progress_message) + self.assertIn("3 skipped", task.progress_message) + + self.yoda.iaso_profile.editable_org_unit_types.clear() + + for org_unit in [self.jedi_squad_endor_1, self.jedi_squad_endor_2]: + org_unit.refresh_from_db() + self.assertEqual(org_unit.validation_status, m.OrgUnit.VALIDATION_REJECTED) + + for org_unit in [self.jedi_council_corruscant, self.jedi_council_endor, self.jedi_council_brussels]: + org_unit.refresh_from_db() + self.assertEqual(org_unit.validation_status, m.OrgUnit.VALIDATION_VALID) + @tag("iaso_only") def test_org_unit_bulkupdate_select_all_with_search(self): """POST /orgunits/bulkupdate happy path (select all, but with search)""" diff --git a/iaso/tests/api/test_orgunits.py b/iaso/tests/api/test_orgunits.py index 966b6cd26d..c8458f6a34 100644 --- a/iaso/tests/api/test_orgunits.py +++ b/iaso/tests/api/test_orgunits.py @@ -607,6 +607,22 @@ def test_create_org_unit_with_read_permission(self): response = self.set_up_org_unit_creation() self.assertJSONResponse(response, 403) + def test_create_org_unit_with_restricted_write_permission_for_user(self): + """ + Check that we cannot create an org unit if writing rights are limited + by a set of org unit types that we are allowed to modify. + """ + self.yoda.iaso_profile.editable_org_unit_types.set( + # Only org units of this type are now writable. + [self.jedi_squad] + ) + self.client.force_authenticate(self.yoda) + response = self.set_up_org_unit_creation() + json_response = self.assertJSONResponse(response, 400) + self.assertEqual(json_response[0]["errorKey"], "org_unit_type_id") + self.assertEqual(json_response[0]["errorMessage"], "You cannot create or edit an Org unit of this type") + self.yoda.iaso_profile.editable_org_unit_types.clear() + def test_create_org_unit(self): """Check that we can create org unit with only org units management permission""" self.client.force_authenticate(self.yoda) @@ -999,6 +1015,31 @@ def test_edit_org_unit_partial_update_read_permission(self): ) self.assertJSONResponse(response, 403) + def test_edit_org_unit_with_restricted_write_permission_for_user(self): + """ + Check that we cannot edit an org unit if writing rights are limited + by a set of org unit types that we are allowed to modify. + """ + org_unit = m.OrgUnit.objects.create( + name="Foo", + org_unit_type=self.jedi_council, + version=self.star_wars.default_version, + ) + self.yoda.iaso_profile.editable_org_unit_types.set( + # Only org units of this type are now writable. + [self.jedi_squad] + ) + self.client.force_authenticate(self.yoda) + response = self.client.patch( + f"/api/orgunits/{org_unit.id}/", + format="json", + data={"name": "New name"}, + ) + json_response = self.assertJSONResponse(response, 400) + self.assertEqual(json_response[0]["errorKey"], "org_unit_type_id") + self.assertEqual(json_response[0]["errorMessage"], "You cannot create or edit an Org unit of this type") + self.yoda.iaso_profile.editable_org_unit_types.clear() + def test_edit_org_unit_edit_bad_group_fail(self): """Check for a previous bug if an org unit is already member of a bad group it couldn't be edited anymore from the interface diff --git a/iaso/tests/api/test_profiles.py b/iaso/tests/api/test_profiles.py index 2c6f86333f..841e07580b 100644 --- a/iaso/tests/api/test_profiles.py +++ b/iaso/tests/api/test_profiles.py @@ -296,7 +296,7 @@ def test_profile_list_read_only_permissions(self): """GET /profiles/ with auth (user has read only permissions)""" self.client.force_authenticate(self.jane) - with self.assertNumQueries(17): + with self.assertNumQueries(12): response = self.client.get("/api/profiles/") self.assertJSONResponse(response, 200) profile_url = "/api/profiles/%s/" % self.jane.iaso_profile.id diff --git a/iaso/tests/api/test_token.py b/iaso/tests/api/test_token.py index c69dddff6a..b328f01190 100644 --- a/iaso/tests/api/test_token.py +++ b/iaso/tests/api/test_token.py @@ -1,7 +1,10 @@ +import jwt from unittest import mock +from django.contrib.auth.models import User from django.core.files import File +from hat.settings import SECRET_KEY from iaso import models as m from iaso.test import APITestCase @@ -41,8 +44,8 @@ class TokenAPITestCase(APITestCase): def setUpTestData(cls): data_source = m.DataSource.objects.create(name="counsil") version = m.SourceVersion.objects.create(data_source=data_source, number=1) - star_wars = m.Account.objects.create(name="Star Wars", default_version=version) - cls.yoda = cls.create_user_with_profile(username="yoda", account=star_wars) + cls.default_account = m.Account.objects.create(name="Star Wars", default_version=version) + cls.yoda = cls.create_user_with_profile(username="yoda", account=cls.default_account) cls.yoda.set_password("IMomLove") cls.yoda.save() @@ -54,7 +57,7 @@ def setUpTestData(cls): cls.project = m.Project.objects.create( name="Hydroponic gardens", app_id="stars.empire.agriculture.hydroponics", - account=star_wars, + account=cls.default_account, needs_authentication=True, ) @@ -86,6 +89,9 @@ def authenticate_using_token(self): response_data = response.json() access_token = response_data.get("access") + payload = jwt.decode(access_token, SECRET_KEY, algorithms=["HS256"]) + self.assertEquals(payload["user_id"], self.yoda.id) + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {access_token}") return response_data @@ -103,6 +109,14 @@ def test_acquire_token_and_authenticate(self): self.assertTrue(self.form_2.id in form_ids) + def test_incorrect_username_or_password(self): + response = self.client.post(f"/api/token/", data={"username": "yoda", "password": "incorrect"}, format="json") + self.assertJSONResponse(response, 401) + self.assertEquals( + response.json()["detail"], + "No active account found with the given credentials", + ) + def test_acquire_token_and_post_instance(self): """Test upload to a project that requires authentication""" # Unauthenticated case is already tested in test_api @@ -272,3 +286,72 @@ def test_unauthenticated_post_org_unit(self): has_problems=True, exception_contains_string="Could not find project for user", ) + + def test_multi_account_user(self): + main_user = User.objects.create(username="main_user") + main_user.set_password("MainPass1") + main_user.save() + + account_a = self.default_account + m.Project.objects.create(app_id="account.a", account=account_a) + account_user_a = self.create_user_with_profile(username="User_A", account=account_a) + m.TenantUser.objects.create(main_user=main_user, account_user=account_user_a) + + data_source_b = m.DataSource.objects.create(name="Source B") + version_b = m.SourceVersion.objects.create(data_source=data_source_b, number=1) + account_b = m.Account.objects.create(name="Account B", default_version=version_b) + m.Project.objects.create(app_id="account.b", account=account_b) + account_user_b = self.create_user_with_profile(username="User_B", account=account_b) + m.TenantUser.objects.create(main_user=main_user, account_user=account_user_b) + + login = {"username": "main_user", "password": "MainPass1"} + + # Login with main user and app_id for Account A + response = self.client.post(f"/api/token/?app_id=account.a", data=login, format="json") + self.assertJSONResponse(response, 200) + access_token = response.json().get("access") + payload = jwt.decode(access_token, SECRET_KEY, algorithms=["HS256"]) + # returns token for account_user_a + self.assertEquals(payload["user_id"], account_user_a.id) + + # Login with main user and app_id for Account B + response = self.client.post(f"/api/token/?app_id=account.b", data=login, format="json") + self.assertJSONResponse(response, 200) + access_token = response.json().get("access") + payload = jwt.decode(access_token, SECRET_KEY, algorithms=["HS256"]) + # returns token for account_user_a + self.assertEquals(payload["user_id"], account_user_b.id) + + def test_multi_account_user_incorrect_app_id(self): + main_user = User.objects.create(username="main_user") + main_user.set_password("MainPass1") + main_user.save() + + account_a = self.default_account + m.Project.objects.create(app_id="account.a", account=account_a) + account_user_a = self.create_user_with_profile(username="User_A", account=account_a) + m.TenantUser.objects.create(main_user=main_user, account_user=account_user_a) + + # Create account B with project, but without link to main user + data_source_b = m.DataSource.objects.create(name="Source B") + version_b = m.SourceVersion.objects.create(data_source=data_source_b, number=1) + account_b = m.Account.objects.create(name="Account B", default_version=version_b) + m.Project.objects.create(app_id="account.b", account=account_b) + + login = {"username": "main_user", "password": "MainPass1"} + + # Login with main user and app_id for Account B + response = self.client.post(f"/api/token/?app_id=account.b", data=login, format="json") + self.assertJSONResponse(response, 401) + self.assertEquals( + response.json()["detail"], + "No active account found with the given credentials", + ) + + # Login with main user and non-existent app_id + response = self.client.post(f"/api/token/?app_id=account.c", data=login, format="json") + self.assertJSONResponse(response, 401) + self.assertEquals( + response.json()["detail"], + "No active account found with the given credentials", + ) diff --git a/iaso/tests/api/test_user_roles.py b/iaso/tests/api/test_user_roles.py index 9c2be70b30..804c5635c9 100644 --- a/iaso/tests/api/test_user_roles.py +++ b/iaso/tests/api/test_user_roles.py @@ -6,16 +6,21 @@ class UserRoleAPITestCase(APITestCase): @classmethod def setUpTestData(cls): - star_wars = m.Account.objects.create(name="Star Wars") - cls.star_wars = star_wars - sw_source = m.DataSource.objects.create(name="Galactic Empire") - cls.sw_source = sw_source + cls.org_unit_type = m.OrgUnitType.objects.create(name="Org unit type", short_name="OUT") + + cls.account = account = m.Account.objects.create(name="Account") + cls.project = project = m.Project.objects.create(name="Project", account=account, app_id="foo.bar.baz") + project.unit_types.set([cls.org_unit_type]) + + cls.sw_source = sw_source = m.DataSource.objects.create(name="Galactic Empire") + sw_source.projects.set([project]) + sw_version = m.SourceVersion.objects.create(data_source=sw_source, number=1) - star_wars.default_version = sw_version - star_wars.save() + account.default_version = sw_version + account.save() - cls.yoda = cls.create_user_with_profile(username="yoda", account=star_wars, permissions=["iaso_user_roles"]) - cls.user_with_no_permissions = cls.create_user_with_profile(username="userNoPermission", account=star_wars) + cls.user = cls.create_user_with_profile(username="yoda", account=account, permissions=["iaso_user_roles"]) + cls.user_with_no_permissions = cls.create_user_with_profile(username="userNoPermission", account=account) cls.permission = Permission.objects.create( name="iaso permission", content_type_id=1, codename="iaso_permission" @@ -32,31 +37,27 @@ def setUpTestData(cls): cls.permission_not_allowable = Permission.objects.create( name="admin permission", content_type_id=1, codename="admin_permission1" ) - cls.group = Group.objects.create(name=str(star_wars.id) + "user role") + cls.group = Group.objects.create(name=str(account.id) + "user role") cls.group.permissions.add(cls.permission) cls.group.refresh_from_db() - cls.userRole = m.UserRole.objects.create(group=cls.group, account=star_wars) - - # This method will remove a given prefix from a string - def remove_prefix_from_str(self, str, prefix): - if str.startswith(prefix): - return str[len(prefix) :] - return str + cls.user_role = m.UserRole.objects.create(group=cls.group, account=account) + cls.user_role.editable_org_unit_types.set([cls.org_unit_type]) def test_create_user_role(self): - self.client.force_authenticate(self.yoda) - - payload = {"name": "New user role name"} + self.client.force_authenticate(self.user) + payload = {"name": "New user role name", "editable_org_unit_type_ids": [self.org_unit_type.id]} response = self.client.post("/api/userroles/", data=payload, format="json") r = self.assertJSONResponse(response, 201) + self.assertEqual(r["name"], payload["name"]) self.assertIsNotNone(r["id"]) + self.assertEqual(r["editable_org_unit_type_ids"], [self.org_unit_type.id]) def test_create_user_role_without_name(self): - self.client.force_authenticate(self.yoda) + self.client.force_authenticate(self.user) payload = {"permissions": ["iaso_mappings"]} response = self.client.post("/api/userroles/", data=payload, format="json") @@ -64,25 +65,28 @@ def test_create_user_role_without_name(self): self.assertEqual(response.status_code, 400) def test_retrieve_user_role(self): - self.client.force_authenticate(self.yoda) + self.client.force_authenticate(self.user) - response = self.client.get(f"/api/userroles/{self.userRole.pk}/") + response = self.client.get(f"/api/userroles/{self.user_role.pk}/") r = self.assertJSONResponse(response, 200) - self.assertEqual(r["id"], self.userRole.pk) - self.userRole.refresh_from_db() - self.assertEqual(r["name"], self.remove_prefix_from_str(self.userRole.group.name, str(self.star_wars.id) + "_")) + self.assertEqual(r["id"], self.user_role.pk) + self.user_role.refresh_from_db() + expected_name = self.user_role.group.name.removeprefix(f"{self.account.id}_") + self.assertEqual(r["name"], expected_name) + self.assertEqual(r["editable_org_unit_type_ids"], [self.org_unit_type.id]) def test_retrieve_user_role_read_only(self): self.client.force_authenticate(self.user_with_no_permissions) - response = self.client.get(f"/api/userroles/{self.userRole.pk}/") + response = self.client.get(f"/api/userroles/{self.user_role.pk}/") r = self.assertJSONResponse(response, 200) - self.assertEqual(r["id"], self.userRole.pk) + self.assertEqual(r["id"], self.user_role.pk) + self.assertEqual(r["editable_org_unit_type_ids"], [self.org_unit_type.id]) def test_list_without_search(self): - self.client.force_authenticate(self.yoda) + self.client.force_authenticate(self.user) response = self.client.get("/api/userroles/") @@ -90,7 +94,7 @@ def test_list_without_search(self): self.assertEqual(len(r["results"]), 1) def test_list_with_search_on_user_role_name(self): - self.client.force_authenticate(self.yoda) + self.client.force_authenticate(self.user) payload = {"search": "user role"} response = self.client.get("/api/userroles/", data=payload, format="json") @@ -98,45 +102,78 @@ def test_list_with_search_on_user_role_name(self): r = self.assertJSONResponse(response, 200) self.assertEqual(len(r["results"]), 1) + + expected_name = self.user_role.group.name.removeprefix(f"{self.account.id}_") + self.assertEqual(r["results"][0]["name"], expected_name) self.assertEqual( - r["results"][0]["name"], self.remove_prefix_from_str(self.userRole.group.name, str(self.star_wars.id) + "_") + r["results"][0]["editable_org_unit_type_ids"], + [self.org_unit_type.id], + ) + + def test_partial_update_invalid_org_unit_type(self): + invalid_org_unit_type = m.OrgUnitType.objects.create( + name="This org unit type is not linked to the account", short_name="Invalid" + ) + + self.client.force_authenticate(self.user) + payload = { + "name": self.user_role.group.name, + "editable_org_unit_type_ids": [invalid_org_unit_type.pk], + } + + response = self.client.put(f"/api/userroles/{self.user_role.id}/", data=payload, format="json") + + r = self.assertJSONResponse(response, 400) + self.assertEqual( + r["editable_org_unit_type_ids"], + [ + f"`{invalid_org_unit_type.name} ({invalid_org_unit_type.pk})` is not a valid Org Unit Type for this account." + ], ) def test_partial_update_no_modification(self): - self.client.force_authenticate(self.yoda) + new_org_unit_type = m.OrgUnitType.objects.create(name="New org unit type", short_name="NOUT") + self.project.unit_types.add(new_org_unit_type.pk) + + self.client.force_authenticate(self.user) + payload = { + "name": self.user_role.group.name, + "editable_org_unit_type_ids": [self.org_unit_type.id, new_org_unit_type.id], + } - payload = {"name": self.userRole.group.name} - response = self.client.put(f"/api/userroles/{self.userRole.id}/", data=payload, format="json") + response = self.client.put(f"/api/userroles/{self.user_role.id}/", data=payload, format="json") r = self.assertJSONResponse(response, 200) self.assertEqual(r["name"], payload["name"]) + self.assertEqual(r["editable_org_unit_type_ids"], [self.org_unit_type.id, new_org_unit_type.id]) def test_partial_update_no_permission(self): self.client.force_authenticate(self.user_with_no_permissions) - payload = {"name": self.userRole.group.name} - response = self.client.put(f"/api/userroles/{self.userRole.id}/", data=payload, format="json") + payload = {"name": self.user_role.group.name} + response = self.client.put(f"/api/userroles/{self.user_role.id}/", data=payload, format="json") r = self.assertJSONResponse(response, 403) self.assertEqual(r["detail"], "You do not have permission to perform this action.") def test_update_name_modification(self): - self.client.force_authenticate(self.yoda) + self.client.force_authenticate(self.user) payload = {"name": "user role modified"} - response = self.client.put(f"/api/userroles/{self.userRole.id}/", data=payload, format="json") + response = self.client.put(f"/api/userroles/{self.user_role.id}/", data=payload, format="json") self.group.refresh_from_db() r = self.assertJSONResponse(response, 200) - self.assertEqual(r["name"], self.remove_prefix_from_str(self.group.name, str(self.star_wars.id) + "_")) + expected_name = self.user_role.group.name.removeprefix(f"{self.account.id}_") + self.assertEqual(r["name"], expected_name) def test_partial_update_permissions_modification(self): - self.client.force_authenticate(self.yoda) + self.client.force_authenticate(self.user) payload = { - "name": self.userRole.group.name, + "name": self.user_role.group.name, "permissions": [self.permission1.codename, self.permission2.codename], } - response = self.client.put(f"/api/userroles/{self.userRole.id}/", data=payload, format="json") + response = self.client.put(f"/api/userroles/{self.user_role.id}/", data=payload, format="json") r = self.assertJSONResponse(response, 200) self.assertEqual( @@ -145,13 +182,13 @@ def test_partial_update_permissions_modification(self): ) def test_partial_update_not_allowable_permissions_modification(self): - self.client.force_authenticate(self.yoda) + self.client.force_authenticate(self.user) payload = { - "name": self.userRole.group.name, + "name": self.user_role.group.name, "permissions": [self.permission_not_allowable.codename], } - response = self.client.put(f"/api/userroles/{self.userRole.id}/", data=payload, format="json") + response = self.client.put(f"/api/userroles/{self.user_role.id}/", data=payload, format="json") r = self.assertJSONResponse(response, 404) self.assertEqual( @@ -160,33 +197,33 @@ def test_partial_update_not_allowable_permissions_modification(self): ) def test_delete_user_role(self): - self.client.force_authenticate(self.yoda) + self.client.force_authenticate(self.user) - response = self.client.delete(f"/api/userroles/{self.userRole.id}/") + response = self.client.delete(f"/api/userroles/{self.user_role.id}/") r = self.assertJSONResponse(response, 204) def test_delete_user_role_and_remove_users_in_it(self): - self.client.force_authenticate(self.yoda) + self.client.force_authenticate(self.user) group_1 = Group.objects.create(name="Group 1") group_2 = Group.objects.create(name="Group 2") group_1.permissions.add(self.permission) group_2.permissions.add(self.permission) - userRole_1 = m.UserRole.objects.create(group=group_1, account=self.star_wars) - userRole_2 = m.UserRole.objects.create(group=group_2, account=self.star_wars) + userRole_1 = m.UserRole.objects.create(group=group_1, account=self.account) + userRole_2 = m.UserRole.objects.create(group=group_2, account=self.account) - self.yoda.iaso_profile.user_roles.add(userRole_1) - self.yoda.iaso_profile.user_roles.add(userRole_2) + self.user.iaso_profile.user_roles.add(userRole_1) + self.user.iaso_profile.user_roles.add(userRole_2) self.assertEqual( - list(self.yoda.iaso_profile.user_roles.all()), + list(self.user.iaso_profile.user_roles.all()), list(m.UserRole.objects.filter(id__in=[userRole_2.id, userRole_1.id])), ) response = self.client.delete(f"/api/userroles/{userRole_1.id}/") r = self.assertJSONResponse(response, 204) self.assertEqual( - list(self.yoda.iaso_profile.user_roles.all()), + list(self.user.iaso_profile.user_roles.all()), list(m.UserRole.objects.filter(id__in=[userRole_2.id])), ) diff --git a/iaso/tests/models/test_profile.py b/iaso/tests/models/test_profile.py index 626c6b2bc1..c0aee1dff7 100644 --- a/iaso/tests/models/test_profile.py +++ b/iaso/tests/models/test_profile.py @@ -1,3 +1,5 @@ +from django.contrib.auth.models import Group + from iaso import models as m from iaso.models.microplanning import Team from iaso.test import TestCase @@ -18,3 +20,76 @@ def setUpTestData(cls): def test_user_has_team(self): self.assertTrue(self.profile1.has_a_team()) self.assertFalse(self.profile2.has_a_team()) + + def test_has_org_unit_write_permission(self): + org_unit_type_country = m.OrgUnitType.objects.create(name="Country") + org_unit_type_region = m.OrgUnitType.objects.create(name="Region") + org_unit_type_district = m.OrgUnitType.objects.create(name="District") + org_unit_type_town = m.OrgUnitType.objects.create(name="Town") + + group_1 = Group.objects.create(name="Group 1") + user_role_1 = m.UserRole.objects.create(group=group_1, account=self.account) + user_role_1.editable_org_unit_types.set([org_unit_type_district]) + + group_2 = Group.objects.create(name="Group 2") + user_role_2 = m.UserRole.objects.create(group=group_2, account=self.account) + user_role_2.editable_org_unit_types.set([org_unit_type_town, org_unit_type_region]) + + with self.assertNumQueries(2): + self.assertTrue(self.profile1.has_org_unit_write_permission(org_unit_type_country.pk)) + + self.profile1.editable_org_unit_types.set([org_unit_type_country]) + with self.assertNumQueries(2): + self.assertFalse(self.profile1.has_org_unit_write_permission(org_unit_type_region.pk)) + with self.assertNumQueries(2): + self.assertTrue(self.profile1.has_org_unit_write_permission(org_unit_type_country.pk)) + self.profile1.editable_org_unit_types.clear() + + self.profile1.editable_org_unit_types.set([org_unit_type_region]) + with self.assertNumQueries(2): + self.assertTrue(self.profile1.has_org_unit_write_permission(org_unit_type_region.pk)) + self.profile1.editable_org_unit_types.clear() + + self.profile1.user_roles.set([user_role_1, user_role_2]) + with self.assertNumQueries(2): + editable_org_unit_type_ids = self.profile1.get_editable_org_unit_type_ids() + with self.assertNumQueries(0): + self.assertTrue( + self.profile1.has_org_unit_write_permission( + org_unit_type_district.pk, prefetched_editable_org_unit_type_ids=editable_org_unit_type_ids + ) + ) + self.assertTrue( + self.profile1.has_org_unit_write_permission( + org_unit_type_town.pk, prefetched_editable_org_unit_type_ids=editable_org_unit_type_ids + ) + ) + self.assertTrue( + self.profile1.has_org_unit_write_permission( + org_unit_type_region.pk, prefetched_editable_org_unit_type_ids=editable_org_unit_type_ids + ) + ) + self.profile1.user_roles.clear() + + def test_with_editable_org_unit_types(self): + org_unit_type_country = m.OrgUnitType.objects.create(name="Country") + org_unit_type_region = m.OrgUnitType.objects.create(name="Region") + org_unit_type_district = m.OrgUnitType.objects.create(name="District") + + group_1 = Group.objects.create(name="Group 1") + user_role_1 = m.UserRole.objects.create(group=group_1, account=self.account) + user_role_1.editable_org_unit_types.set([org_unit_type_country]) + + group_2 = Group.objects.create(name="Group 2") + user_role_2 = m.UserRole.objects.create(group=group_2, account=self.account) + user_role_2.editable_org_unit_types.set([org_unit_type_region]) + + self.profile1.user_roles.set([user_role_1, user_role_2]) + self.profile1.editable_org_unit_types.set([org_unit_type_district]) + + profile = m.Profile.objects.filter(id=self.profile1.pk).with_editable_org_unit_types().first() + + self.assertEqual(profile.annotated_editable_org_unit_types_ids, [org_unit_type_district.pk]) + self.assertCountEqual( + profile.annotated_user_roles_editable_org_unit_type_ids, [org_unit_type_country.pk, org_unit_type_region.pk] + ) diff --git a/package-lock.json b/package-lock.json index 05996f651b..d44e9d0429 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,13 +55,9 @@ "react-leaflet-markercluster": "^3.0.0-rc1", "react-query": "^3.18.1", "react-quilljs": "^2.0.0", - "react-redux": "^7.2.0", "react-router-dom": "^6.22.3", - "react-router-redux": "^4.0.0", "react-table": "^7.7.0", "recharts": "^2.2", - "redux": "^4.0.5", - "redux-thunk": "^2.3.0", "typescript": "^4.5.2", "url-search-params-polyfill": "^8.2.5", "use-debounce": "^7.0.0", @@ -116,7 +112,6 @@ "prettier": "^3.2.5", "prettier-eslint": "^12.0.0", "prettier-eslint-cli": "^8.0.1", - "redux-mock-store": "^1.5.4", "sinon": "^18.0.0", "sinon-chai": "^3.7.0", "source-map-loader": "^5.0.0", @@ -4427,17 +4422,6 @@ "@types/react": "^17" } }, - "node_modules/@types/react-redux": { - "version": "7.1.33", - "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.33.tgz", - "integrity": "sha512-NF8m5AjWCkert+fosDsN3hAlHzpjSiXlVy9EgQEmLoBhaNXbmyeGs/aj5dQzKuF+/q+S7JQagorGDW8pJ28Hmg==", - "dependencies": { - "@types/hoist-non-react-statics": "^3.3.0", - "@types/react": "*", - "hoist-non-react-statics": "^3.3.0", - "redux": "^4.0.0" - } - }, "node_modules/@types/react-transition-group": { "version": "4.4.10", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz", @@ -12993,12 +12977,6 @@ "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==" }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true - }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -19109,35 +19087,6 @@ "react-dom": "^17.0.2 || ^18.0.0-0" } }, - "node_modules/react-redux": { - "version": "7.2.9", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz", - "integrity": "sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==", - "dependencies": { - "@babel/runtime": "^7.15.4", - "@types/react-redux": "^7.1.20", - "hoist-non-react-statics": "^3.3.2", - "loose-envify": "^1.4.0", - "prop-types": "^15.7.2", - "react-is": "^17.0.2" - }, - "peerDependencies": { - "react": "^16.8.3 || ^17 || ^18" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - }, - "react-native": { - "optional": true - } - } - }, - "node_modules/react-redux/node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" - }, "node_modules/react-router": { "version": "6.23.1", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.23.1.tgz", @@ -19168,11 +19117,6 @@ "react-dom": ">=16.8" } }, - "node_modules/react-router-redux": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/react-router-redux/-/react-router-redux-4.0.8.tgz", - "integrity": "sha512-lzlK+S6jZnn17BZbzBe6F8ok3YAhGAUlyWgRu3cz5mT199gKxfem5lNu3qcgzRiVhNEOFVG0/pdT+1t4aWhoQw==" - }, "node_modules/react-shallow-renderer": { "version": "16.15.0", "resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz", @@ -19354,23 +19298,6 @@ "@babel/runtime": "^7.9.2" } }, - "node_modules/redux-mock-store": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/redux-mock-store/-/redux-mock-store-1.5.4.tgz", - "integrity": "sha512-xmcA0O/tjCLXhh9Fuiq6pMrJCwFRaouA8436zcikdIpYWWCjU76CRk+i2bHx8EeiSiMGnB85/lZdU3wIJVXHTA==", - "dev": true, - "dependencies": { - "lodash.isplainobject": "^4.0.6" - } - }, - "node_modules/redux-thunk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.2.tgz", - "integrity": "sha512-+P3TjtnP0k/FEjcBL5FZpoovtvrTNT/UXd4/sluaSyrURlSlhLSzEdfsTBW7WsKB6yPvgd7q/iZPICFjW4o57Q==", - "peerDependencies": { - "redux": "^4" - } - }, "node_modules/reflect.getprototypeof": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.6.tgz", diff --git a/package.json b/package.json index f6c2721512..88268fd208 100644 --- a/package.json +++ b/package.json @@ -94,13 +94,9 @@ "react-leaflet-markercluster": "^3.0.0-rc1", "react-query": "^3.18.1", "react-quilljs": "^2.0.0", - "react-redux": "^7.2.0", "react-router-dom": "^6.22.3", - "react-router-redux": "^4.0.0", "react-table": "^7.7.0", "recharts": "^2.2", - "redux": "^4.0.5", - "redux-thunk": "^2.3.0", "typescript": "^4.5.2", "url-search-params-polyfill": "^8.2.5", "use-debounce": "^7.0.0", @@ -155,7 +151,6 @@ "prettier": "^3.2.5", "prettier-eslint": "^12.0.0", "prettier-eslint-cli": "^8.0.1", - "redux-mock-store": "^1.5.4", "sinon": "^18.0.0", "sinon-chai": "^3.7.0", "source-map-loader": "^5.0.0", @@ -166,4 +161,4 @@ "webpack-cli": "^4.10.0", "webpack-dev-server": "^4.15.1" } -} \ No newline at end of file +} diff --git a/plugins/polio/js/src/domains/Calendar/campaignCalendar/types.ts b/plugins/polio/js/src/domains/Calendar/campaignCalendar/types.ts index 853875390a..3a74bbbda6 100644 --- a/plugins/polio/js/src/domains/Calendar/campaignCalendar/types.ts +++ b/plugins/polio/js/src/domains/Calendar/campaignCalendar/types.ts @@ -105,8 +105,4 @@ export type Users = { current: User; }; -export type ReduxState = { - users: Users; -}; - export type PeriodType = 'quarter' | 'year' | 'semester'; diff --git a/plugins/test/js/index.js b/plugins/test/js/index.js index 9a6b3d2623..e0335bb900 100644 --- a/plugins/test/js/index.js +++ b/plugins/test/js/index.js @@ -1,16 +1,14 @@ -import React, { useState, useEffect } from 'react'; -import { useDispatch } from 'react-redux'; import { Box } from '@mui/material'; import { makeStyles } from '@mui/styles'; import { - useSafeIntl, - LoadingSpinner, commonStyles, + LoadingSpinner, Table, + useSafeIntl, } from 'bluesquare-components'; import TopBar from 'Iaso/components/nav/TopBarComponent'; +import React, { useEffect, useState } from 'react'; -import { redirectTo } from 'Iaso/routing/actions'; import { getRequest } from 'Iaso/libs/Api'; import tableColumns from './columns'; import MESSAGES from './messages'; @@ -22,7 +20,6 @@ const useStyles = makeStyles(theme => ({ })); const TestApp = () => { - const dispatch = useDispatch(); const classes = useStyles(); const intl = useSafeIntl(); const [fetching, setFetching] = useState(true); @@ -56,7 +53,7 @@ const TestApp = () => { baseUrl={baseUrl} params={{}} redirectTo={(key, params) => - dispatch(redirectTo(key, params)) + console.log('redirectTo', key, params) } />