From 81e88fcd9ed85c3e9347863b4de865c5fc25c9b4 Mon Sep 17 00:00:00 2001 From: Benjamin Marguin <benjamin.marguin@makina-corpus.com> Date: Thu, 3 Nov 2022 14:45:40 +0100 Subject: [PATCH 01/15] Add tanstack-query (former react-query) dependency Refs: tropo-group/suivi-de-projet#9 --- package-lock.json | 64 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 65 insertions(+) diff --git a/package-lock.json b/package-lock.json index 83b5b6703..7100f0eaf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@mui/lab": "^5.0.0-alpha.63", "@mui/material": "^5.2.7", "@react-keycloak/web": "^3.4.0", + "@tanstack/react-query": "^4.14.1", "@types/node": "^17.0.8", "date-fns": "^2.28.0", "dompurify": "^2.3.4", @@ -1097,6 +1098,41 @@ "node": ">= 8.0.0" } }, + "node_modules/@tanstack/query-core": { + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.14.1.tgz", + "integrity": "sha512-mUejKoFDe4NZB8jQJR1uuAl6IwvkUpOD2m8NcuTVPOu0pcxeeFPdrnHaljwOEFPtlqXoiiIIQGYy6whjCMN+iQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.14.1.tgz", + "integrity": "sha512-cRgNzigw4GSPwGlTEkXi8hi/xgUnSEt9jCkiC8oAT3PEIdsQ50onZcpXd+JNJcZk2RTh8KM1fGyWz6xYLiY8bg==", + "dependencies": { + "@tanstack/query-core": "4.14.1", + "use-sync-external-store": "^1.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-native": "*" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/@types/json-schema": { "version": "7.0.9", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.9.tgz", @@ -4740,6 +4776,14 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -5521,6 +5565,20 @@ "picomatch": "^2.2.2" } }, + "@tanstack/query-core": { + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.14.1.tgz", + "integrity": "sha512-mUejKoFDe4NZB8jQJR1uuAl6IwvkUpOD2m8NcuTVPOu0pcxeeFPdrnHaljwOEFPtlqXoiiIIQGYy6whjCMN+iQ==" + }, + "@tanstack/react-query": { + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.14.1.tgz", + "integrity": "sha512-cRgNzigw4GSPwGlTEkXi8hi/xgUnSEt9jCkiC8oAT3PEIdsQ50onZcpXd+JNJcZk2RTh8KM1fGyWz6xYLiY8bg==", + "requires": { + "@tanstack/query-core": "4.14.1", + "use-sync-external-store": "^1.2.0" + } + }, "@types/json-schema": { "version": "7.0.9", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.9.tgz", @@ -8183,6 +8241,12 @@ "punycode": "^2.1.0" } }, + "use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "requires": {} + }, "uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", diff --git a/package.json b/package.json index ebe55b721..83d1037c5 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@mui/lab": "^5.0.0-alpha.63", "@mui/material": "^5.2.7", "@react-keycloak/web": "^3.4.0", + "@tanstack/react-query": "^4.14.1", "@types/node": "^17.0.8", "date-fns": "^2.28.0", "dompurify": "^2.3.4", -- GitLab From 9423523e4dd691a018504831c44b540e0a03e289 Mon Sep 17 00:00:00 2001 From: Benjamin Marguin <benjamin.marguin@makina-corpus.com> Date: Thu, 3 Nov 2022 14:46:16 +0100 Subject: [PATCH 02/15] Setup tanstack-query Provider Refs: tropo-group/suivi-de-projet#9 --- src/index.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/index.tsx b/src/index.tsx index be2436daa..4cdff02b8 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,5 +1,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + import './index.css'; import { BrowserRouter } from 'react-router-dom'; import { ThemeProvider } from '@mui/material/styles'; @@ -10,12 +12,16 @@ import theme from './theme'; import './i18n'; +const queryClient = new QueryClient(); + ReactDOM.render( <React.StrictMode> <ThemeProvider theme={theme}> <CssBaseline /> <BrowserRouter> - <App /> + <QueryClientProvider client={queryClient}> + <App /> + </QueryClientProvider> </BrowserRouter> </ThemeProvider> </React.StrictMode>, -- GitLab From 029ed240331e6a918dbb30bc61514a991adb7c78 Mon Sep 17 00:00:00 2001 From: Benjamin Marguin <benjamin.marguin@makina-corpus.com> Date: Thu, 3 Nov 2022 14:46:51 +0100 Subject: [PATCH 03/15] Make Study list queries through tanstack-query Refs: tropo-group/suivi-de-projet#9 --- src/pages/List.tsx | 145 +++++++++++++++++++++++++++++---------------- 1 file changed, 95 insertions(+), 50 deletions(-) diff --git a/src/pages/List.tsx b/src/pages/List.tsx index 0b1072998..4a13cbf12 100644 --- a/src/pages/List.tsx +++ b/src/pages/List.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { Delete } from '@mui/icons-material'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; @@ -21,9 +22,9 @@ import { import { useKeycloak } from '@react-keycloak/web'; import { useSnackbar } from 'notistack'; -import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link as RouterLink } from 'react-router-dom'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import StatusChip from '../components/StatusChip'; import { ComputationId } from '../entities/computation'; @@ -191,68 +192,112 @@ export const Row = ({ study, onDeleteStudy, onDeleteConnectivity }: RowProps) => const StudyList = () => { const { keycloak } = useKeycloak(); - const [loading, setLoading] = useState(false); - const [studies, setStudies] = useState<Study[]>([]); const { t } = useTranslation(); const { enqueueSnackbar } = useSnackbar(); + const queryClient = useQueryClient(); - useEffect(() => { - setLoading(true); - // call your backend api to load users - fetchStudies(keycloak).then(response => { - setStudies(response); - setLoading(false); - }); - }, [keycloak]); - - function displaySuccess () { - enqueueSnackbar(t('The study has been deleted.'), { + const { isLoading, data: studies } = useQuery(['studies'], () => fetchStudies(keycloak)); + + const displaySuccess = React.useCallback( + () => enqueueSnackbar(t('The study has been deleted.'), { variant: 'success', anchorOrigin: { horizontal: 'center', vertical: 'top' }, - }); - } + }), + [enqueueSnackbar, t], + ); - function displayError () { - enqueueSnackbar(t('This action has encountered an unexpected error.'), { + const displayError = React.useCallback( + () => enqueueSnackbar(t('This action has encountered an unexpected error.'), { variant: 'error', anchorOrigin: { horizontal: 'center', vertical: 'top' }, - }); - } + }), + [enqueueSnackbar, t], + ); - async function onDeleteStudy (id: ComputationId) { - try { - // const response = await deleteStudy(keycloak, id); - await deleteStudy(keycloak, id); - setStudies(studies.filter(({ id: itemId }) => itemId !== id)); - displaySuccess(); - } catch (e) { + const handleMutationError = React.useCallback( + (err, newTodo, context) => { displayError(); - } - } - - async function onDeleteConnectivity (id: ComputationId) { - try { - // const response = await deleteConnectivity(keycloak, id); - await deleteConnectivity(keycloak, id); - setStudies( - studies.map(study => { - const newStudy = study; - newStudy.connectivities = study.connectivities.filter( - connectivity => connectivity.id !== id, - ); - return newStudy; - }), + queryClient.setQueryData( + ['studies'], + context?.previousStudies, ); + }, + [displayError, queryClient], + ); + + const handleMutationSettle = React.useCallback( + () => { displaySuccess(); - } catch (e) { - displayError(); - } - } + queryClient.invalidateQueries({ queryKey: ['studies'] }); + }, + [displaySuccess, queryClient], + ); + + const deleteStudyMutation = useMutation({ + mutationFn: id => deleteStudy(keycloak, id), + + // When mutate is called: + onMutate: async id => { + // Cancel any outgoing refetches (so they don't overwrite our optimistic update) + await queryClient.cancelQueries({ queryKey: ['studies'] }); + + // Snapshot the previous value + const previousStudies = queryClient.getQueryData(['studies']); + + // Optimistically update to the new value + queryClient.setQueryData( + ['studies'], + (prevStudies: Array<any> = []) => prevStudies.filter(({ id: itemId }) => itemId !== id), + ); + + // Return a context object with the snapshotted value + return { previousStudies }; + }, + + // If the mutation fails, use the context returned from onMutate to roll back + onError: handleMutationError, + + // Always refetch after error or success: + onSettled: handleMutationSettle, + }); + + const deleteConnectivityMutation = useMutation({ + mutationFn: id => deleteConnectivity(keycloak, id), + + // When mutate is called: + onMutate: async id => { + // Cancel any outgoing refetches (so they don't overwrite our optimistic update) + await queryClient.cancelQueries({ queryKey: ['studies'] }); + + // Snapshot the previous value + const previousStudies = queryClient.getQueryData(['studies']); + + // Optimistically update to the new value + queryClient.setQueryData( + ['studies'], + (prevStudies: Array<any> = []) => prevStudies.map(({ connectivities = [], ...study }) => ({ + ...study, + connectivities: connectivities.filter( + ({ id: connectivityId }) => connectivityId !== id, + ), + })), + ); + + // Return a context object with the snapshotted value + return { previousStudies }; + }, + + // If the mutation fails, use the context returned from onMutate to roll back + onError: handleMutationError, + + // Always refetch after error or success: + onSettled: handleMutationSettle, + }); return ( <Container maxWidth="xl" sx={{ pt: 4 }}> <TableContainer component={Paper} sx={{ position: 'relative' }}> - {loading && ( + {isLoading && ( <LinearProgress sx={{ m: 'auto', position: 'absolute', bottom: 0, left: 0, right: 0 }} /> )} <Table aria-label="collapsible table" size="small"> @@ -268,12 +313,12 @@ const StudyList = () => { </TableRow> </TableHead> <TableBody> - {studies.map(study => ( + {studies && studies.map(study => ( <Row key={study.id} study={study} - onDeleteStudy={onDeleteStudy} - onDeleteConnectivity={onDeleteConnectivity} + onDeleteStudy={id => deleteStudyMutation.mutate(id)} + onDeleteConnectivity={id => deleteConnectivityMutation.mutate(id)} /> ))} </TableBody> -- GitLab From f4cfa94d9b67c97c82b7a9525b7d4526abeacdbe Mon Sep 17 00:00:00 2001 From: Benjamin Marguin <benjamin.marguin@makina-corpus.com> Date: Thu, 3 Nov 2022 14:57:38 +0100 Subject: [PATCH 04/15] Extract StudyList queries to dedicated hook Refs: tropo-group/suivi-de-projet#9 --- src/hooks/useStudies.ts | 122 ++++++++++++++++++++++++++++++++++++++++ src/pages/List.tsx | 115 +++---------------------------------- 2 files changed, 131 insertions(+), 106 deletions(-) create mode 100644 src/hooks/useStudies.ts diff --git a/src/hooks/useStudies.ts b/src/hooks/useStudies.ts new file mode 100644 index 000000000..58e19b433 --- /dev/null +++ b/src/hooks/useStudies.ts @@ -0,0 +1,122 @@ +import React from 'react'; +import { useKeycloak } from '@react-keycloak/web'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; + +import { useTranslation } from 'react-i18next'; +import { useSnackbar } from 'notistack'; + +import { deleteConnectivity, deleteStudy, fetchStudies } from '../utils/api'; + +const useStudies = () => { + const { t } = useTranslation(); + const { keycloak } = useKeycloak(); + const queryClient = useQueryClient(); + const { enqueueSnackbar } = useSnackbar(); + + const displaySuccess = React.useCallback( + () => enqueueSnackbar(t('The study has been deleted.'), { + variant: 'success', + anchorOrigin: { horizontal: 'center', vertical: 'top' }, + }), + [enqueueSnackbar, t], + ); + + const displayError = React.useCallback( + () => enqueueSnackbar(t('This action has encountered an unexpected error.'), { + variant: 'error', + anchorOrigin: { horizontal: 'center', vertical: 'top' }, + }), + [enqueueSnackbar, t], + ); + + const studiesQueryResponse = useQuery(['studies'], () => fetchStudies(keycloak)); + + const handleMutationError = React.useCallback( + (err, newTodo, context) => { + displayError(); + queryClient.setQueryData( + ['studies'], + context?.previousStudies, + ); + }, + [displayError, queryClient], + ); + + const handleMutationSettle = React.useCallback( + () => { + displaySuccess(); + queryClient.invalidateQueries({ queryKey: ['studies'] }); + }, + [displaySuccess, queryClient], + ); + + const deleteStudyMutation = useMutation({ + mutationFn: id => deleteStudy(keycloak, id), + + // When mutate is called: + onMutate: async id => { + // Cancel any outgoing refetches (so they don't overwrite our optimistic update) + await queryClient.cancelQueries({ queryKey: ['studies'] }); + + // Snapshot the previous value + const previousStudies = queryClient.getQueryData(['studies']); + + // Optimistically update to the new value + queryClient.setQueryData( + ['studies'], + (prevStudies: Array<any> = []) => prevStudies.filter(({ id: itemId }) => itemId !== id), + ); + + // Return a context object with the snapshotted value + return { previousStudies }; + }, + + // If the mutation fails, use the context returned from onMutate to roll back + onError: handleMutationError, + + // Always refetch after error or success: + onSettled: handleMutationSettle, + }); + + const deleteConnectivityMutation = useMutation({ + mutationFn: id => deleteConnectivity(keycloak, id), + + // When mutate is called: + onMutate: async id => { + // Cancel any outgoing refetches (so they don't overwrite our optimistic update) + await queryClient.cancelQueries({ queryKey: ['studies'] }); + + // Snapshot the previous value + const previousStudies = queryClient.getQueryData(['studies']); + + // Optimistically update to the new value + queryClient.setQueryData( + ['studies'], + (prevStudies: Array<any> = []) => prevStudies.map(({ connectivities = [], ...study }) => ({ + ...study, + connectivities: connectivities.filter( + ({ id: connectivityId }) => connectivityId !== id, + ), + })), + ); + + // Return a context object with the snapshotted value + return { previousStudies }; + }, + + // If the mutation fails, use the context returned from onMutate to roll back + onError: handleMutationError, + + // Always refetch after error or success: + onSettled: handleMutationSettle, + }); + + return { + ...studiesQueryResponse, + studies: studiesQueryResponse.data, + deleteStudy: deleteStudyMutation.mutateAsync, + deleteConnectivity: deleteConnectivityMutation.mutateAsync, + }; +}; + +export default useStudies; diff --git a/src/pages/List.tsx b/src/pages/List.tsx index 4a13cbf12..ea72ed53c 100644 --- a/src/pages/List.tsx +++ b/src/pages/List.tsx @@ -20,16 +20,13 @@ import { useTheme, } from '@mui/material'; -import { useKeycloak } from '@react-keycloak/web'; -import { useSnackbar } from 'notistack'; import { useTranslation } from 'react-i18next'; import { Link as RouterLink } from 'react-router-dom'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import StatusChip from '../components/StatusChip'; import { ComputationId } from '../entities/computation'; import { Study } from '../entities/study'; -import { deleteConnectivity, deleteStudy, fetchStudies } from '../utils/api'; +import useStudies from '../hooks/useStudies'; interface RowProps { study: Study; @@ -191,108 +188,14 @@ export const Row = ({ study, onDeleteStudy, onDeleteConnectivity }: RowProps) => }; const StudyList = () => { - const { keycloak } = useKeycloak(); const { t } = useTranslation(); - const { enqueueSnackbar } = useSnackbar(); - const queryClient = useQueryClient(); - const { isLoading, data: studies } = useQuery(['studies'], () => fetchStudies(keycloak)); - - const displaySuccess = React.useCallback( - () => enqueueSnackbar(t('The study has been deleted.'), { - variant: 'success', - anchorOrigin: { horizontal: 'center', vertical: 'top' }, - }), - [enqueueSnackbar, t], - ); - - const displayError = React.useCallback( - () => enqueueSnackbar(t('This action has encountered an unexpected error.'), { - variant: 'error', - anchorOrigin: { horizontal: 'center', vertical: 'top' }, - }), - [enqueueSnackbar, t], - ); - - const handleMutationError = React.useCallback( - (err, newTodo, context) => { - displayError(); - queryClient.setQueryData( - ['studies'], - context?.previousStudies, - ); - }, - [displayError, queryClient], - ); - - const handleMutationSettle = React.useCallback( - () => { - displaySuccess(); - queryClient.invalidateQueries({ queryKey: ['studies'] }); - }, - [displaySuccess, queryClient], - ); - - const deleteStudyMutation = useMutation({ - mutationFn: id => deleteStudy(keycloak, id), - - // When mutate is called: - onMutate: async id => { - // Cancel any outgoing refetches (so they don't overwrite our optimistic update) - await queryClient.cancelQueries({ queryKey: ['studies'] }); - - // Snapshot the previous value - const previousStudies = queryClient.getQueryData(['studies']); - - // Optimistically update to the new value - queryClient.setQueryData( - ['studies'], - (prevStudies: Array<any> = []) => prevStudies.filter(({ id: itemId }) => itemId !== id), - ); - - // Return a context object with the snapshotted value - return { previousStudies }; - }, - - // If the mutation fails, use the context returned from onMutate to roll back - onError: handleMutationError, - - // Always refetch after error or success: - onSettled: handleMutationSettle, - }); - - const deleteConnectivityMutation = useMutation({ - mutationFn: id => deleteConnectivity(keycloak, id), - - // When mutate is called: - onMutate: async id => { - // Cancel any outgoing refetches (so they don't overwrite our optimistic update) - await queryClient.cancelQueries({ queryKey: ['studies'] }); - - // Snapshot the previous value - const previousStudies = queryClient.getQueryData(['studies']); - - // Optimistically update to the new value - queryClient.setQueryData( - ['studies'], - (prevStudies: Array<any> = []) => prevStudies.map(({ connectivities = [], ...study }) => ({ - ...study, - connectivities: connectivities.filter( - ({ id: connectivityId }) => connectivityId !== id, - ), - })), - ); - - // Return a context object with the snapshotted value - return { previousStudies }; - }, - - // If the mutation fails, use the context returned from onMutate to roll back - onError: handleMutationError, - - // Always refetch after error or success: - onSettled: handleMutationSettle, - }); + const { + isLoading, + studies, + deleteStudy, + deleteConnectivity, + } = useStudies(); return ( <Container maxWidth="xl" sx={{ pt: 4 }}> @@ -317,8 +220,8 @@ const StudyList = () => { <Row key={study.id} study={study} - onDeleteStudy={id => deleteStudyMutation.mutate(id)} - onDeleteConnectivity={id => deleteConnectivityMutation.mutate(id)} + onDeleteStudy={id => deleteStudy(id)} + onDeleteConnectivity={id => deleteConnectivity(id)} /> ))} </TableBody> -- GitLab From 0323f9c8f49d92d0402fc1f890396620c735d2e8 Mon Sep 17 00:00:00 2001 From: Benjamin Marguin <benjamin.marguin@makina-corpus.com> Date: Thu, 3 Nov 2022 15:46:22 +0100 Subject: [PATCH 05/15] Make Map component able to support async rendering --- src/components/Map.tsx | 48 ++++++++++++++++++++++++++++++------------ 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/src/components/Map.tsx b/src/components/Map.tsx index 701c7ad4a..fcbac3a30 100644 --- a/src/components/Map.tsx +++ b/src/components/Map.tsx @@ -1,6 +1,7 @@ import React, { useEffect } from 'react'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; +import { Box } from '@mui/material'; function getCentroid (arr) { return arr.reduce( @@ -10,26 +11,45 @@ function getCentroid (arr) { } const Map = ({ points }) => { + const mapRef = React.useRef(); + const [map, setMap] = React.useState<any>(null); + + useEffect( + () => { + if (!mapRef.current || map) { + return; + } + + const mapInstance = L.map(mapRef.current, { + center: [0, 0], + zoom: 4, + layers: [ + L.tileLayer('http://{s}.tile.osm.org/{z}/{x}/{y}.png', { + attribution: + '© <a href="http://osm.org/copyright">OpenStreetMap</a> contributors', + }), + ], + }); + setMap(mapInstance); + }, + [map], + ); + useEffect(() => { + if (!map) { + return; + } + const center = getCentroid(points); - // create map - const map = L.map('map', { - center, - zoom: 4, - layers: [ - L.tileLayer('http://{s}.tile.osm.org/{z}/{x}/{y}.png', { - attribution: - '© <a href="http://osm.org/copyright">OpenStreetMap</a> contributors', - }), - ], - }); points.forEach(point => { L.circleMarker(point, { radius: 4 }).addTo(map); }); - }, [points]); - return <div id="map" style={{ height: '350px' }} />; + map.setView(center); + }, [map, points]); + + return <Box id="map" ref={mapRef} style={{ height: '350px' }} />; }; -export default Map; +export default React.memo(Map); -- GitLab From 73970d23969f6483ecfbd17d6023271fd8ba8cd5 Mon Sep 17 00:00:00 2001 From: Benjamin Marguin <benjamin.marguin@makina-corpus.com> Date: Thu, 3 Nov 2022 15:50:30 +0100 Subject: [PATCH 06/15] Fix Chip error with <div> as child of <p> --- src/components/StatusChip.tsx | 2 +- src/pages/DetailTrajectory.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/StatusChip.tsx b/src/components/StatusChip.tsx index 743b612d7..03e36a209 100644 --- a/src/components/StatusChip.tsx +++ b/src/components/StatusChip.tsx @@ -48,7 +48,7 @@ const StatusChip = ({ } return ( - <Chip size="small" label={status} color={color} /> + <Chip size="small" label={status} color={color} {...rest} /> ); }; diff --git a/src/pages/DetailTrajectory.tsx b/src/pages/DetailTrajectory.tsx index 6d1161e07..cea0f93b8 100644 --- a/src/pages/DetailTrajectory.tsx +++ b/src/pages/DetailTrajectory.tsx @@ -159,7 +159,7 @@ export const DetailTrajectory = () => { <Typography variant="h1" paragraph> {t('Trajectory')} {trajectory.name} {' '} - <StatusChip status={trajectory.status} /> + <StatusChip status={trajectory.status} component="span" /> </Typography> </Grid> <Grid item> -- GitLab From 3879a65bdc90ca45d144457ae67770119ca260f9 Mon Sep 17 00:00:00 2001 From: Benjamin Marguin <benjamin.marguin@makina-corpus.com> Date: Thu, 3 Nov 2022 16:21:42 +0100 Subject: [PATCH 07/15] Use tanstack-query to fetch DetailTrajectory data Refs: tropo-group/suivi-de-projet#9 --- src/hooks/useTrajectory.ts | 27 +++++++++++++++ src/pages/DetailTrajectory.tsx | 62 +++++----------------------------- 2 files changed, 36 insertions(+), 53 deletions(-) create mode 100644 src/hooks/useTrajectory.ts diff --git a/src/hooks/useTrajectory.ts b/src/hooks/useTrajectory.ts new file mode 100644 index 000000000..59551a1be --- /dev/null +++ b/src/hooks/useTrajectory.ts @@ -0,0 +1,27 @@ +import { useKeycloak } from '@react-keycloak/web'; +import { useQuery } from '@tanstack/react-query'; +import React from 'react'; + +import { fetchTrajectory } from '../utils/api'; +import { parseTrajectory } from '../utils/trajectory'; + +const useTrajectory = id => { + const { keycloak } = useKeycloak(); + + const trajectoryQueryResponse = useQuery( + ['trajectory', id], + () => fetchTrajectory(keycloak, +id), + ); + + return React.useMemo( + () => ({ + ...trajectoryQueryResponse, + trajectory: trajectoryQueryResponse.data + ? parseTrajectory(trajectoryQueryResponse.data) + : null, + }), + [trajectoryQueryResponse], + ); +}; + +export default useTrajectory; diff --git a/src/pages/DetailTrajectory.tsx b/src/pages/DetailTrajectory.tsx index cea0f93b8..7fa58f239 100644 --- a/src/pages/DetailTrajectory.tsx +++ b/src/pages/DetailTrajectory.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { Box, @@ -23,73 +23,29 @@ import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; import { format } from 'date-fns'; import Map from '../components/Map'; -import { Trajectory } from '../entities/trajectory'; -import { fetchTrajectory } from '../utils/api'; import { isoDate } from '../utils/dates'; import exportJSON from '../utils/export-json'; -import { parseTrajectory } from '../utils/trajectory'; import ListLocations from '../components/ListLocations'; import ListDates from '../components/ListDates'; import { VERTICAL_MOTION } from '../computation.config'; import StatusChip from '../components/StatusChip'; +import useTrajectory from '../hooks/useTrajectory'; export const DetailTrajectory = () => { const { t } = useTranslation(); const { keycloak } = useKeycloak(); - const [trajectory, setTrajectory] = useState<Trajectory | null>(null); - const [points, setPoints] = useState<any[][]>([]); - const [ - errors, - // setErrors, - ] = useState(null); - const [loading, setLoading] = useState(true); const [busyDataQuery, setBusyDataQuery] = useState<String[]>([]); - const { id } = useParams(); - - useEffect(() => { - let mounted = true; - setLoading(true); - - /** To prevent warning : React Hook useEffect has a missing dependency */ - const getTrajectory = async (isMounted, signal) => { - /** Load the trajectories only if component mounted */ - if (isMounted && id) { - const response = await fetchTrajectory(keycloak, +id, signal); - // TODO: manage error - // if (response && response.length > 0 && response[0].code) { - // // setErrors(...response) - // } else - if (response !== null) { - setTrajectory(parseTrajectory(response)); - } - setLoading(false); - } - }; - - /** Allow to abort fetch if component is being unmounted */ - const abortController = new AbortController(); - getTrajectory(mounted, abortController.signal); - - return () => { - mounted = false; - abortController.abort(); - }; - }, [id, keycloak]); - - useEffect(() => { - const latLngPoints = trajectory?.locations.map(location => { - const locationData = location.value.split(' '); - return [locationData[2], locationData[3]]; - }) || []; - setPoints(latLngPoints); - }, [trajectory]); + const { id } = useParams(); + const { isLoading, trajectory } = useTrajectory(id); - // TODO: style error - if (errors) return <p>error</p>; + const points = trajectory?.locations.map(location => { + const locationData = location.value.split(' '); + return [locationData[2], locationData[3]]; + }) || []; // TODO: style loading - if (loading) { + if (isLoading) { return <p>loading</p>; } -- GitLab From d1397053ab295302918ca208dd28e4b1b8b67de6 Mon Sep 17 00:00:00 2001 From: Benjamin Marguin <benjamin.marguin@makina-corpus.com> Date: Thu, 3 Nov 2022 16:22:12 +0100 Subject: [PATCH 08/15] Do refetch DetailTrajectory only if not succeeded/failed Refs: tropo-group/suivi-de-projet#9 --- src/hooks/useTrajectory.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/hooks/useTrajectory.ts b/src/hooks/useTrajectory.ts index 59551a1be..68f3785f9 100644 --- a/src/hooks/useTrajectory.ts +++ b/src/hooks/useTrajectory.ts @@ -1,16 +1,28 @@ +import React from 'react'; import { useKeycloak } from '@react-keycloak/web'; import { useQuery } from '@tanstack/react-query'; -import React from 'react'; import { fetchTrajectory } from '../utils/api'; import { parseTrajectory } from '../utils/trajectory'; const useTrajectory = id => { + const [pending, setPending] = React.useState<boolean>(true); const { keycloak } = useKeycloak(); const trajectoryQueryResponse = useQuery( ['trajectory', id], () => fetchTrajectory(keycloak, +id), + { enabled: pending }, + ); + + React.useEffect( + () => { + const isDone = ['succeeded', 'failed'].includes(trajectoryQueryResponse?.data?.status || ''); + if (isDone) { + setPending(false); + } + }, + [trajectoryQueryResponse?.data?.status], ); return React.useMemo( -- GitLab From 3732fb6543fe0cd38d0763a5be47f68df577902b Mon Sep 17 00:00:00 2001 From: Benjamin Marguin <benjamin.marguin@makina-corpus.com> Date: Thu, 3 Nov 2022 17:16:04 +0100 Subject: [PATCH 09/15] Adjust main logo to limit impact of layout shift --- src/components/Layout.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 22d9cfa66..379bda2d6 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -39,9 +39,19 @@ const Layout = ({ children }) => { justifyContent: 'space-between', }} > - <Link to="/"> - <img src="/logo.png" style={{ width: 120 }} alt="tropolink" /> - </Link> + <Box component={Link} to="/"> + <Box + component="img" + src="/logo.png" + alt="Tropolink" + sx={{ + width: 120, + height: 56, + display: 'flex', + alignItems: 'center', + }} + /> + </Box> <Tabs value={currentTab} sx={{ minHeight: 0 }}> <Tab -- GitLab From f834ee31c3d7120d1e41f720379c1c4efa5f0ff5 Mon Sep 17 00:00:00 2001 From: Benjamin Marguin <benjamin.marguin@makina-corpus.com> Date: Thu, 3 Nov 2022 17:22:49 +0100 Subject: [PATCH 10/15] Install & setup material-ui-confirm Refs: tropo-group/suivi-de-projet#13 --- package-lock.json | 17 +++++++++++++++++ package.json | 1 + src/index.tsx | 15 +++++++++------ 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7100f0eaf..add784a89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "keycloak-js": "^16.1.0", "leaflet": "^1.7.1", "marked": "^4.0.8", + "material-ui-confirm": "^3.0.7", "notistack": "^2.0.3", "react": "^17.0.2", "react-dom": "^17.0.2", @@ -3757,6 +3758,16 @@ "node": ">= 12" } }, + "node_modules/material-ui-confirm": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/material-ui-confirm/-/material-ui-confirm-3.0.7.tgz", + "integrity": "sha512-HZkym2nYSdKAmcCVtj/N/ygwWz/mlbWLrLAY6oRxBvD4Z1JBEmBNC//4yGAiNKlR0/9BlC6GKdY5RnqUaTwSKA==", + "peerDependencies": { + "@mui/material": ">= 5.0.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -7527,6 +7538,12 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-4.0.8.tgz", "integrity": "sha512-dkpJMIlJpc833hbjjg8jraw1t51e/eKDoG8TFOgc5O0Z77zaYKigYekTDop5AplRoKFGIaoazhYEhGkMtU3IeA==" }, + "material-ui-confirm": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/material-ui-confirm/-/material-ui-confirm-3.0.7.tgz", + "integrity": "sha512-HZkym2nYSdKAmcCVtj/N/ygwWz/mlbWLrLAY6oRxBvD4Z1JBEmBNC//4yGAiNKlR0/9BlC6GKdY5RnqUaTwSKA==", + "requires": {} + }, "merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", diff --git a/package.json b/package.json index 83d1037c5..941919662 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "keycloak-js": "^16.1.0", "leaflet": "^1.7.1", "marked": "^4.0.8", + "material-ui-confirm": "^3.0.7", "notistack": "^2.0.3", "react": "^17.0.2", "react-dom": "^17.0.2", diff --git a/src/index.tsx b/src/index.tsx index 4cdff02b8..33a050369 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,6 +1,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ConfirmProvider } from 'material-ui-confirm'; import './index.css'; import { BrowserRouter } from 'react-router-dom'; @@ -17,12 +18,14 @@ const queryClient = new QueryClient(); ReactDOM.render( <React.StrictMode> <ThemeProvider theme={theme}> - <CssBaseline /> - <BrowserRouter> - <QueryClientProvider client={queryClient}> - <App /> - </QueryClientProvider> - </BrowserRouter> + <ConfirmProvider> + <CssBaseline /> + <BrowserRouter> + <QueryClientProvider client={queryClient}> + <App /> + </QueryClientProvider> + </BrowserRouter> + </ConfirmProvider> </ThemeProvider> </React.StrictMode>, document.getElementById('root'), -- GitLab From d84af333b269a1dac4f2c8d47f72627ddccb1447 Mon Sep 17 00:00:00 2001 From: Benjamin Marguin <benjamin.marguin@makina-corpus.com> Date: Thu, 3 Nov 2022 18:19:46 +0100 Subject: [PATCH 11/15] Use tanstack-query to fetch DetailConnectivity data Refs: tropo-group/suivi-de-projet#9 --- src/hooks/useConnectivity.ts | 39 +++++++++++++++++++ src/pages/DetailConnectivity.tsx | 65 +++++--------------------------- 2 files changed, 49 insertions(+), 55 deletions(-) create mode 100644 src/hooks/useConnectivity.ts diff --git a/src/hooks/useConnectivity.ts b/src/hooks/useConnectivity.ts new file mode 100644 index 000000000..203b1e2fc --- /dev/null +++ b/src/hooks/useConnectivity.ts @@ -0,0 +1,39 @@ +import React from 'react'; +import { useKeycloak } from '@react-keycloak/web'; +import { useQuery } from '@tanstack/react-query'; + +import { fetchConnectivity } from '../utils/api'; +import { parseConnectivity } from '../utils/connectivity'; + +const useConnectivity = id => { + const [pending, setPending] = React.useState<boolean>(true); + const { keycloak } = useKeycloak(); + + const connectivityQueryResponse = useQuery( + ['connectivity', id], + () => fetchConnectivity(keycloak, +id), + { enabled: pending }, + ); + + React.useEffect( + () => { + const isDone = ['succeeded', 'failed'].includes(connectivityQueryResponse?.data?.status || ''); + if (isDone) { + setPending(false); + } + }, + [connectivityQueryResponse?.data?.status], + ); + + return React.useMemo( + () => ({ + ...connectivityQueryResponse, + connectivity: connectivityQueryResponse.data + ? parseConnectivity(connectivityQueryResponse.data) + : null, + }), + [connectivityQueryResponse], + ); +}; + +export default useConnectivity; diff --git a/src/pages/DetailConnectivity.tsx b/src/pages/DetailConnectivity.tsx index e87786074..8c380a5b3 100644 --- a/src/pages/DetailConnectivity.tsx +++ b/src/pages/DetailConnectivity.tsx @@ -1,4 +1,4 @@ -import { ContentCopy } from '@mui/icons-material'; +import React, { useState } from 'react'; import { Box, Button, @@ -16,80 +16,35 @@ import { Typography, } from '@mui/material'; import { LoadingButton } from '@mui/lab'; +import { ContentCopy } from '@mui/icons-material'; import DownloadIcon from '@mui/icons-material/Download'; import { useKeycloak } from '@react-keycloak/web'; -import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link as RouterLink, useParams } from 'react-router-dom'; import ListLocations from '../components/ListLocations'; // import ListDates from '../components/ListDates'; import Map from '../components/Map'; -import { Connectivity } from '../entities/connectivity'; -import { fetchConnectivity } from '../utils/api'; -import { parseConnectivity } from '../utils/connectivity'; import exportJSON from '../utils/export-json'; import Loading from '../components/Loading'; import StatusChip from '../components/StatusChip'; +import useConnectivity from '../hooks/useConnectivity'; const DetailConnectivity = () => { const { t } = useTranslation(); const { keycloak } = useKeycloak(); - // const { id } = useParams() - const [connectivity, setConnectivity] = useState<Connectivity | null>(null); - const [points, setPoints] = useState<any[][]>([]); - const [ - errors, - // setErrors, - ] = useState(null); - const [loading, setLoading] = useState(true); const [busyDataQuery, setBusyDataQuery] = useState<String[]>([]); - const { id } = useParams(); - - useEffect(() => { - let mounted = true; - setLoading(true); - - /** To prevent warning : React Hook useEffect has a missing dependency */ - const getStudy = async (isMounted, signal) => { - /** Load the connectivity only if component mounted */ - if (isMounted && id) { - const response = await fetchConnectivity(keycloak, +id, signal); - - // TODO: handle errors - if (response !== null) { - setConnectivity(parseConnectivity(response)); - } - setLoading(false); - } - }; - - /** Allow to abort fetch if component is being unmounted */ - const abortController = new AbortController(); - - getStudy(mounted, abortController.signal); - - return () => { - mounted = false; - abortController.abort(); - }; - }, [id, keycloak]); - - useEffect(() => { - // console.log('connectivity', connectivity); - const latLngPoints = connectivity?.parameters.locations.map(location => { - const locationData = location.value.split(' '); - return [locationData[2], locationData[3]]; - }) || []; - setPoints(latLngPoints); - }, [connectivity]); + const { id } = useParams(); + const { isLoading, connectivity } = useConnectivity(id); - // TODO: style error - if (errors) return <p>error</p>; + const points = connectivity?.parameters.locations.map(location => { + const locationData = location.value.split(' '); + return [locationData[2], locationData[3]]; + }) || []; - if (loading) { + if (isLoading) { return ( <Loading /> ); -- GitLab From 8ca9685dd467cd55786f2e8d68973f8d9d5dc2b9 Mon Sep 17 00:00:00 2001 From: Benjamin Marguin <benjamin.marguin@makina-corpus.com> Date: Thu, 3 Nov 2022 17:49:58 +0100 Subject: [PATCH 12/15] Add confirmation for study & connectivity deletition Refs: tropo-group/suivi-de-projet#13 --- src/index.tsx | 1 + src/pages/List.tsx | 42 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 33a050369..1fadd8cd8 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -18,6 +18,7 @@ const queryClient = new QueryClient(); ReactDOM.render( <React.StrictMode> <ThemeProvider theme={theme}> + {/* @ts-ignore */} <ConfirmProvider> <CssBaseline /> <BrowserRouter> diff --git a/src/pages/List.tsx b/src/pages/List.tsx index ea72ed53c..a5d1f7ac0 100644 --- a/src/pages/List.tsx +++ b/src/pages/List.tsx @@ -23,6 +23,8 @@ import { import { useTranslation } from 'react-i18next'; import { Link as RouterLink } from 'react-router-dom'; +import { useConfirm } from 'material-ui-confirm'; + import StatusChip from '../components/StatusChip'; import { ComputationId } from '../entities/computation'; import { Study } from '../entities/study'; @@ -37,6 +39,20 @@ interface RowProps { export const Row = ({ study, onDeleteStudy, onDeleteConnectivity }: RowProps) => { const theme = useTheme(); const [open, setOpen] = React.useState(false); + const confirm = useConfirm(); + const { t } = useTranslation(); + + const defaultConfirmDeleteOptions = React.useMemo( + () => ({ + description: t('This action is irreversible!'), + confirmationText: t('Delete'), + cancellationText: t('Cancel'), + confirmationButtonProps: { variant: 'contained', color: 'error' }, + cancellationButtonProps: { variant: 'contained' }, + }), + [t], + ); + const toggleRow = React.useCallback( event => { event.stopPropagation(); @@ -45,16 +61,32 @@ export const Row = ({ study, onDeleteStudy, onDeleteConnectivity }: RowProps) => [], ); + const handleConnectivityDelete = React.useCallback( + id => event => { + event.stopPropagation(); + + // @ts-ignore + confirm({ + ...defaultConfirmDeleteOptions, + title: t('Are you sure you want to delete this connectivity?'), + }).then(() => onDeleteConnectivity(id)).catch(() => {}); + }, + [confirm, defaultConfirmDeleteOptions, t, onDeleteConnectivity], + ); + const handleStudyDelete = React.useCallback( event => { event.stopPropagation(); - onDeleteStudy(study.id); + + // @ts-ignore + confirm({ + ...defaultConfirmDeleteOptions, + title: t('Are you sure you want to delete this study?'), + }).then(() => onDeleteStudy(study.id)).catch(() => {}); }, - [onDeleteStudy, study.id], + [confirm, defaultConfirmDeleteOptions, onDeleteStudy, study.id, t], ); - const { t } = useTranslation(); - if (!study?.trajectory) { return null; } @@ -167,7 +199,7 @@ export const Row = ({ study, onDeleteStudy, onDeleteConnectivity }: RowProps) => <TableCell>{connectivity.method}</TableCell> <TableCell align="right"> <IconButton - onClick={() => onDeleteConnectivity(connectivity.id)} + onClick={handleConnectivityDelete(connectivity.id)} aria-label="Delete connectivity" size="small" sx={{ my: -2 }} -- GitLab From 60c796ee41c79f0c3e67ba5b2129fb2a3013dc82 Mon Sep 17 00:00:00 2001 From: Benjamin Marguin <benjamin.marguin@makina-corpus.com> Date: Fri, 4 Nov 2022 09:19:05 +0100 Subject: [PATCH 13/15] Alors sorting study list Refs: tropo-group/suivi-de-projet#14 --- src/pages/List.tsx | 62 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 57 insertions(+), 5 deletions(-) diff --git a/src/pages/List.tsx b/src/pages/List.tsx index a5d1f7ac0..dd1f7cb14 100644 --- a/src/pages/List.tsx +++ b/src/pages/List.tsx @@ -17,6 +17,7 @@ import { TableContainer, TableHead, TableRow, + TableSortLabel, useTheme, } from '@mui/material'; @@ -111,7 +112,7 @@ export const Row = ({ study, onDeleteStudy, onDeleteConnectivity }: RowProps) => {study.name} </TableCell> <TableCell>{study.creation_date}</TableCell> - {/* <TableCell>{study.creation_date}</TableCell> */} + <TableCell>{study.creation_date}</TableCell>{/* modification date */} <TableCell>{study.description}</TableCell> <TableCell> <AvatarGroup spacing="small" sx={{ justifyContent: 'flex-end' }}> @@ -219,6 +220,8 @@ export const Row = ({ study, onDeleteStudy, onDeleteConnectivity }: RowProps) => ); }; +type Order = 'asc' | 'desc'; + const StudyList = () => { const { t } = useTranslation(); @@ -229,6 +232,31 @@ const StudyList = () => { deleteConnectivity, } = useStudies(); + const [order, setOrder] = React.useState<Order>('desc'); + const [orderBy, setOrderBy] = React.useState<string>('modification_date'); + + const handleRequestSort = ( + event: React.MouseEvent<unknown>, + property, + ) => { + const isAsc = orderBy === property && order === 'asc'; + setOrder(isAsc ? 'desc' : 'asc'); + setOrderBy(property); + }; + + const createSortHandler = property => (event: React.MouseEvent<unknown>) => { + handleRequestSort(event, property); + }; + + const sortedStudies = React.useMemo( + () => (studies || []).slice().sort( + // @ts-expect-error + ({ [orderBy]: a = '' }, { [orderBy]: b = '' }) => ( + order === 'asc' ? a.localeCompare(b) : b.localeCompare(a)), + ), + [order, orderBy, studies], + ); + return ( <Container maxWidth="xl" sx={{ pt: 4 }}> <TableContainer component={Paper} sx={{ position: 'relative' }}> @@ -239,16 +267,40 @@ const StudyList = () => { <TableHead> <TableRow> <TableCell /> - <TableCell>{t('Name')}</TableCell> - <TableCell>{t('Creation Date')}</TableCell> - {/* <TableCell>{t('Last update')}</TableCell> */} + <TableCell> + <TableSortLabel + active={orderBy === 'name'} + direction={orderBy === 'name' ? order : 'asc'} + onClick={createSortHandler('name')} + > + {t('Name')} + </TableSortLabel> + </TableCell> + <TableCell> + <TableSortLabel + active={orderBy === 'creation_date'} + direction={orderBy === 'creation_date' ? order : 'asc'} + onClick={createSortHandler('creation_date')} + > + {t('Creation date')} + </TableSortLabel> + </TableCell> + <TableCell> + <TableSortLabel + active={orderBy === 'modification_date'} + direction={orderBy === 'modification_date' ? order : 'asc'} + onClick={createSortHandler('modification_date')} + > + {t('Modification date')} + </TableSortLabel> + </TableCell> <TableCell>{t('Description')}</TableCell> <TableCell>{t('Status')}</TableCell> <TableCell /> </TableRow> </TableHead> <TableBody> - {studies && studies.map(study => ( + {studies && sortedStudies.map(study => ( <Row key={study.id} study={study} -- GitLab From 0482a05ec5a2111199d1400ec53f3978905ee723 Mon Sep 17 00:00:00 2001 From: Benjamin Marguin <benjamin.marguin@makina-corpus.com> Date: Fri, 4 Nov 2022 09:19:05 +0100 Subject: [PATCH 14/15] Create mutation & api function to patch Study Refs: tropo-group/suivi-de-projet#15 --- src/hooks/useStudies.ts | 65 ++++++++++++++++++++++++++++++++--------- src/pages/List.tsx | 31 ++++++++++++++++++-- src/utils/api.ts | 26 +++++++++++++++++ 3 files changed, 107 insertions(+), 15 deletions(-) diff --git a/src/hooks/useStudies.ts b/src/hooks/useStudies.ts index 58e19b433..bc2a395f9 100644 --- a/src/hooks/useStudies.ts +++ b/src/hooks/useStudies.ts @@ -5,7 +5,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { useSnackbar } from 'notistack'; -import { deleteConnectivity, deleteStudy, fetchStudies } from '../utils/api'; +import { deleteConnectivity, deleteStudy, patchStudy, fetchStudies } from '../utils/api'; const useStudies = () => { const { t } = useTranslation(); @@ -14,11 +14,16 @@ const useStudies = () => { const { enqueueSnackbar } = useSnackbar(); const displaySuccess = React.useCallback( - () => enqueueSnackbar(t('The study has been deleted.'), { + str => enqueueSnackbar(str, { variant: 'success', anchorOrigin: { horizontal: 'center', vertical: 'top' }, }), - [enqueueSnackbar, t], + [enqueueSnackbar], + ); + + const displayDeletitionSuccess = React.useCallback( + () => displaySuccess(t('The study has been deleted.')), + [displaySuccess, t], ); const displayError = React.useCallback( @@ -42,14 +47,6 @@ const useStudies = () => { [displayError, queryClient], ); - const handleMutationSettle = React.useCallback( - () => { - displaySuccess(); - queryClient.invalidateQueries({ queryKey: ['studies'] }); - }, - [displaySuccess, queryClient], - ); - const deleteStudyMutation = useMutation({ mutationFn: id => deleteStudy(keycloak, id), @@ -75,7 +72,45 @@ const useStudies = () => { onError: handleMutationError, // Always refetch after error or success: - onSettled: handleMutationSettle, + onSettled: () => { + displayDeletitionSuccess(); + queryClient.invalidateQueries({ queryKey: ['studies'] }); + }, + }); + + const patchStudyMutation = useMutation({ + mutationFn: changes => patchStudy(keycloak, changes), + + // When mutate is called: + onMutate: async ({ id, ...changes }) => { + // Cancel any outgoing refetches (so they don't overwrite our optimistic update) + await queryClient.cancelQueries({ queryKey: ['studies'] }); + + // Snapshot the previous value + const previousStudies = queryClient.getQueryData(['studies']); + + // Optimistically update to the new value + queryClient.setQueryData( + ['studies'], + (prevStudies: Array<any> = []) => prevStudies.map(study => ( + study.id === id + ? { ...study, ...changes } + : study + )), + ); + + // Return a context object with the snapshotted value + return { previousStudies }; + }, + + // If the mutation fails, use the context returned from onMutate to roll back + onError: handleMutationError, + + // Always refetch after error or success: + onSettled: () => { + displaySuccess(t('Study has been updated')); + queryClient.invalidateQueries({ queryKey: ['studies'] }); + }, }); const deleteConnectivityMutation = useMutation({ @@ -108,13 +143,17 @@ const useStudies = () => { onError: handleMutationError, // Always refetch after error or success: - onSettled: handleMutationSettle, + onSettled: () => { + displayDeletitionSuccess(); + queryClient.invalidateQueries({ queryKey: ['studies'] }); + }, }); return { ...studiesQueryResponse, studies: studiesQueryResponse.data, deleteStudy: deleteStudyMutation.mutateAsync, + patchStudy: patchStudyMutation.mutateAsync, deleteConnectivity: deleteConnectivityMutation.mutateAsync, }; }; diff --git a/src/pages/List.tsx b/src/pages/List.tsx index dd1f7cb14..9d6fb30cc 100644 --- a/src/pages/List.tsx +++ b/src/pages/List.tsx @@ -2,9 +2,11 @@ import React from 'react'; import { Delete } from '@mui/icons-material'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; +import EditIcon from '@mui/icons-material/Edit'; import { AvatarGroup, Box, + Button, Collapse, Container, IconButton, @@ -34,10 +36,16 @@ import useStudies from '../hooks/useStudies'; interface RowProps { study: Study; onDeleteStudy: (id: ComputationId) => void; + onPatchStudy: (args: Object) => void; onDeleteConnectivity: (id: ComputationId) => void; } -export const Row = ({ study, onDeleteStudy, onDeleteConnectivity }: RowProps) => { +export const Row = ({ + study, + onDeleteStudy, + onDeleteConnectivity, + onPatchStudy, +}: RowProps) => { const theme = useTheme(); const [open, setOpen] = React.useState(false); const confirm = useConfirm(); @@ -88,6 +96,14 @@ export const Row = ({ study, onDeleteStudy, onDeleteConnectivity }: RowProps) => [confirm, defaultConfirmDeleteOptions, onDeleteStudy, study.id, t], ); + const handlePatchStudy = event => { + event.stopPropagation(); + onPatchStudy({ + id: study.id, + description: `${study.description}·`, + }); + }; + if (!study?.trajectory) { return null; } @@ -113,7 +129,16 @@ export const Row = ({ study, onDeleteStudy, onDeleteConnectivity }: RowProps) => </TableCell> <TableCell>{study.creation_date}</TableCell> <TableCell>{study.creation_date}</TableCell>{/* modification date */} - <TableCell>{study.description}</TableCell> + <TableCell> + <Button + size="small" + onClick={handlePatchStudy} + sx={{ mr: 1, minWidth: 0, opacity: 0.2, '&:hover': { opacity: 1 } }} + > + <EditIcon fontSize="small" /> + </Button> + {study.description} + </TableCell> <TableCell> <AvatarGroup spacing="small" sx={{ justifyContent: 'flex-end' }}> <StatusChip @@ -230,6 +255,7 @@ const StudyList = () => { studies, deleteStudy, deleteConnectivity, + patchStudy, } = useStudies(); const [order, setOrder] = React.useState<Order>('desc'); @@ -305,6 +331,7 @@ const StudyList = () => { key={study.id} study={study} onDeleteStudy={id => deleteStudy(id)} + onPatchStudy={changes => patchStudy(changes)} onDeleteConnectivity={id => deleteConnectivity(id)} /> ))} diff --git a/src/utils/api.ts b/src/utils/api.ts index 0d314415e..ac208e145 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -243,6 +243,32 @@ export const saveTrajectory = async ( return null; }); +export const patchStudy = async (keycloak, { id = null, ...changes } = {}) => + id && keycloak + .updateToken(MIN_VALIDITY) + .then(async () => { + try { + return await fetch(`${API_HOST}/studies/${id}/`, { + method: 'PATCH', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${keycloak.token}`, + }, + body: JSON.stringify(changes), + }).then(trajectories => + (trajectories.ok ? trajectories.json() : { code: trajectories.status })); + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + return null; + } + }) + .catch(() => { + keycloak.clearToken(); + return null; + }); + export const saveConnectivity = async ( keycloak: KeycloakInstance, data: ConnectivityModel, -- GitLab From 888c220ca915ede467b19b6d754d424903444afd Mon Sep 17 00:00:00 2001 From: Benjamin Marguin <benjamin.marguin@makina-corpus.com> Date: Fri, 4 Nov 2022 09:19:05 +0100 Subject: [PATCH 15/15] Create modal to edit Study description Refs: tropo-group/suivi-de-projet#15 --- src/components/StudyEditor.tsx | 53 ++++++++++++++++++++++++++++++++++ src/pages/List.tsx | 10 ++++++- 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 src/components/StudyEditor.tsx diff --git a/src/components/StudyEditor.tsx b/src/components/StudyEditor.tsx new file mode 100644 index 000000000..10daf4532 --- /dev/null +++ b/src/components/StudyEditor.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + TextField, +} from '@mui/material'; +import { useTranslation } from 'react-i18next'; + +const StudyEditor = ({ + study, + onValidation = () => {}, + onClose = () => {}, +}) => { + const { t } = useTranslation(); + const fieldRef = React.useRef(); + + const handleSave = () => { + // @ts-expect-error + onValidation({ id: study.id, description: fieldRef?.current?.value }); + onClose(); + }; + + return ( + <Dialog open={Boolean(study)} onClose={onClose}> + {Boolean(study) && ( + <> + <DialogTitle>{study.name}</DialogTitle> + <DialogContent sx={{ minWidth: { xs: 0, sm: 500 } }}> + <TextField + autoFocus + inputRef={fieldRef} + defaultValue={study.description} + margin="dense" + id="description" + label={t('Description')} + fullWidth + variant="standard" + /> + </DialogContent> + <DialogActions> + <Button onClick={onClose}>{t('Cancel')}</Button> + <Button onClick={handleSave}>{t('Save')}</Button> + </DialogActions> + </> + )} + </Dialog> + ); +}; + +export default StudyEditor; diff --git a/src/pages/List.tsx b/src/pages/List.tsx index 9d6fb30cc..7b153a8c1 100644 --- a/src/pages/List.tsx +++ b/src/pages/List.tsx @@ -32,6 +32,7 @@ import StatusChip from '../components/StatusChip'; import { ComputationId } from '../entities/computation'; import { Study } from '../entities/study'; import useStudies from '../hooks/useStudies'; +import StudyEditor from '../components/StudyEditor'; interface RowProps { study: Study; @@ -260,6 +261,7 @@ const StudyList = () => { const [order, setOrder] = React.useState<Order>('desc'); const [orderBy, setOrderBy] = React.useState<string>('modification_date'); + const [studyToEdit, setStudyToEdit] = React.useState(); const handleRequestSort = ( event: React.MouseEvent<unknown>, @@ -331,13 +333,19 @@ const StudyList = () => { key={study.id} study={study} onDeleteStudy={id => deleteStudy(id)} - onPatchStudy={changes => patchStudy(changes)} + onPatchStudy={() => setStudyToEdit(studies.find(({ id }) => (id === study.id)))} onDeleteConnectivity={id => deleteConnectivity(id)} /> ))} </TableBody> </Table> </TableContainer> + + <StudyEditor + study={studyToEdit} + onValidation={patchStudy} + onClose={() => setStudyToEdit(null)} + /> </Container> ); }; -- GitLab