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:
+              '&copy; <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:
-            '&copy; <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