From fa8c753a65e0f3576dd00f73f1aa10e7a391c742 Mon Sep 17 00:00:00 2001 From: Arnaud Ambroselli <31724752+arnaudambro@users.noreply.github.com> Date: Tue, 10 Oct 2023 04:52:36 +0200 Subject: [PATCH] fix(dashboard): nouveau DataLoader (#1692) * fix(dashboard): nouveau DataLoader * fix: sorting when necessary * add logs * fix: recoil default * fix: clean * fix: need to initiate default recoil values before setting new values * fix: test * fix: restricted roles * fix: move API.get({ path: /now }); * fix and cleans * fix: clean * fix: remove data.length check * fix: return from data migrator * fix: setFullScreen(isStartingInitialLoad) --- api/src/controllers/organisation.js | 22 +- dashboard/src/app.js | 17 +- dashboard/src/components/ActionModal.js | 4 +- dashboard/src/components/DataLoader.js | 813 +++++++++--------- dashboard/src/components/DataMigrator.js | 14 +- dashboard/src/recoil/actions.js | 10 +- dashboard/src/recoil/comments.js | 12 +- dashboard/src/recoil/groups.ts | 12 +- dashboard/src/recoil/passages.js | 12 +- dashboard/src/recoil/persons.ts | 10 +- dashboard/src/recoil/places.js | 12 +- dashboard/src/recoil/relPersonPlace.js | 12 +- dashboard/src/recoil/rencontres.js | 12 +- dashboard/src/recoil/reports.js | 10 +- dashboard/src/recoil/selectors.js | 1 + dashboard/src/recoil/territory.js | 12 +- dashboard/src/recoil/territoryObservations.js | 10 +- dashboard/src/scenes/auth/signin.js | 4 +- dashboard/src/scenes/person/Places.js | 7 +- dashboard/src/scenes/report/view.js | 3 +- .../src/scenes/territory-observations/list.js | 8 +- e2e/activate_passages_rencontres.spec.ts | 2 +- 22 files changed, 542 insertions(+), 477 deletions(-) diff --git a/api/src/controllers/organisation.js b/api/src/controllers/organisation.js index 44f37175f..fde1e8665 100644 --- a/api/src/controllers/organisation.js +++ b/api/src/controllers/organisation.js @@ -37,8 +37,6 @@ router.get( passport.authenticate("user", { session: false }), validateUser(["superadmin", "admin", "normal", "restricted-access"]), catchErrors(async (req, res, next) => { - const startLoadingDate = Date.now(); - try { z.object({ organisation: z.string().regex(looseUuidRegex), @@ -77,7 +75,7 @@ router.get( // Medical data is never saved in cache so we always have to download all at every page reload. // In other words "after" param is intentionnaly ignored for consultations, treatments and medical files. const medicalDataQuery = - withAllMedicalData !== "true" ? query : { where: { organisation: req.query.organisation }, paranoid: withDeleted === "true" }; + withAllMedicalData !== "true" ? query : { where: { organisation: req.query.organisation }, paranoid: withDeleted === "true" ? false : true }; const consultations = await Consultation.count(medicalDataQuery); const medicalFiles = await MedicalFile.count(medicalDataQuery); const treatments = await Treatment.count(medicalDataQuery); @@ -85,20 +83,20 @@ router.get( return res.status(200).send({ ok: true, data: { - actions, - consultations, - treatments, - comments, - passages, - rencontres, - medicalFiles, persons, groups, + reports, + passages, + rencontres, + actions, + territories, places, relsPersonPlace, - territories, territoryObservations, - reports, + comments, + consultations, + treatments, + medicalFiles, }, }); }) diff --git a/dashboard/src/app.js b/dashboard/src/app.js index 926c65d87..8f1bc610e 100644 --- a/dashboard/src/app.js +++ b/dashboard/src/app.js @@ -33,7 +33,7 @@ import ScrollToTop from './components/ScrollToTop'; import TopBar from './components/TopBar'; import VersionOutdatedAlert from './components/VersionOutdatedAlert'; import ModalConfirm from './components/ModalConfirm'; -import DataLoader, { initialLoadingTextState, loadingTextState, useDataLoader } from './components/DataLoader'; +import DataLoader, { initialLoadIsDoneState, useDataLoader } from './components/DataLoader'; import { Bounce, cssTransition, ToastContainer } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; import SentryRoute from './components/Sentryroute'; @@ -90,7 +90,7 @@ if (ENV === 'production') { const App = ({ resetRecoil }) => { const authToken = useRecoilValue(authTokenState); const user = useRecoilValue(userState); - const loadingText = useRecoilValue(loadingTextState); + const initialLoadIsDone = useRecoilValue(initialLoadIsDoneState); const recoilResetKey = useRecoilValue(recoilResetKeyState); useEffect(() => { @@ -106,7 +106,7 @@ const App = ({ resetRecoil }) => { if (authToken && e.newState === 'active') { API.get({ path: '/check-auth' }) // will force logout if session is expired .then(() => { - if (loadingText !== initialLoadingTextState) { + if (initialLoadIsDone) { // if the app is already loaded // will refresh data if session is still valid refresh(); @@ -120,7 +120,7 @@ const App = ({ resetRecoil }) => { return () => { lifecycle.removeEventListener('statechange', onWindowFocus); }; - }, [authToken, refresh, loadingText]); + }, [authToken, refresh, initialLoadIsDone]); return (
@@ -195,8 +195,13 @@ export default function ContextedApp() { const [recoilKey, setRecoilKey] = useState(0); return ( - - setRecoilKey((k) => k + 1)} /> + {/* We need React.Suspense here because default values for `person`, `action` etc. tables is an async cache request */} + {/* https://recoiljs.org/docs/guides/asynchronous-data-queries#query-default-atom-values */} +
}> + {/* Recoil Nexus allows to use Recoil state outside React tree */} + + setRecoilKey((k) => k + 1)} /> + ); } diff --git a/dashboard/src/components/ActionModal.js b/dashboard/src/components/ActionModal.js index 0498e47f8..172d7a016 100644 --- a/dashboard/src/components/ActionModal.js +++ b/dashboard/src/components/ActionModal.js @@ -612,7 +612,9 @@ function ActionContent({ onClose, action, personId = null, personIds = null, isM .filter(Boolean) .join(' ')}> ({ ...comment, type: 'action', person: action.person }))} + comments={action?.comments + .map((comment) => ({ ...comment, type: 'action', person: action.person })) + .sort((a, b) => new Date(b.date || b.createdAt) - new Date(a.date || a.createdAt))} color="main" canToggleUrgentCheck typeForNewComment="action" diff --git a/dashboard/src/components/DataLoader.js b/dashboard/src/components/DataLoader.js index 534dafb45..7f0f22009 100644 --- a/dashboard/src/components/DataLoader.js +++ b/dashboard/src/components/DataLoader.js @@ -1,9 +1,9 @@ -import { useEffect, useRef, useState } from 'react'; -import styled from 'styled-components'; -import { atom, useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; +import { useEffect } from 'react'; +import { atom, selector, useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; import { toast } from 'react-toastify'; import { personsState } from '../recoil/persons'; +import { groupsState } from '../recoil/groups'; import { treatmentsState } from '../recoil/treatments'; import { actionsState } from '../recoil/actions'; import { medicalFileState } from '../recoil/medicalFiles'; @@ -18,370 +18,420 @@ import { consultationsState, formatConsultation } from '../recoil/consultations' import { commentsState } from '../recoil/comments'; import { organisationState, userState } from '../recoil/auth'; -import { clearCache, dashboardCurrentCacheKey, getCacheItem, getCacheItemDefaultValue, setCacheItem } from '../services/dataManagement'; +import { clearCache, dashboardCurrentCacheKey, getCacheItemDefaultValue, setCacheItem } from '../services/dataManagement'; import API from '../services/api'; import { RandomPicture, RandomPicturePreloader } from './LoaderRandomPicture'; import ProgressBar from './LoaderProgressBar'; import useDataMigrator from './DataMigrator'; -import { groupsState } from '../recoil/groups'; // Update to flush cache. -const cacheEffect = ({ onSet }) => { - onSet(async (newValue) => { - await setCacheItem(dashboardCurrentCacheKey, newValue); - }); -}; - -const loaderTriggerState = atom({ key: 'loaderTriggerState', default: false }); const isLoadingState = atom({ key: 'isLoadingState', default: false }); -const initialLoadState = atom({ key: 'isInitialLoadState', default: false }); const fullScreenState = atom({ key: 'fullScreenState', default: true }); -export const lastLoadState = atom({ key: 'lastLoadState', default: null, effects: [cacheEffect] }); -export const initialLoadingTextState = 'En attente de chargement'; +const progressState = atom({ key: 'progressState', default: null }); +const totalState = atom({ key: 'totalState', default: null }); +const initialLoadingTextState = 'En attente de chargement'; export const loadingTextState = atom({ key: 'loadingTextState', default: initialLoadingTextState }); +export const initialLoadIsDoneState = atom({ key: 'initialLoadIsDoneState', default: false }); +export const lastLoadState = atom({ + key: 'lastLoadState', + default: selector({ + key: 'lastLoadState/default', + get: async () => { + const cache = await getCacheItemDefaultValue(dashboardCurrentCacheKey, 0); + return cache; + }, + }), + effects: [ + ({ onSet }) => { + onSet(async (newValue) => { + await setCacheItem(dashboardCurrentCacheKey, newValue); + }); + }, + ], +}); export default function DataLoader() { - const [user, setUser] = useRecoilState(userState); + const isLoading = useRecoilValue(isLoadingState); + const fullScreen = useRecoilValue(fullScreenState); + const loadingText = useRecoilValue(loadingTextState); + const progress = useRecoilValue(progressState); + const total = useRecoilValue(totalState); + + if (!isLoading) return ; + if (!total && !fullScreen) return null; + + if (fullScreen) { + return ( +
+
+ + +
+
+ ); + } + + return ( +
+ +
+ ); +} + +export function useDataLoader(options = { refreshOnMount: false }) { + const [fullScreen, setFullScreen] = useRecoilState(fullScreenState); + const [isLoading, setIsLoading] = useRecoilState(isLoadingState); + const setLoadingText = useSetRecoilState(loadingTextState); + const setInitialLoadIsDone = useSetRecoilState(initialLoadIsDoneState); + const [lastLoadValue, setLastLoad] = useRecoilState(lastLoadState); + const setProgress = useSetRecoilState(progressState); + const setTotal = useSetRecoilState(totalState); + + const setUser = useSetRecoilState(userState); + const setOrganisation = useSetRecoilState(organisationState); const { migrateData } = useDataMigrator(); const [persons, setPersons] = useRecoilState(personsState); - const [actions, setActions] = useRecoilState(actionsState); - const [consultations, setConsultations] = useRecoilState(consultationsState); - const [treatments, setTreatments] = useRecoilState(treatmentsState); - const [medicalFiles, setMedicalFiles] = useRecoilState(medicalFileState); + const [groups, setGroups] = useRecoilState(groupsState); + const [reports, setReports] = useRecoilState(reportsState); const [passages, setPassages] = useRecoilState(passagesState); const [rencontres, setRencontres] = useRecoilState(rencontresState); - const [reports, setReports] = useRecoilState(reportsState); + const [actions, setActions] = useRecoilState(actionsState); const [territories, setTerritories] = useRecoilState(territoriesState); const [places, setPlaces] = useRecoilState(placesState); const [relsPersonPlace, setRelsPersonPlace] = useRecoilState(relsPersonPlaceState); const [territoryObservations, setTerritoryObservations] = useRecoilState(territoryObservationsState); const [comments, setComments] = useRecoilState(commentsState); - const [groups, setGroups] = useRecoilState(groupsState); + const [consultations, setConsultations] = useRecoilState(consultationsState); + const [treatments, setTreatments] = useRecoilState(treatmentsState); + const [medicalFiles, setMedicalFiles] = useRecoilState(medicalFileState); - const [loaderTrigger, setLoaderTrigger] = useRecoilState(loaderTriggerState); - const [lastLoad, setLastLoad] = useRecoilState(lastLoadState); - const [isLoading, setIsLoading] = useRecoilState(isLoadingState); - const [fullScreen, setFullScreen] = useRecoilState(fullScreenState); - const [loadingText, setLoadingText] = useRecoilState(loadingTextState); - const initialLoad = useRecoilValue(initialLoadState); - const [organisation, setOrganisation] = useRecoilState(organisationState); + useEffect(function refreshOnMountEffect() { + if (options.refreshOnMount && !isLoading) loadOrRefreshData(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - const [loadList, setLoadList] = useState({ list: [], offset: 0 }); - const [progressBuffer, setProgressBuffer] = useState(null); - const [progress, setProgress] = useState(null); - const [total, setTotal] = useState(null); + /* + Steps + + INITIALLY: Recoil takes care of cache reconciliation, so the loader don't have to worry about it + - each atom `person`, `action` etc. has an async default value, which is a selector that will get the data from the cache + - each of those atoms has an effect that will update the cache when the data is set + + Then, for initial load / refresh, the loader will: + 1. Start the UI: + - set isLoading to true + - set fullScreen to true if it's the initial load + - set loadingText to 'Chargement des données' if it's the initial load, 'Mise à jour des données' otherwise + 2. Get the latest organisation, in order to get the latest migrations data, and updated custom fields + 3. Get the latest user, in order to get the latest user roles + 4. Get the latest stats, in order to know what data to download, with the following parameters + - after lastLoadValue, which is a date provided by the server, and is the last time the data was downloaded + - withDeleted, to get the data deleted from other users, so that we can filter it out in here + - withAllMedicalData, only on initial load because medical data is never saved in cache + The stats will be used for + - knowing the total items count, and setting the progress bar + - knowing what data to download + 5. Get the server date, in order to know when the data was last updated + 6. Download all the data, with the following parameters + - withDeleted, to get the data deleted from other users, so that we can filter it out in here + - after lastLoadValue, which is a date provided by the server, and is the last time the data was downloaded + + */ + async function loadOrRefreshData(isStartingInitialLoad) { + setIsLoading(true); + setFullScreen(isStartingInitialLoad); + setLoadingText(isStartingInitialLoad ? 'Chargement des données' : 'Mise à jour des données'); + + /* + Refresh organisation (and user), to get the latest organisation fields + and the latest user roles + */ + const userResponse = await API.get({ path: '/user/me' }); + if (!userResponse.ok) return resetLoaderOnError(); + const latestOrganisation = userResponse.user.organisation; + const latestUser = userResponse.user; + const organisationId = latestOrganisation._id; + setOrganisation(latestOrganisation); + setUser(latestUser); + if (isStartingInitialLoad) { + const migrationIsSuccessful = await migrateData(latestOrganisation); + if (!migrationIsSuccessful) return resetLoaderOnError(); + } - useEffect(() => { - initLoader(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [progress, total, loaderTrigger, loadList.list.length, isLoading]); + const statsResponse = await API.get({ + path: '/organisation/stats', + query: { + organisation: organisationId, + after: lastLoadValue, + withDeleted: true, + // Medical data is never saved in cache so we always have to download all at every page reload. + withAllMedicalData: isStartingInitialLoad, + }, + }); - useEffect(() => { - fetchData(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [loadList]); - useEffect(() => { - updateProgress(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [progress, progressBuffer, loadList.list.length]); - - const organisationId = organisation?._id; - - const serverDate = useRef(null); - - // Loader initialization: get data from cache, check stats, init recoils states, and start loader. - function initLoader() { - if (loadList.list.length > 0) return; - - const shouldStart = progress === null && total === null && loaderTrigger && isLoading; - const shouldStop = progress !== null && total !== null && isLoading; - - if (shouldStart) { - Promise.resolve() - .then(async () => { - /* - Refresh organisation (and user), to get the latest organisation fields - and the latest user roles - */ - const userResponse = await API.get({ path: '/user/me' }); - if (!userResponse.ok) return resetLoaderOnError(); - setOrganisation(userResponse.user.organisation); - setUser(userResponse.user); - // Get date from server at the very beginning of the loader. - const serverDateResponse = await API.get({ path: '/now' }); - serverDate.current = serverDateResponse.data; - }) - .then(() => (initialLoad ? migrateData() : Promise.resolve())) - .then(() => getCacheItem(dashboardCurrentCacheKey)) - .then((lastLoadValue) => { - setLastLoad(lastLoadValue || 0); - API.get({ - path: '/organisation/stats', - query: { - organisation: organisationId, - after: lastLoadValue || 0, - withDeleted: true, - // Medical data is never saved in cache so we always have to download all at every page reload. - withAllMedicalData: initialLoad, - }, - }).then(({ data: stats }) => { - if (!stats) return; - const newList = []; - let itemsCount = - 0 + - stats.persons + - stats.consultations + - stats.actions + - stats.treatments + - stats.medicalFiles + - stats.passages + - stats.rencontres + - stats.reports + - stats.territories + - stats.places + - stats.relsPersonPlace + - stats.territoryObservations + - stats.comments + - stats.groups; - - if (stats.persons) newList.push('person'); - if (stats.groups) newList.push('group'); - if (stats.consultations) newList.push('consultation'); - if (['admin', 'normal'].includes(user.role)) { - if (stats.treatments) newList.push('treatment'); - if (stats.medicalFiles) newList.push('medicalFile'); - } - if (stats.reports) newList.push('report'); - if (stats.passages) newList.push('passage'); - if (stats.rencontres) newList.push('rencontre'); - if (stats.actions) newList.push('action'); - if (stats.territories) newList.push('territory'); - if (stats.places) newList.push('place'); - if (stats.relsPersonPlace) newList.push('relsPersonPlace'); - if (stats.territoryObservations) newList.push('territoryObservation'); - if (stats.comments) newList.push('comment'); - - // In case this is not the initial load, we don't have to load from cache again. - if (!initialLoad) { - startLoader(newList, itemsCount); - return; - } - - setLoadingText('Récupération des données dans le cache'); - Promise.resolve() - .then(() => getCacheItemDefaultValue('person', [])) - .then((persons) => setPersons([...persons])) - .then(() => getCacheItemDefaultValue('group', [])) - .then((groups) => setGroups([...groups])) - .then(() => getCacheItemDefaultValue('report', [])) - .then((reports) => setReports([...reports])) - .then(() => getCacheItemDefaultValue('passage', [])) - .then((passages) => setPassages([...passages])) - .then(() => getCacheItemDefaultValue('rencontre', [])) - .then((rencontres) => setRencontres([...rencontres])) - .then(() => getCacheItemDefaultValue('action', [])) - .then((actions) => setActions([...actions])) - .then(() => getCacheItemDefaultValue('territory', [])) - .then((territories) => setTerritories([...territories])) - .then(() => getCacheItemDefaultValue('place', [])) - .then((places) => setPlaces([...places])) - .then(() => getCacheItemDefaultValue('relPersonPlace', [])) - .then((relsPersonPlace) => setRelsPersonPlace([...relsPersonPlace])) - .then(() => getCacheItemDefaultValue('territory-observation', [])) - .then((territoryObservations) => setTerritoryObservations([...territoryObservations])) - .then(() => getCacheItemDefaultValue('comment', [])) - .then((comments) => setComments([...comments])) - .then(() => startLoader(newList, itemsCount)); - }); - }); - } else if (shouldStop) stopLoader(); - } + if (!statsResponse.ok) return false; + + // Get date from server just after getting all the stats + // We'll set the `lastLoadValue` to this date after all the data is downloaded + const serverDateResponse = await API.get({ path: '/now' }); + const serverDate = serverDateResponse.data; + + const stats = statsResponse.data; + let itemsCount = + 0 + + stats.persons + + stats.groups + + stats.reports + + stats.passages + + stats.rencontres + + stats.actions + + stats.territories + + stats.places + + stats.relsPersonPlace + + stats.territoryObservations + + stats.comments + + stats.consultations; + + if (['admin', 'normal'].includes(latestUser.role)) { + itemsCount += stats.treatments + stats.medicalFiles; + } - // Fetch data from API, handle loader progress. - async function fetchData() { - if (loadList.list.length === 0) return; + setProgress(0); + setTotal(itemsCount); - const [current] = loadList.list; const query = { organisation: organisationId, limit: String(10000), - page: String(loadList.offset), - after: lastLoad, - withDeleted: Boolean(lastLoad), + after: lastLoadValue, + withDeleted: true, }; - function handleMore(hasMore) { - if (hasMore) setLoadList({ list: loadList.list, offset: loadList.offset + 1 }); - else setLoadList({ list: loadList.list.slice(1), offset: 0 }); - } - - if (current === 'person') { + if (stats.persons > 0) { + let newItems = []; setLoadingText('Chargement des personnes'); - const res = await API.get({ path: '/person', query }); - if (!res.data) return resetLoaderOnError(); - setPersons( - res.hasMore - ? mergeItems(persons, res.decryptedData) - : mergeItems(persons, res.decryptedData) - .map((p) => ({ ...p, followedSince: p.followedSince || p.createdAt })) - .sort((p1, p2) => (p1.name || '').localeCompare(p2.name || '')) - ); - handleMore(res.hasMore); - setProgressBuffer(res.data.length); - } else if (current === 'group') { + async function loadPersons(page = 0) { + const res = await API.get({ path: '/person', query: { ...query, page: String(page) } }); + if (!res.ok) return resetLoaderOnError(); + setProgress((p) => p + res.data.length); + newItems.push(...res.decryptedData); + if (res.hasMore) return loadPersons(page + 1); + setPersons(mergeItems(persons, newItems)); + return true; + } + const personSuccess = await loadPersons(0); + if (!personSuccess) return false; + } + if (stats.groups > 0) { + let newItems = []; setLoadingText('Chargement des familles'); - const res = await API.get({ path: '/group', query }); - if (!res.data) return resetLoaderOnError(); - setGroups(() => { - const mergedItems = mergeItems(groups, res.decryptedData); - if (res.hasMore) return mergedItems; - if (mergedItems.length > groups.length) { - return mergedItems.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); - } - return mergedItems; - }); - handleMore(res.hasMore); - setProgressBuffer(res.data.length); - } else if (current === 'consultation') { - setLoadingText('Chargement des consultations'); - const res = await API.get({ path: '/consultation', query: { ...query, after: initialLoad ? 0 : lastLoad } }); - if (!res.data) return resetLoaderOnError(); - setConsultations( - res.hasMore ? mergeItems(consultations, res.decryptedData) : mergeItems(consultations, res.decryptedData).map(formatConsultation) - ); - handleMore(res.hasMore); - setProgressBuffer(res.data.length); - } else if (current === 'treatment') { - setLoadingText('Chargement des traitements'); - const res = await API.get({ path: '/treatment', query: { ...query, after: initialLoad ? 0 : lastLoad } }); - if (!res.data) return resetLoaderOnError(); - setTreatments(mergeItems(treatments, res.decryptedData)); - handleMore(res.hasMore); - setProgressBuffer(res.data.length); - } else if (current === 'medicalFile') { - setLoadingText('Chargement des fichiers médicaux'); - const res = await API.get({ path: '/medical-file', query: { ...query, after: initialLoad ? 0 : lastLoad } }); - if (!res.data) return resetLoaderOnError(); - setMedicalFiles(mergeItems(medicalFiles, res.decryptedData)); - handleMore(res.hasMore); - setProgressBuffer(res.data.length); - } else if (current === 'report') { - setLoadingText('Chargement des rapports'); - const res = await API.get({ path: '/report', query }); - if (!res.data) return resetLoaderOnError(); - setReports( - res.hasMore - ? mergeItems(reports, res.decryptedData) - : mergeItems(reports, res.decryptedData) - // This line should be removed when `clean-reports-with-no-team-nor-date` migration has run on all organisations. - .filter((r) => !!r.team && !!r.date) - ); - handleMore(res.hasMore); - setProgressBuffer(res.data.length); - } else if (current === 'passage') { + async function loadGroups(page = 0) { + const res = await API.get({ path: '/group', query: { ...query, page: String(page) } }); + if (!res.ok) return resetLoaderOnError(); + setProgress((p) => p + res.data.length); + newItems.push(...res.decryptedData); + if (res.hasMore) return loadGroups(page + 1); + setGroups(mergeItems(groups, newItems)); + return true; + } + const groupsSuccess = await loadGroups(0); + if (!groupsSuccess) return false; + } + if (stats.reports > 0) { + let newItems = []; + setLoadingText('Chargement des comptes-rendus'); + async function loadReports(page = 0) { + const res = await API.get({ path: '/report', query: { ...query, page: String(page) } }); + if (!res.ok) return resetLoaderOnError(); + setProgress((p) => p + res.data.length); + newItems.push(...res.decryptedData); + if (res.hasMore) return loadReports(page + 1); + setReports(mergeItems(reports, newItems, { filterNewItemsFunction: (r) => !!r.team && !!r.date })); + return true; + } + const reportsSuccess = await loadReports(0); + if (!reportsSuccess) return false; + } + if (stats.passages > 0) { + let newItems = []; setLoadingText('Chargement des passages'); - const res = await API.get({ path: '/passage', query }); - if (!res.data) return resetLoaderOnError(); - setPassages(() => { - const mergedItems = mergeItems(passages, res.decryptedData); - if (res.hasMore) return mergedItems; - return mergedItems.sort((a, b) => new Date(b.date || b.createdAt) - new Date(a.date || a.createdAt)); - }); - handleMore(res.hasMore); - setProgressBuffer(res.data.length); - } else if (current === 'rencontre') { + async function loadPassages(page = 0) { + const res = await API.get({ path: '/passage', query: { ...query, page: String(page) } }); + if (!res.ok) return resetLoaderOnError(); + setProgress((p) => p + res.data.length); + newItems.push(...res.decryptedData); + if (res.hasMore) return loadPassages(page + 1); + setPassages(mergeItems(passages, newItems)); + return true; + } + const passagesSuccess = await loadPassages(0); + if (!passagesSuccess) return false; + } + if (stats.rencontres > 0) { + let newItems = []; setLoadingText('Chargement des rencontres'); - const res = await API.get({ path: '/rencontre', query }); - if (!res.data) return resetLoaderOnError(); - setRencontres(() => { - const mergedItems = mergeItems(rencontres, res.decryptedData); - if (res.hasMore) return mergedItems; - return mergedItems.sort((a, b) => new Date(b.date || b.createdAt) - new Date(a.date || a.createdAt)); - }); - handleMore(res.hasMore); - setProgressBuffer(res.data.length); - } else if (current === 'action') { - setFullScreen(false); + async function loadRencontres(page = 0) { + const res = await API.get({ path: '/rencontre', query: { ...query, page: String(page) } }); + if (!res.ok) return resetLoaderOnError(); + setProgress((p) => p + res.data.length); + newItems.push(...res.decryptedData); + if (res.hasMore) return loadRencontres(page + 1); + setRencontres(mergeItems(rencontres, newItems)); + return true; + } + const rencontresSuccess = await loadRencontres(0); + if (!rencontresSuccess) return false; + } + if (stats.actions > 0) { + let newItems = []; setLoadingText('Chargement des actions'); - const res = await API.get({ path: '/action', query }); - if (!res.data) return resetLoaderOnError(); - setActions(mergeItems(actions, res.decryptedData)); - handleMore(res.hasMore); - setProgressBuffer(res.data.length); - } else if (current === 'territory') { + async function loadActions(page = 0) { + const res = await API.get({ path: '/action', query: { ...query, page: String(page) } }); + if (!res.ok) return resetLoaderOnError(); + setProgress((p) => p + res.data.length); + newItems.push(...res.decryptedData); + if (res.hasMore) return loadActions(page + 1); + setActions(mergeItems(actions, newItems)); + return true; + } + const actionsSuccess = await loadActions(0); + if (!actionsSuccess) return false; + } + if (stats.territories > 0) { + let newItems = []; setLoadingText('Chargement des territoires'); - const res = await API.get({ path: '/territory', query }); - if (!res.data) return resetLoaderOnError(); - setTerritories(() => { - const mergedItems = mergeItems(territories, res.decryptedData); - if (res.hasMore) return mergedItems; - return mergedItems.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); - }); - handleMore(res.hasMore); - setProgressBuffer(res.data.length); - } else if (current === 'place') { + async function loadTerritories(page = 0) { + const res = await API.get({ path: '/territory', query: { ...query, page: String(page) } }); + if (!res.ok) return resetLoaderOnError(); + setProgress((p) => p + res.data.length); + newItems.push(...res.decryptedData); + if (res.hasMore) return loadTerritories(page + 1); + setTerritories(mergeItems(territories, newItems)); + return true; + } + const territoriesSuccess = await loadTerritories(0); + if (!territoriesSuccess) return false; + } + if (stats.places > 0) { + let newItems = []; setLoadingText('Chargement des lieux'); - const res = await API.get({ path: '/place', query }); - if (!res.data) return resetLoaderOnError(); - setPlaces(() => { - const mergedItems = mergeItems(places, res.decryptedData); - if (res.hasMore) return mergedItems; - return mergedItems.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); - }); - handleMore(res.hasMore); - setProgressBuffer(res.data.length); - } else if (current === 'relsPersonPlace') { + async function loadPlaces(page = 0) { + const res = await API.get({ path: '/place', query: { ...query, page: String(page) } }); + if (!res.ok) return resetLoaderOnError(); + setProgress((p) => p + res.data.length); + newItems.push(...res.decryptedData); + if (res.hasMore) return loadPlaces(page + 1); + setPlaces(mergeItems(places, newItems)); + return true; + } + const placesSuccess = await loadPlaces(0); + if (!placesSuccess) return false; + } + if (stats.relsPersonPlace > 0) { + let newItems = []; setLoadingText('Chargement des relations personne-lieu'); - const res = await API.get({ path: '/relPersonPlace', query }); - if (!res.data) return resetLoaderOnError(); - setRelsPersonPlace(() => { - const mergedItems = mergeItems(relsPersonPlace, res.decryptedData); - if (res.hasMore) return mergedItems; - return mergedItems.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); - }); - handleMore(res.hasMore); - setProgressBuffer(res.data.length); - } else if (current === 'territoryObservation') { + async function loadRelPersonPlaces(page = 0) { + const res = await API.get({ path: '/relPersonPlace', query: { ...query, page: String(page) } }); + if (!res.ok) return resetLoaderOnError(); + setProgress((p) => p + res.data.length); + newItems.push(...res.decryptedData); + if (res.hasMore) return loadRelPersonPlaces(page + 1); + setRelsPersonPlace(mergeItems(relsPersonPlace, newItems)); + return true; + } + const relsPersonPlacesSuccess = await loadRelPersonPlaces(0); + if (!relsPersonPlacesSuccess) return false; + } + if (stats.territoryObservations > 0) { + let newItems = []; setLoadingText('Chargement des observations de territoire'); - const res = await API.get({ path: '/territory-observation', query }); - if (!res.data) return resetLoaderOnError(); - setTerritoryObservations(() => { - const mergedItems = mergeItems(territoryObservations, res.decryptedData); - if (res.hasMore) return mergedItems; - return mergedItems.sort((a, b) => new Date(b.observedAt || b.createdAt) - new Date(a.observedAt || a.createdAt)); - }); - handleMore(res.hasMore); - setProgressBuffer(res.data.length); - } else if (current === 'comment') { + async function loadObservations(page = 0) { + const res = await API.get({ path: '/territory-observation', query: { ...query, page: String(page) } }); + if (!res.ok) return resetLoaderOnError(); + setProgress((p) => p + res.data.length); + newItems.push(...res.decryptedData); + if (res.hasMore) return loadObservations(page + 1); + setTerritoryObservations(mergeItems(territoryObservations, newItems)); + return true; + } + const territoryObservationsSuccess = await loadObservations(0); + if (!territoryObservationsSuccess) return false; + } + if (stats.comments > 0) { + let newItems = []; setLoadingText('Chargement des commentaires'); - const res = await API.get({ path: '/comment', query }); - if (!res.data) return resetLoaderOnError(); - setComments(() => { - const mergedItems = mergeItems(comments, res.decryptedData); - if (res.hasMore) return mergedItems; - return mergedItems.sort((a, b) => new Date(b.date || b.createdAt) - new Date(a.date || a.createdAt)); - }); - handleMore(res.hasMore); - setProgressBuffer(res.data.length); + async function loadComments(page = 0) { + const res = await API.get({ path: '/comment', query: { ...query, page: String(page) } }); + if (!res.ok) return resetLoaderOnError(); + setProgress((p) => p + res.data.length); + newItems.push(...res.decryptedData); + if (res.hasMore) return loadComments(page + 1); + setComments(mergeItems(comments, newItems)); + return true; + } + const commentsSuccess = await loadComments(0); + if (!commentsSuccess) return false; + } + if (stats.consultations > 0) { + let newItems = []; + setLoadingText('Chargement des consultations'); + async function loadConsultations(page = 0) { + const res = await API.get({ + path: '/consultation', + query: { ...query, page: String(page), after: isStartingInitialLoad ? 0 : lastLoadValue }, + }); + if (!res.ok) return resetLoaderOnError(); + setProgress((p) => p + res.data.length); + newItems.push(...res.decryptedData); + if (res.hasMore) return loadConsultations(page + 1); + setConsultations(mergeItems(consultations, newItems, { formatNewItemsFunction: formatConsultation })); + return true; + } + const consultationsSuccess = await loadConsultations(0); + if (!consultationsSuccess) return false; + } + if (['admin', 'normal'].includes(latestUser.role) && stats.treatments > 0) { + let newItems = []; + setLoadingText('Chargement des traitements'); + async function loadTreatments(page = 0) { + const res = await API.get({ path: '/treatment', query: { ...query, page: String(page), after: isStartingInitialLoad ? 0 : lastLoadValue } }); + if (!res.ok) return resetLoaderOnError(); + setProgress((p) => p + res.data.length); + newItems.push(...res.decryptedData); + if (res.hasMore) return loadTreatments(page + 1); + setTreatments(mergeItems(treatments, newItems)); + return true; + } + const treatmentsSuccess = await loadTreatments(0); + if (!treatmentsSuccess) return false; + } + if (['admin', 'normal'].includes(latestUser.role) && stats.medicalFiles > 0) { + let newItems = []; + setLoadingText('Chargement des fichiers médicaux'); + async function loadMedicalFiles(page = 0) { + const res = await API.get({ + path: '/medical-file', + query: { ...query, page: String(page), after: isStartingInitialLoad ? 0 : lastLoadValue }, + }); + if (!res.ok) return resetLoaderOnError(); + setProgress((p) => p + res.data.length); + newItems.push(...res.decryptedData); + if (res.hasMore) return loadMedicalFiles(page + 1); + setMedicalFiles(mergeItems(medicalFiles, newItems)); + return true; + } + const medicalFilesSuccess = await loadMedicalFiles(0); + if (!medicalFilesSuccess) return false; } - } - - function startLoader(list, itemsCount) { - setLoadList({ list, offset: 0 }); - setLoaderTrigger(false); - setProgress(0); - setTotal(itemsCount); - } - function stopLoader() { setIsLoading(false); - setLastLoad(serverDate.current); - setLoadingText('En attente de chargement'); - setProgressBuffer(null); + setLastLoad(serverDate); + setLoadingText('En attente de rafraichissement'); setProgress(null); setTotal(null); + setInitialLoadIsDone(true); + return true; } async function resetLoaderOnError() { @@ -393,64 +443,7 @@ export default function DataLoader() { onClose: () => window.location.replace('/auth'), autoClose: 5000, }); - } - - function updateProgress() { - if (!loadList.list.length) return; - - if (progressBuffer !== null) { - setProgress((progress || 0) + progressBuffer); - setProgressBuffer(null); - } - } - - if (!isLoading) return ; - if (!total && !fullScreen) return null; - - if (fullScreen) { - return ( - - - - - - - ); - } - - return ( - - - - ); -} - -export function useDataLoader(options = { refreshOnMount: false }) { - const [fullScreen, setFullScreen] = useRecoilState(fullScreenState); - const [isLoading, setIsLoading] = useRecoilState(isLoadingState); - const setLoaderTrigger = useSetRecoilState(loaderTriggerState); - const setInitialLoad = useSetRecoilState(initialLoadState); - const setLoadingText = useSetRecoilState(loadingTextState); - const setLastLoad = useSetRecoilState(lastLoadState); - - useEffect(function refreshOnMountEffect() { - if (options.refreshOnMount && !isLoading) refresh(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - function refresh() { - setIsLoading(true); - setFullScreen(false); - setInitialLoad(false); - setLoaderTrigger(true); - setLoadingText('Mise à jour des données'); - } - function load() { - setIsLoading(true); - setFullScreen(true); - setInitialLoad(true); - setLoaderTrigger(true); - setLoadingText('Chargement des données'); + return false; } async function resetCache() { @@ -459,54 +452,38 @@ export function useDataLoader(options = { refreshOnMount: false }) { } return { - refresh, - load, + refresh: () => loadOrRefreshData(false), + startInitialLoad: () => loadOrRefreshData(true), resetCache, isLoading: Boolean(isLoading), isFullScreen: Boolean(fullScreen), }; } -export const mergeItems = (oldItems, newItems = []) => { +export function mergeItems(oldItems, newItems = [], { formatNewItemsFunction, filterNewItemsFunction } = {}) { + const newItemsCleanedAndFormatted = []; const newItemIds = {}; + for (const newItem of newItems) { newItemIds[newItem._id] = true; + if (newItem.deletedAt) continue; + if (filterNewItemsFunction) { + if (!filterNewItemsFunction(newItem)) continue; + } + if (formatNewItemsFunction) { + newItemsCleanedAndFormatted.push(formatNewItemsFunction(newItem)); + } else { + newItemsCleanedAndFormatted.push(newItem); + } + } + + const oldItemsPurged = []; + for (const oldItem of oldItems) { + if (oldItem.deletedAt) continue; + if (!newItemIds[oldItem._id]) { + oldItemsPurged.push(oldItem); + } } - const oldItemsPurged = oldItems.filter((item) => !newItemIds[item._id] && !item.deletedAt); - return [...oldItemsPurged, ...newItems.filter((item) => !item.deletedAt)]; -}; - -const FullScreenContainer = styled.div` - width: 100%; - z-index: 1000; - position: absolute; - top: 0; - left: 0; - bottom: 0; - right: 0; - box-sizing: border-box; - background-color: #fff; - display: flex; - justify-content: center; - align-items: center; -`; - -const InsideContainer = styled.div` - display: flex; - flex-direction: column; - width: 50vw; - max-width: 50vh; - height: 50vh; - max-height: 50vw; - justify-content: center; - align-items: center; -`; - -const Container = styled.div` - width: 100%; - z-index: 1000; - position: absolute; - top: 0; - left: 0; - box-sizing: border-box; -`; + + return [...oldItemsPurged, ...newItemsCleanedAndFormatted]; +} diff --git a/dashboard/src/components/DataMigrator.js b/dashboard/src/components/DataMigrator.js index b4df9933f..36c7a6d84 100644 --- a/dashboard/src/components/DataMigrator.js +++ b/dashboard/src/components/DataMigrator.js @@ -1,4 +1,4 @@ -import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; import { mappedIdsToLabels, prepareActionForEncryption } from '../recoil/actions'; import { organisationState, userState } from '../recoil/auth'; import { usePreparePersonForEncryption } from '../recoil/persons'; @@ -17,16 +17,15 @@ const LOADING_TEXT = 'Mise à jour des données de votre organisation…'; export default function useDataMigrator() { const setLoadingText = useSetRecoilState(loadingTextState); const user = useRecoilValue(userState); - const [organisation, setOrganisation] = useRecoilState(organisationState); - - const organisationId = organisation?._id; + const setOrganisation = useSetRecoilState(organisationState); const preparePersonForEncryption = usePreparePersonForEncryption(); return { // One "if" for each migration. // `migrationLastUpdateAt` should be set after each migration and send in every PUT/POST/PATCH request to server. - migrateData: async () => { + migrateData: async (organisation) => { + const organisationId = organisation?._id; let migrationLastUpdateAt = organisation.migrationLastUpdateAt; /* // Example of migration: @@ -50,6 +49,8 @@ export default function useDataMigrator() { if (response.ok) { setOrganisation(response.organisation); migrationLastUpdateAt = response.organisation.migrationLastUpdateAt; + else { + return false; } } // End of example of migration. @@ -109,8 +110,11 @@ export default function useDataMigrator() { if (response.ok) { setOrganisation(response.organisation); migrationLastUpdateAt = response.organisation.migrationLastUpdateAt; + } else { + return false; } } + return true; }, }; } diff --git a/dashboard/src/recoil/actions.js b/dashboard/src/recoil/actions.js index 5be0c19ac..5646c8cec 100644 --- a/dashboard/src/recoil/actions.js +++ b/dashboard/src/recoil/actions.js @@ -1,4 +1,4 @@ -import { setCacheItem } from '../services/dataManagement'; +import { getCacheItemDefaultValue, setCacheItem } from '../services/dataManagement'; import { atom, selector } from 'recoil'; import { organisationState } from './auth'; import { looseUuidRegex } from '../utils'; @@ -8,7 +8,13 @@ import { capture } from '../services/sentry'; const collectionName = 'action'; export const actionsState = atom({ key: collectionName, - default: [], + default: selector({ + key: 'action/default', + get: async () => { + const cache = await getCacheItemDefaultValue('action', []); + return cache; + }, + }), effects: [({ onSet }) => onSet(async (newValue) => setCacheItem(collectionName, newValue))], }); diff --git a/dashboard/src/recoil/comments.js b/dashboard/src/recoil/comments.js index 52378399b..6c692f06e 100644 --- a/dashboard/src/recoil/comments.js +++ b/dashboard/src/recoil/comments.js @@ -1,5 +1,5 @@ -import { setCacheItem } from '../services/dataManagement'; -import { atom } from 'recoil'; +import { getCacheItemDefaultValue, setCacheItem } from '../services/dataManagement'; +import { atom, selector } from 'recoil'; import { looseUuidRegex } from '../utils'; import { toast } from 'react-toastify'; import { capture } from '../services/sentry'; @@ -7,7 +7,13 @@ import { capture } from '../services/sentry'; const collectionName = 'comment'; export const commentsState = atom({ key: collectionName, - default: [], + default: selector({ + key: 'comment/default', + get: async () => { + const cache = await getCacheItemDefaultValue('comment', []); + return cache; + }, + }), effects: [({ onSet }) => onSet(async (newValue) => setCacheItem(collectionName, newValue))], }); diff --git a/dashboard/src/recoil/groups.ts b/dashboard/src/recoil/groups.ts index 03524dc29..e2d9a7293 100644 --- a/dashboard/src/recoil/groups.ts +++ b/dashboard/src/recoil/groups.ts @@ -1,12 +1,18 @@ -import { setCacheItem } from '../services/dataManagement'; -import { atom, selectorFamily } from 'recoil'; +import { getCacheItemDefaultValue, setCacheItem } from '../services/dataManagement'; +import { atom, selector, selectorFamily } from 'recoil'; import type { GroupInstance } from '../types/group'; import type { UUIDV4 } from '../types/uuid'; const collectionName = 'group'; export const groupsState = atom({ key: collectionName, - default: [], + default: selector({ + key: 'group/default', + get: async () => { + const cache = await getCacheItemDefaultValue('group', []); + return cache; + }, + }), effects: [({ onSet }) => onSet(async (newValue) => setCacheItem(collectionName, newValue))], }); diff --git a/dashboard/src/recoil/passages.js b/dashboard/src/recoil/passages.js index 599d29985..3f93e5399 100644 --- a/dashboard/src/recoil/passages.js +++ b/dashboard/src/recoil/passages.js @@ -1,5 +1,5 @@ -import { setCacheItem } from '../services/dataManagement'; -import { atom } from 'recoil'; +import { getCacheItemDefaultValue, setCacheItem } from '../services/dataManagement'; +import { atom, selector } from 'recoil'; import { looseUuidRegex } from '../utils'; import { toast } from 'react-toastify'; import { capture } from '../services/sentry'; @@ -7,7 +7,13 @@ import { capture } from '../services/sentry'; const collectionName = 'passage'; export const passagesState = atom({ key: collectionName, - default: [], + default: selector({ + key: 'passage/default', + get: async () => { + const cache = await getCacheItemDefaultValue('passage', []); + return cache; + }, + }), effects: [({ onSet }) => onSet(async (newValue) => setCacheItem(collectionName, newValue))], }); diff --git a/dashboard/src/recoil/persons.ts b/dashboard/src/recoil/persons.ts index 146ab1d32..3b3b6d6e7 100644 --- a/dashboard/src/recoil/persons.ts +++ b/dashboard/src/recoil/persons.ts @@ -1,4 +1,4 @@ -import { setCacheItem } from '../services/dataManagement'; +import { getCacheItemDefaultValue, setCacheItem } from '../services/dataManagement'; import { atom, selector, useRecoilValue } from 'recoil'; import { organisationState } from './auth'; import { toast } from 'react-toastify'; @@ -9,7 +9,13 @@ import type { PredefinedField, CustomField } from '../types/field'; const collectionName = 'person'; export const personsState = atom({ key: collectionName, - default: [], + default: selector({ + key: 'person/default', + get: async () => { + const cache = await getCacheItemDefaultValue('person', []); + return cache; + }, + }), effects: [({ onSet }) => onSet(async (newValue) => setCacheItem(collectionName, newValue))], }); diff --git a/dashboard/src/recoil/places.js b/dashboard/src/recoil/places.js index d869eb37d..45f73106f 100644 --- a/dashboard/src/recoil/places.js +++ b/dashboard/src/recoil/places.js @@ -1,5 +1,5 @@ -import { setCacheItem } from '../services/dataManagement'; -import { atom } from 'recoil'; +import { getCacheItemDefaultValue, setCacheItem } from '../services/dataManagement'; +import { atom, selector } from 'recoil'; import { looseUuidRegex } from '../utils'; import { toast } from 'react-toastify'; import { capture } from '../services/sentry'; @@ -7,7 +7,13 @@ import { capture } from '../services/sentry'; const collectionName = 'place'; export const placesState = atom({ key: collectionName, - default: [], + default: selector({ + key: 'place/default', + get: async () => { + const cache = await getCacheItemDefaultValue('place', []); + return cache; + }, + }), effects: [({ onSet }) => onSet(async (newValue) => setCacheItem(collectionName, newValue))], }); diff --git a/dashboard/src/recoil/relPersonPlace.js b/dashboard/src/recoil/relPersonPlace.js index ec6d72c23..4a972be4f 100644 --- a/dashboard/src/recoil/relPersonPlace.js +++ b/dashboard/src/recoil/relPersonPlace.js @@ -1,5 +1,5 @@ -import { setCacheItem } from '../services/dataManagement'; -import { atom } from 'recoil'; +import { getCacheItemDefaultValue, setCacheItem } from '../services/dataManagement'; +import { atom, selector } from 'recoil'; import { looseUuidRegex } from '../utils'; import { toast } from 'react-toastify'; import { capture } from '../services/sentry'; @@ -7,7 +7,13 @@ import { capture } from '../services/sentry'; const collectionName = 'relPersonPlace'; export const relsPersonPlaceState = atom({ key: collectionName, - default: [], + default: selector({ + key: 'relPersonPlace/default', + get: async () => { + const cache = await getCacheItemDefaultValue('relPersonPlace', []); + return cache; + }, + }), effects: [({ onSet }) => onSet(async (newValue) => setCacheItem(collectionName, newValue))], }); diff --git a/dashboard/src/recoil/rencontres.js b/dashboard/src/recoil/rencontres.js index a1705d963..d664b0d58 100644 --- a/dashboard/src/recoil/rencontres.js +++ b/dashboard/src/recoil/rencontres.js @@ -1,5 +1,5 @@ -import { setCacheItem } from '../services/dataManagement'; -import { atom } from 'recoil'; +import { getCacheItemDefaultValue, setCacheItem } from '../services/dataManagement'; +import { atom, selector } from 'recoil'; import { looseUuidRegex } from '../utils'; import { toast } from 'react-toastify'; import { capture } from '../services/sentry'; @@ -7,7 +7,13 @@ import { capture } from '../services/sentry'; const collectionName = 'rencontre'; export const rencontresState = atom({ key: collectionName, - default: [], + default: selector({ + key: 'rencontre/default', + get: async () => { + const cache = await getCacheItemDefaultValue('rencontre', []); + return cache; + }, + }), effects: [({ onSet }) => onSet(async (newValue) => setCacheItem(collectionName, newValue))], }); diff --git a/dashboard/src/recoil/reports.js b/dashboard/src/recoil/reports.js index b918f1090..7916e53e1 100644 --- a/dashboard/src/recoil/reports.js +++ b/dashboard/src/recoil/reports.js @@ -1,4 +1,4 @@ -import { setCacheItem } from '../services/dataManagement'; +import { getCacheItemDefaultValue, setCacheItem } from '../services/dataManagement'; import { atom, selector } from 'recoil'; import { capture } from '../services/sentry'; import { organisationState } from './auth'; @@ -8,7 +8,13 @@ import { toast } from 'react-toastify'; const collectionName = 'report'; export const reportsState = atom({ key: collectionName, - default: [], + default: selector({ + key: 'report/default', + get: async () => { + const cache = await getCacheItemDefaultValue('report', []); + return cache; + }, + }), effects: [ ({ onSet }) => onSet(async (newValue) => { diff --git a/dashboard/src/recoil/selectors.js b/dashboard/src/recoil/selectors.js index 12ba91d40..f0d4c0df1 100644 --- a/dashboard/src/recoil/selectors.js +++ b/dashboard/src/recoil/selectors.js @@ -102,6 +102,7 @@ export const itemsGroupedByPersonSelector = selector({ originalPersonsObject[person._id] = { name: person.name, _id: person._id }; personsObject[person._id] = { ...person, + followedSince: person.followedSince || person.createdAt, userPopulated: usersObject[person.user], formattedBirthDate: formatBirthDate(person.birthdate), age: formatAge(person.birthdate), diff --git a/dashboard/src/recoil/territory.js b/dashboard/src/recoil/territory.js index 9f054cbd3..8a5736250 100644 --- a/dashboard/src/recoil/territory.js +++ b/dashboard/src/recoil/territory.js @@ -1,5 +1,5 @@ -import { setCacheItem } from '../services/dataManagement'; -import { atom } from 'recoil'; +import { getCacheItemDefaultValue, setCacheItem } from '../services/dataManagement'; +import { atom, selector } from 'recoil'; import { looseUuidRegex } from '../utils'; import { toast } from 'react-toastify'; import { capture } from '../services/sentry'; @@ -7,7 +7,13 @@ import { capture } from '../services/sentry'; const collectionName = 'territory'; export const territoriesState = atom({ key: collectionName, - default: [], + default: selector({ + key: 'territory/default', + get: async () => { + const cache = await getCacheItemDefaultValue('territory', []); + return cache; + }, + }), effects: [({ onSet }) => onSet(async (newValue) => setCacheItem(collectionName, newValue))], }); diff --git a/dashboard/src/recoil/territoryObservations.js b/dashboard/src/recoil/territoryObservations.js index a9faf3c64..bcbc5fc05 100644 --- a/dashboard/src/recoil/territoryObservations.js +++ b/dashboard/src/recoil/territoryObservations.js @@ -1,6 +1,6 @@ import { organisationState } from './auth'; import { atom, selector } from 'recoil'; -import { setCacheItem } from '../services/dataManagement'; +import { getCacheItemDefaultValue, setCacheItem } from '../services/dataManagement'; import { looseUuidRegex } from '../utils'; import { toast } from 'react-toastify'; import { capture } from '../services/sentry'; @@ -8,7 +8,13 @@ import { capture } from '../services/sentry'; const collectionName = 'territory-observation'; export const territoryObservationsState = atom({ key: collectionName, - default: [], + default: selector({ + key: 'territory-observation/default', + get: async () => { + const cache = await getCacheItemDefaultValue('territory-observation', []); + return cache; + }, + }), effects: [({ onSet }) => onSet(async (newValue) => setCacheItem(collectionName, newValue))], }); diff --git a/dashboard/src/scenes/auth/signin.js b/dashboard/src/scenes/auth/signin.js index 565a592ef..372738c15 100644 --- a/dashboard/src/scenes/auth/signin.js +++ b/dashboard/src/scenes/auth/signin.js @@ -28,7 +28,7 @@ const SignIn = () => { const [showPassword, setShowPassword] = useState(false); const [loading, setLoading] = useState(true); const [authViaCookie, setAuthViaCookie] = useState(false); - const { load: runDataLoader, isLoading, resetCache } = useDataLoader(); + const { startInitialLoad, isLoading, resetCache } = useDataLoader(); const setToken = useSetRecoilState(authTokenState); const [signinForm, setSigninForm] = useState({ email: '', password: '', orgEncryptionKey: DEFAULT_ORGANISATION_KEY || '' }); @@ -45,7 +45,7 @@ const SignIn = () => { } }, [history, organisation, isLoading, isDesktop]); - const onSigninValidated = async () => runDataLoader(); + const onSigninValidated = () => startInitialLoad(); const onLogout = async () => { await API.logout(); diff --git a/dashboard/src/scenes/person/Places.js b/dashboard/src/scenes/person/Places.js index 1ed929490..6051c8839 100644 --- a/dashboard/src/scenes/person/Places.js +++ b/dashboard/src/scenes/person/Places.js @@ -40,6 +40,11 @@ const PersonPlaces = ({ person }) => { return personPlaces.length !== new Set(personPlaces).size; }, [person.relsPersonPlace]); + const sortedPlaces = useMemo(() => { + if (!person.relsPersonPlace?.length) return []; + return [...person.relsPersonPlace]?.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + }, [person.relsPersonPlace]); + return ( <>
@@ -74,7 +79,7 @@ const PersonPlaces = ({ person }) => { - {person.relsPersonPlace?.map((relPersonPlace) => { + {sortedPlaces?.map((relPersonPlace) => { const { place: placeId, createdAt, user } = relPersonPlace; const place = places.find((p) => p._id === placeId); return ( diff --git a/dashboard/src/scenes/report/view.js b/dashboard/src/scenes/report/view.js index d5472bf3e..83dda19bb 100644 --- a/dashboard/src/scenes/report/view.js +++ b/dashboard/src/scenes/report/view.js @@ -348,7 +348,8 @@ const View = () => { { referenceStartDay: dateString, referenceEndDay: dateString }, currentTeam?.nightSession ? 12 : 0 ); - }), + }) + .sort((a, b) => new Date(b.observedAt || b.createdAt) - new Date(a.observedAt || a.createdAt)), [dateString, selectedTeamsObject, territoryObservations] ); diff --git a/dashboard/src/scenes/territory-observations/list.js b/dashboard/src/scenes/territory-observations/list.js index ef88195e5..05e1f6804 100644 --- a/dashboard/src/scenes/territory-observations/list.js +++ b/dashboard/src/scenes/territory-observations/list.js @@ -15,7 +15,13 @@ const List = ({ territory = {} }) => { const [observation, setObservation] = useState({}); const [openObservationModale, setOpenObservationModale] = useState(null); - const observations = useMemo(() => territoryObservations.filter((obs) => obs.territory === territory._id), [territory._id, territoryObservations]); + const observations = useMemo( + () => + territoryObservations + .filter((obs) => obs.territory === territory._id) + .sort((a, b) => new Date(b.observedAt || b.createdAt) - new Date(a.observedAt || a.createdAt)), + [territory._id, territoryObservations] + ); if (!observations) return null; diff --git a/e2e/activate_passages_rencontres.spec.ts b/e2e/activate_passages_rencontres.spec.ts index febd3793f..a10a0cf47 100644 --- a/e2e/activate_passages_rencontres.spec.ts +++ b/e2e/activate_passages_rencontres.spec.ts @@ -78,7 +78,7 @@ test("test", async ({ page }) => { await page.getByRole("link", { name: "Statistiques" }).click(); await page.getByRole("button", { name: "Passages" }).click(); - await expect(page.getByText("Nombre de passages ?Non-anonyme375%Anonyme125%Total4100%Non-anonymeAnonyme3 (75%")).toBeVisible(); + await expect(page.getByText("Nombre de passages ?Non-anonyme375%Anonyme125%Total4100%AnonymeNon-anonyme1 (25%")).toBeVisible(); await expect(page.getByRole("button", { name: "Rencontres" })).not.toBeVisible();