commit f2db3e05a895114c7a667bb1c07a8cc29a16d82b
Author: Vaclav Honzik <vaclavhonzik98@gmail.com>
Date:   Thu Apr 7 21:11:43 2022 +0200

    re #9369 - catalog detail fetching

diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 2bcd3ad..c1658e0 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -8,6 +8,7 @@ import { ThemeProvider } from '@emotion/react'
 import { useSelector } from 'react-redux'
 import { RootState } from './features/redux/store'
 import Login from './features/Auth/Login'
+import CatalogItemDetail from './features/Catalog/CatalogItemDetail'
 
 const App = () => {
     const theme: Theme = useSelector((state: RootState) => state.theme.theme)
@@ -28,6 +29,10 @@ const App = () => {
                     <Routes>
                         <Route path="/" element={<Home />} />
                         <Route path="/catalog" element={<Catalog />} />
+                        <Route
+                            path="/catalog/:itemId"
+                            element={<CatalogItemDetail />}
+                        />
                         <Route path="/login" element={<Login />} />
                         <Route path="*" element={<NotFound />} />
                     </Routes>
diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts
index 843a52d..fa0a2bb 100644
--- a/frontend/src/api/api.ts
+++ b/frontend/src/api/api.ts
@@ -39,12 +39,18 @@ axiosInstance.interceptors.response.use(
     async (err) => {
         const originalConfig = err.config
 
+
         // Original URL might be login in which case we don't want to refresh the access token
         // Since the user just failed to log in and no token expired
         if (originalConfig.url === '/login' || !err.response) {
             return Promise.reject(err)
         }
 
+        // If the error is not a 401 reject this
+        if (err.response.status !== 401) {
+            return Promise.reject(err)
+        }
+
         // We need to set the refresh token in the auth header
         const oldRefreshToken = store.getState().user.refreshToken
 
diff --git a/frontend/src/config/conf.ts b/frontend/src/config/conf.ts
index fd43d2a..2d1174f 100644
--- a/frontend/src/config/conf.ts
+++ b/frontend/src/config/conf.ts
@@ -1,3 +1,5 @@
-export default {
+const conf = {
     baseUrl: process.env.REACT_APP_API_BASE_URL || 'http://localhost:8080'
-}
\ No newline at end of file
+}
+
+export default conf;
\ No newline at end of file
diff --git a/frontend/src/features/Catalog/CatalogItemDetail.tsx b/frontend/src/features/Catalog/CatalogItemDetail.tsx
index 53ff9ce..e4caaad 100644
--- a/frontend/src/features/Catalog/CatalogItemDetail.tsx
+++ b/frontend/src/features/Catalog/CatalogItemDetail.tsx
@@ -1,23 +1,48 @@
-import { Box, Divider, Grid, Paper, Skeleton, Typography } from '@mui/material'
-import { Fragment, ReactNode } from 'react'
-import { CatalogItemDto } from '../../swagger/data-contracts'
+import {
+    Box,
+    Divider,
+    Grid,
+    Paper,
+    Skeleton,
+    Stack,
+    Typography,
+} from '@mui/material'
+import { Fragment, useEffect } from 'react'
+import { useDispatch, useSelector } from 'react-redux'
+import { useParams } from 'react-router-dom'
+import { RootState } from '../redux/store'
+import ConsumeLastError from '../Reusables/ConsumeLastError'
+import ContentLoading from '../Reusables/ContentLoading'
+import { consumeCatalogItemLastErr, setItemLoading } from './catalogSlice'
+import { getCatalogItem } from './catalogThunks'
 
 const CatalogItemDetail = () => {
-    // TODO fetch from api
-    const item: CatalogItemDto = {
-        id: '1',
-        name: 'Abaindi',
-        alternativeNames: ['Abaindi', 'Tell muhamad', 'and his waifu'],
-        certainty: 100,
-        longitude: 27.22,
-        latitude: 27.22,
-        bibliography: ['A', 'V muhamad', '😭😂'],
-        writtenForms: ['sadsadsa', 'sadTell muhamad', 'and his waifu'],
-        types: ['City', 'Town', 'Village'],
-        countries: ['Sidonu', 'Lebanon', 'Syria'],
-    }
+    // itemId from query params
+    const { itemId } = useParams()
 
-    const mapToRow = (rowName: string, items: string[]): ReactNode => (
+    // Loaded item
+    const item = useSelector((state: RootState) => state.catalog.catalogItem)
+
+    // Whether the item is loading
+    const isItemLoading = useSelector(
+        (state: RootState) => state.catalog.isItemLoading
+    )
+
+    // Redux dispatch
+    const dispatch = useDispatch()
+
+    const lastErr = useSelector(
+        (state: RootState) => state.catalog.catalogItemLastErr
+    )
+
+    // Fetch the item from the api after mounting the component
+    useEffect(() => {
+        dispatch(setItemLoading(true))
+        dispatch(getCatalogItem(itemId as string))
+    }, [dispatch, itemId])
+
+    // Maps catalogItem property to corresponding table row
+    const mapToRow = (rowName: string, items: string[]) => (
         <Fragment>
             <Grid sx={{ my: 2 }} container justifyContent="space-around">
                 <Grid item xs={8} sx={{ px: 1 }}>
@@ -27,87 +52,93 @@ const CatalogItemDetail = () => {
                     {items.map((item) => (
                         <Typography>{item}</Typography>
                     ))}
-                    {items.map((itm, idx) => () => <Fragment></Fragment>)}
                 </Grid>
             </Grid>
         </Fragment>
     )
 
+    // Catalog item rows
     const rows = [
         {
             rowName: 'Name',
-            items: [item.name],
+            items: [item?.name],
         },
         {
             rowName: 'Alternative Names',
-            items: item.alternativeNames,
+            items: item?.alternativeNames,
         },
         {
             rowName: 'Written Forms',
-            items: item.writtenForms,
+            items: item?.writtenForms,
         },
         {
             rowName: 'Type',
-            items: item.types,
+            items: item?.types,
         },
         {
             rowName: 'State or Territory',
-            items: item.countries,
+            items: item?.countries,
         },
         {
             rowName: 'Coordinates',
-            items: [`${item.longitude}°, ${item.latitude}°`],
+            items: [`${item?.longitude}°, ${item?.latitude}°`],
         },
-
         {
             rowName: 'Certainty',
-            // @ts-ignore
-            items: [item.certainty],
+            items: [item?.certainty],
         },
         {
             rowName: 'Bibliography',
-            items: item.bibliography,
+            items: item?.bibliography,
         },
-        // TODO description
     ]
 
     return (
         // TODO remove min height
         <Paper style={{ minHeight: '100vh', borderRadius: 0 }} elevation={2}>
-            <Grid container justifyContent="space-around">
-                {/* <Paper style={{ minHeight: '100vh', borderRadius: 0 }}> */}
-                <Grid item xs={6} sx={{ px: 2 }}>
-                    {rows.map((row, idx) => {
-                        return (
-                            <Fragment>
-                                {mapToRow(
-                                    row.rowName as string,
-                                    row.items as string[]
-                                )}
-                                {idx === rows.length - 1 ? null : <Divider />}
-                            </Fragment>
-                        )
-                    })}
-                </Grid>
+            <ConsumeLastError
+                consumeFn={consumeCatalogItemLastErr}
+                err={lastErr}
+            />
 
-                <Grid item xs={6}>
-                    <Box sx={{ px: 2, py: 4 }}>
-                        <Typography variant="h4" sx={{mb: 4}} fontWeight="bold">Map</Typography>
+            {isItemLoading ? <ContentLoading /> : null}
+            {!isItemLoading && item ? (
+                <Grid container justifyContent="space-around">
+                    <Grid item xs={6} sx={{ px: 2 }}>
+                        {rows.map((row, idx) => {
+                            const maxIdx = rows.length - 1
+                            return (
+                                <Fragment>
+                                    {mapToRow(
+                                        row.rowName as string,
+                                        row.items as string[]
+                                    )}
+                                    {idx === maxIdx ? null : <Divider />}
+                                </Fragment>
+                            )
+                        })}
+                    </Grid>
 
-                        <Skeleton
-                        animation="pulse"
-                            variant="rectangular"
-                            width={'100%'}
-                            height={400}
-                        />
-                        {/* <Skeleton animation="wave" />
-                        <Skeleton animation="wave" />
-                        <Skeleton animation="wave" />
-                        <Skeleton animation="wave" /> */}
-                    </Box>
+                    <Grid item xs={6}>
+                        <Box sx={{ px: 2, py: 4 }}>
+                            <Typography
+                                variant="h4"
+                                sx={{ mb: 4 }}
+                                fontWeight="bold"
+                            >
+                                Map
+                            </Typography>
+
+                            <Skeleton
+                                animation="pulse"
+                                variant="rectangular"
+                                width="100%"
+                                height={400}
+                            />
+                        </Box>
+                    </Grid>
                 </Grid>
-                {/* </Paper> */}
-            </Grid>
+            ) : null}
         </Paper>
     )
 }
diff --git a/frontend/src/features/Catalog/CatalogTable.tsx b/frontend/src/features/Catalog/CatalogTable.tsx
index 1687be9..49e23a0 100644
--- a/frontend/src/features/Catalog/CatalogTable.tsx
+++ b/frontend/src/features/Catalog/CatalogTable.tsx
@@ -1,4 +1,5 @@
 import {
+    Link,
     Table,
     TableBody,
     TableCell,
@@ -9,8 +10,12 @@ import {
 } from '@mui/material'
 import { Fragment, useEffect, useState } from 'react'
 import { useDispatch, useSelector } from 'react-redux'
+import { Link as RouterLink } from 'react-router-dom'
 import { CatalogItemDto } from '../../swagger/data-contracts'
 import { RootState } from '../redux/store'
+import ConsumeLastError from '../Reusables/ConsumeLastError'
+import ContentLoading from '../Reusables/ContentLoading'
+import { consumeCatalogLastErr } from './catalogSlice'
 import { getCatalogItems } from './catalogThunks'
 
 // Catalog table component
@@ -19,18 +24,28 @@ const CatalogTable = () => {
     const rowsPerPage = [5, 10, 15, 20] // number of rows per page
 
     // Selected rows per page
-    const [selectedRowsPerPage, setSelectedRowsPerPage] = useState(rowsPerPage[0])
+    const [selectedRowsPerPage, setSelectedRowsPerPage] = useState(
+        rowsPerPage[0]
+    )
 
     // Fetched items from the store
     const items = useSelector((state: RootState) => state.catalog.catalogItems)
+    const areItemsLoading = useSelector(
+        (state: RootState) => state.catalog.isListLoading
+    )
+    const fetchError = useSelector(
+        (state: RootState) => state.catalog.catalogLastErr
+    )
 
     // Redux dispatch
     const dispatch = useDispatch()
 
     // When changing rows per page set the selected number and reset to the first page
-    const onRowsPerPageChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+    const onRowsPerPageChange = (
+        event: React.ChangeEvent<HTMLInputElement>
+    ) => {
         setSelectedRowsPerPage(Number(event.target.value))
-        setPage(0);
+        setPage(0)
     }
 
     // Use effect hook to fetch rows from the server
@@ -56,7 +71,10 @@ const CatalogTable = () => {
     // Maps catalogItem to corresponding table row
     const mapItemColumnValues = (item: CatalogItemDto) => (
         <Fragment>
-            {mapValueOrDefault(item.name)}
+            {/* {mapValueOrDefault(item.name)} */}
+            <TableCell align="center">
+                <Link component={RouterLink} to={`/catalog/${item.id as string}`}>{item.name}</Link>
+            </TableCell>
             {mapValueOrDefault(item.alternativeNames?.join(', '))}
             {mapValueOrDefault(item.writtenForms?.join(', '))}
             {mapValueOrDefault(item.types?.join(', '))}
@@ -74,44 +92,53 @@ const CatalogTable = () => {
 
     return (
         <Fragment>
-            <TableContainer>
-                <Table
-                    stickyHeader
-                    sx={{ minWidth: 400 }}
-                    aria-label="catalogTable"
-                >
-                    <TableHead>
-                        <TableRow>
-                            {columns.map((col, idx) => (
-                                <TableCell key={idx} align="center">
-                                    {col}
-                                </TableCell>
-                            ))}
-                        </TableRow>
-                    </TableHead>
-                    <TableBody>
-                        {items
-                            .slice(
-                                page * rowsPerPage[0],
-                                page * rowsPerPage[0] + rowsPerPage[0]
-                            )
-                            .map((row, idx) => (
-                                <TableRow hover tabIndex={-1} key={idx}>
-                                    {mapItemColumnValues(row)}
-                                </TableRow>
-                            ))}
-                    </TableBody>
-                </Table>
-            </TableContainer>
-            <TablePagination
-                rowsPerPageOptions={rowsPerPage}
-                component="div"
-                count={items.length}
-                rowsPerPage={selectedRowsPerPage}
-                page={page}
-                onPageChange={(_, newPage) => setPage(newPage)}
-                onRowsPerPageChange={onRowsPerPageChange}
+            <ConsumeLastError
+                err={fetchError}
+                consumeFn={consumeCatalogLastErr}
             />
+            {areItemsLoading && !fetchError ? <ContentLoading /> : null}
+            {!areItemsLoading && !fetchError ? (
+                <Fragment>
+                    <TableContainer>
+                        <Table
+                            stickyHeader
+                            sx={{ minWidth: 400 }}
+                            aria-label="catalogTable"
+                        >
+                            <TableHead>
+                                <TableRow>
+                                    {columns.map((col, idx) => (
+                                        <TableCell key={idx} align="center">
+                                            {col}
+                                        </TableCell>
+                                    ))}
+                                </TableRow>
+                            </TableHead>
+                            <TableBody>
+                                {items
+                                    .slice(
+                                        page * rowsPerPage[0],
+                                        page * rowsPerPage[0] + rowsPerPage[0]
+                                    )
+                                    .map((row, idx) => (
+                                        <TableRow hover tabIndex={-1} key={idx}>
+                                            {mapItemColumnValues(row)}
+                                        </TableRow>
+                                    ))}
+                            </TableBody>
+                        </Table>
+                    </TableContainer>
+                    <TablePagination
+                        rowsPerPageOptions={rowsPerPage}
+                        component="div"
+                        count={items.length}
+                        rowsPerPage={selectedRowsPerPage}
+                        page={page}
+                        onPageChange={(_, newPage) => setPage(newPage)}
+                        onRowsPerPageChange={onRowsPerPageChange}
+                    />
+                </Fragment>
+            ) : null}
         </Fragment>
     )
 }
diff --git a/frontend/src/features/Catalog/catalogSlice.ts b/frontend/src/features/Catalog/catalogSlice.ts
index 0c93393..a1b0a73 100644
--- a/frontend/src/features/Catalog/catalogSlice.ts
+++ b/frontend/src/features/Catalog/catalogSlice.ts
@@ -5,9 +5,10 @@ import { getCatalogItem, getCatalogItems } from './catalogThunks'
 export interface CatalogState {
     catalogItems: CatalogItemDto[] // Items shown in the table
     isListLoading: boolean
-    lastErr?: string
+    catalogLastErr?: string // Error while fetching all catalog items
     catalogItem?: CatalogItemDto // Item shown in detail
     isItemLoading: boolean
+    catalogItemLastErr?: string // Error while fetching catalog item
 }
 
 const initialState: CatalogState = {
@@ -18,18 +19,24 @@ const initialState: CatalogState = {
 
 export const catalogSlice = createSlice({
     name: 'catalog',
-
     initialState,
-
     reducers: {
         setItemListLoading: (state, action) => ({
             ...state,
             isListLoading: action.payload,
         }),
+        consumeCatalogLastErr: (state) => ({
+            ...state,
+            catalogLastErr: undefined,
+        }),
         setItemLoading: (state, action) => ({
             ...state,
             isItemLoading: action.payload,
         }),
+        consumeCatalogItemLastErr: (state) => ({
+            ...state,
+            catalogItemLastErr: undefined,
+        }),
     },
 
     extraReducers: (builder) => {
@@ -51,13 +58,14 @@ export const catalogSlice = createSlice({
             ...state,
             lastErr: action.payload as string,
         }))
-        builder.addCase(getCatalogItem.fulfilled, (state, action) => ({
-            ...state, catalogItem: action.payload, isItemLoading: false,
-        }))
-        builder.addCase(getCatalogItem.rejected, (state, action) => ({
-            ...state, lastErr: action.payload as string
-        }))
     },
 })
 
+export const {
+    setItemListLoading,
+    setItemLoading,
+    consumeCatalogItemLastErr,
+    consumeCatalogLastErr,
+} = catalogSlice.actions
+
 export const catalogReducer = catalogSlice.reducer
diff --git a/frontend/src/features/Catalog/catalogThunks.ts b/frontend/src/features/Catalog/catalogThunks.ts
index 2cd107b..66e0a61 100644
--- a/frontend/src/features/Catalog/catalogThunks.ts
+++ b/frontend/src/features/Catalog/catalogThunks.ts
@@ -21,7 +21,7 @@ export const getCatalogItems = createAsyncThunk(
 
 export const getCatalogItem = createAsyncThunk(
     'catalog/getCatalogItem',
-    async (id: number) => {
+    async (id: string) => {
         try {
             const { data, status } = await axiosInstance.get(
                 `/catalog-items/${id}`
diff --git a/frontend/src/features/Reusables/ConsumeLastError.tsx b/frontend/src/features/Reusables/ConsumeLastError.tsx
new file mode 100644
index 0000000..7861e9b
--- /dev/null
+++ b/frontend/src/features/Reusables/ConsumeLastError.tsx
@@ -0,0 +1,32 @@
+import { Typography } from '@mui/material'
+import { Fragment, FunctionComponent, useEffect, useState } from 'react'
+import { useDispatch } from 'react-redux'
+
+export interface ConsumeLastErrorProps {
+    // Observable error from the store
+    err?: string
+    // Consume function that clears the error in the store
+    consumeFn: () => void
+}
+
+// Utility component that consumes the last error from the store and shows it
+const ConsumeLastError: FunctionComponent<ConsumeLastErrorProps> = ({
+    err: error,
+    consumeFn,
+}) => {
+    const dispatch = useDispatch()
+    const [err, setErr] = useState('')
+
+    useEffect(() => {
+        if (error) {
+            setErr(`${error}`)
+            dispatch(consumeFn())
+        }
+    }, [consumeFn, dispatch, error])
+
+    return <Fragment>
+        {err ? <Typography variant="h6" fontWeight="400">{err}</Typography> : null}
+    </Fragment>
+}
+
+export default ConsumeLastError
diff --git a/frontend/src/features/Reusables/ContentLoading.tsx b/frontend/src/features/Reusables/ContentLoading.tsx
new file mode 100644
index 0000000..c3e5875
--- /dev/null
+++ b/frontend/src/features/Reusables/ContentLoading.tsx
@@ -0,0 +1,19 @@
+import { Skeleton, Stack, Typography } from "@mui/material";
+import { Fragment } from "react";
+
+/**
+ * Component that shows a skeleton while the specified item is loading
+ * @returns 
+ */
+const ContentLoading = () => (
+    <Fragment>
+        <Typography align="center" fontWeight={400}>
+            Loading ...
+        </Typography>
+        <Stack justifyContent="center" alignItems="center">
+            <Skeleton variant="rectangular" width="100%" height="25%" />
+        </Stack>
+    </Fragment>
+)
+
+export default ContentLoading
