diff --git a/frontend/.env b/frontend/.env index ae3abf8f093cb9db6236ce4dd3eb20320ad09cc1..d6e54cd6264755ebdacac63d9fb4ee1f394f476e 100644 --- a/frontend/.env +++ b/frontend/.env @@ -1 +1,2 @@ -REACT_APP_API_BASE_URL=/api \ No newline at end of file +REACT_APP_API_BASE_URL=/api +REACT_APP_DEV_ENV=true \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 9851cc8b31f4840455ba469626d78efaee7beff6..35adf2e48db7398e127be8b229208a57ed8a987e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -63,6 +63,7 @@ "@types/react-dom": "^17.0.9", "@types/react-redux": "^7.1.23", "@types/redux-persist": "^4.3.1", - "@types/yup": "^0.29.13" + "@types/yup": "^0.29.13", + "redux-devtools-extension": "^2.13.9" } } diff --git a/frontend/src/config/conf.ts b/frontend/src/config/conf.ts index 7c592a317d2799ee429584d951aaaeb2594dc4cb..04125dbb4ee8f380ae971a58dc894d07542ed22c 100644 --- a/frontend/src/config/conf.ts +++ b/frontend/src/config/conf.ts @@ -1,5 +1,8 @@ const conf = { - baseUrl: '/api' + baseUrl: + process.env.REACT_APP_DEV_ENV === 'true' + ? 'http://localhost:8080' + : '/api', } -export default conf; \ No newline at end of file +export default conf diff --git a/frontend/src/features/Catalog/Catalog.tsx b/frontend/src/features/Catalog/Catalog.tsx index eac9af309b25478ff8fb692b90022d9a0484f5e1..44f1d030e099c8ce1ad168461dd640ae132092f3 100644 --- a/frontend/src/features/Catalog/Catalog.tsx +++ b/frontend/src/features/Catalog/Catalog.tsx @@ -1,22 +1,14 @@ import { - Box, - Button, - Collapse, Container, - Grid, Paper, - Stack, - TextField, Typography, } from '@mui/material' import CatalogTable from './CatalogTable' -import { Fragment, useState } from 'react' +import { Fragment } from 'react' +import CatalogFilter from './CatalogFilter' const Catalog = () => { - const [filterOpen, setFilterOpen] = useState(false) - const toggleFilter = () => { - setFilterOpen(!filterOpen) - } + return ( <Fragment> @@ -27,68 +19,8 @@ const Catalog = () => { > <Container sx={{ mt: 4 }}> <Typography variant="h3" sx={{mb: 2}} fontWeight="bold" >Catalog</Typography> - <Button variant="outlined" color="primary" onClick={toggleFilter}> - Filter - </Button> - <Collapse in={filterOpen} timeout="auto" unmountOnExit> - <Grid container spacing={1} alignItems="stretch"> - <Grid item xs={6}> - <Stack - direction="column" - spacing={1} - sx={{ mt: 2 }} - > - <Stack direction="row" spacing={2}> - <TextField - size="small" - id="name" - label="Name" - /> - <TextField - size="small" - id="type" - label="Type" - /> - <TextField - size="small" - id="coordinates" - label="Coordinates" - /> - </Stack> - <Stack direction="row" spacing={2}> - <TextField - size="small" - id="writtenForm" - label="Written form" - /> - <TextField - size="small" - id="stateOrTerritory" - label="State or territory" - /> - <TextField - size="small" - id="groupBy" - label="Group by" - /> - </Stack> - </Stack> - </Grid> - <Grid item xs sx={{ mt: 'auto', ml: 1, mb: 1 }}> - <Stack - direction="row" - justifyContent="flex-start" - alignItems="flex-end" - > - <Button variant="outlined">Search</Button> - </Stack> - </Grid> - </Grid> - </Collapse> - - <Box sx={{ mt: 4 }}> + <CatalogFilter /> <CatalogTable /> - </Box> </Container> </Paper> </Fragment> diff --git a/frontend/src/features/Catalog/CatalogFilter.tsx b/frontend/src/features/Catalog/CatalogFilter.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bbbacc3a5619ecf78d7dee417a0c635e0bde20be --- /dev/null +++ b/frontend/src/features/Catalog/CatalogFilter.tsx @@ -0,0 +1,89 @@ +import { Button, Collapse, Grid, Stack, TextField } from '@mui/material' +import { Fragment, useState } from 'react' +import { useDispatch } from 'react-redux' +import { setFilter, CatalogFilter as Filter } from './catalogSlice' +import { fetchItems } from './catalogThunks' + +const CatalogFilter = () => { + const dispatch = useDispatch() + + const [filterOpen, setFilterOpen] = useState(false) + const toggleFilter = () => { + setFilterOpen(!filterOpen) + } + + // current filter object + const filter: Filter = {} + const applyFilter = () => { + dispatch(fetchItems()) + } + + + return ( + <Fragment> + <Button variant="outlined" color="primary" onClick={toggleFilter}> + Filter + </Button> + <Collapse in={filterOpen} timeout="auto" unmountOnExit> + <Grid container spacing={1} alignItems="stretch"> + <Grid item xs={6}> + <Stack direction="column" spacing={1} sx={{ mt: 2 }}> + <Stack direction="row" spacing={2}> + <TextField + size="small" + id="name" + label="Name" + onChange={(e: any) => { + filter.name = e.target.value + dispatch(setFilter(filter)) + }} + /> + <TextField + size="small" + id="type" + label="Type" + onChange={(e: any) => { + filter.type = e.target.value + dispatch(setFilter(filter)) + }} + /> + </Stack> + <Stack direction="row" spacing={2}> + <TextField + size="small" + id="writtenForm" + label="Written form" + /> + <TextField + size="small" + id="stateOrTerritory" + label="State or territory" + onChange={(e: any) => { + filter.country = e.target.value + dispatch(setFilter(filter)) + }} + /> + <TextField + size="small" + id="groupBy" + label="Group by" + /> + </Stack> + </Stack> + </Grid> + <Grid item xs sx={{ mt: 'auto', ml: 1, mb: 1 }}> + <Stack + direction="row" + justifyContent="flex-start" + alignItems="flex-end" + > + <Button variant="outlined" onClick={applyFilter}>Search</Button> + </Stack> + </Grid> + </Grid> + </Collapse> + </Fragment> + ) +} + +export default CatalogFilter diff --git a/frontend/src/features/Catalog/CatalogTable.tsx b/frontend/src/features/Catalog/CatalogTable.tsx index d1318bf258747b43e1c2979a78767aa316740bf0..098cb784ed376cd743d50b1501e744e499328204 100644 --- a/frontend/src/features/Catalog/CatalogTable.tsx +++ b/frontend/src/features/Catalog/CatalogTable.tsx @@ -13,10 +13,10 @@ import { Link as RouterLink } from 'react-router-dom' import { CatalogItemDto } from '../../swagger/data-contracts' import ShowErrorIfPresent from '../Reusables/ShowErrorIfPresent' import ContentLoading from '../Reusables/ContentLoading' -import axiosInstance from '../../api/api' - -const apiError = - 'Error while fetching data from the server, please try again later.' +import { RootState } from '../redux/store' +import { useDispatch, useSelector } from 'react-redux' +import { consumeError, setLoading } from './catalogSlice' +import { fetchItems } from './catalogThunks' // Catalog table component const CatalogTable = () => { @@ -28,9 +28,12 @@ const CatalogTable = () => { rowsPerPage[0] ) - const [items, setItems] = useState<CatalogItemDto[]>([]) - const [areItemsLoading, setAreItemsLoading] = useState(true) - const [err, setErr] = useState<string | undefined>(undefined) + // Subscribe to the store + const items = useSelector((state: RootState) => state.catalog.items) + const loading = useSelector((state: RootState) => state.catalog.loading) + const apiError = useSelector((state: RootState) => state.catalog.error) + + const [displayError, setDisplayError] = useState<string | undefined>(undefined) // When changing rows per page set the selected number and reset to the first page const onRowsPerPageChange = ( @@ -40,28 +43,27 @@ const CatalogTable = () => { setPage(0) } - // Use effect hook to fetch rows from the server + const dispatch = useDispatch() + useEffect(() => { - // Function to fetch items from the API - const fetchItems = async () => { - try { - const { data, status } = await axiosInstance.get( - '/catalog-items' - ) - if (status !== 200) { - setErr(apiError) - return - } + // Fetch items when the component is mounted + // This will automatically search whenever the filter changes + dispatch(fetchItems()) - setItems(data) - setAreItemsLoading(false) - } catch (err: any) { - setErr(apiError) - } + return () => { + // Invalidate the state when unmounting so that the old list is not rerendered when the user returns to the page + dispatch(setLoading()) + } + }, [dispatch]) + + // Use effect to read the error and consume it + useEffect(() => { + if (apiError) { + setDisplayError(apiError) + dispatch(consumeError()) } + }, [apiError, dispatch]) - fetchItems() - }, []) // Name of columns in the header const columns = [ @@ -107,10 +109,10 @@ const CatalogTable = () => { return ( <Fragment> - <ShowErrorIfPresent err={err} /> - {areItemsLoading && !err ? <ContentLoading /> : null} - {!areItemsLoading && !err ? ( - <Fragment> + <ShowErrorIfPresent err={displayError} /> + {loading && !displayError ? <ContentLoading /> : null} + {!loading && !displayError ? ( + <Fragment> <TableContainer> <Table stickyHeader diff --git a/frontend/src/features/Catalog/catalogSlice.tsx b/frontend/src/features/Catalog/catalogSlice.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cf4248e58922d77fefcee117af0750737d2f0696 --- /dev/null +++ b/frontend/src/features/Catalog/catalogSlice.tsx @@ -0,0 +1,62 @@ +import { createSlice } from '@reduxjs/toolkit' +import { CatalogItemDto } from '../../swagger/data-contracts' +import { fetchItems } from './catalogThunks' + +export interface CatalogFilter { + name?: string + type?: string + country?: string +} + +export interface CatalogState { + items: CatalogItemDto[] // list of all fetched items + filter: CatalogFilter // filter object + loading: boolean // whether the catalog is loading + error?: string +} + +const initialState: CatalogState = { + items: [], + filter: {}, + loading: true, + error: undefined, +} + +const catalogSlice = createSlice({ + name: 'catalog', + initialState, + reducers: { + setFilter: (state, action) => ({ + ...state, + filter: {...action.payload}, + }), + clearFilter: (state, action) => ({ + ...state, + loading: true, + filter: {}, + }), + clear: (state) => ({ ...initialState }), + setLoading: (state) => ({ ...state, loading: true }), + consumeError: (state) => ({ ...state, error: undefined }), + }, + extraReducers: (builder) => { + builder.addCase(fetchItems.pending, (state) => ({ + ...state, + loading: true, + })) + builder.addCase(fetchItems.fulfilled, (state, action) => ({ + ...state, + items: action.payload, + loading: false, + })) + builder.addCase(fetchItems.rejected, (state, action) => ({ + ...state, + loading: false, + error: action.payload as string, + })) + }, +}) + +export const { setFilter, clearFilter, clear, setLoading, consumeError } = catalogSlice.actions +const reducer = catalogSlice.reducer +export default reducer diff --git a/frontend/src/features/Catalog/catalogThunks.tsx b/frontend/src/features/Catalog/catalogThunks.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7e2e9efba14e7eab57e4d235ead8e6a46d308e1e --- /dev/null +++ b/frontend/src/features/Catalog/catalogThunks.tsx @@ -0,0 +1,40 @@ +import { createAsyncThunk } from '@reduxjs/toolkit' +import axiosInstance from '../../api/api' +import { CatalogFilter, CatalogState } from './catalogSlice' + +const apiError = 'Error, server is currently unavailable.' + +// Builds query string from the filter object +const buildQuery = (catalogFilter: CatalogFilter): string => { + return Object.entries(catalogFilter).length === 0 + ? '' + : `?${Object.entries(catalogFilter) + .map(([key, value]) => `${key}=${value}`) + .join('&')}` +} + +// Thunk to fetch catalog items from the API +export const fetchItems = createAsyncThunk( + 'catalog/fetchItems', + async (dispatch, { getState }) => { + try { + // To make typescript happy we fool it like this + const { catalog } = getState() as { catalog: CatalogState } + + + // Send request with the filter + const { data, status } = await axiosInstance.get( + `/catalog-items${buildQuery(catalog.filter)}` + ) + + // If the request was successful return the items + if (status === 200) { + return data + } + + return Promise.reject(apiError) + } catch (err: any) { + return Promise.reject(apiError) + } + } +) diff --git a/frontend/src/features/redux/store.ts b/frontend/src/features/redux/store.ts index 945ae90c9d538c1ae139d50390caf2e7d74cd1b8..b3addad9a154e47a3e64b670129b041ac23c9582 100644 --- a/frontend/src/features/redux/store.ts +++ b/frontend/src/features/redux/store.ts @@ -1,15 +1,25 @@ - import { applyMiddleware, combineReducers, createStore } from 'redux' import { persistStore } from 'redux-persist' import thunk from 'redux-thunk' import userReducer from '../Auth/userSlice' import themeReducer from '../Theme/themeReducer' +import catalogReducer from '../Catalog/catalogSlice' +import { composeWithDevTools } from 'redux-devtools-extension' +const composeEnhancers = composeWithDevTools({}) // Store holds shared state in the application const store = createStore( - combineReducers({ user: userReducer, theme: themeReducer }), - applyMiddleware(thunk) // Thunk middleware so we can async fetch data from the api + combineReducers({ + user: userReducer, + theme: themeReducer, + catalog: catalogReducer, + }), + process.env.REACT_APP_DEV_ENV === 'true' + ? composeEnhancers( + applyMiddleware(thunk) // Thunk middleware so we can async fetch data from the api + ) + : applyMiddleware(thunk) ) export default store diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 6d4c9893b26533255acc370cbd70a49d1a000cbd..9d8e362f519e347cb04044db818869c027fcf4cf 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -15,11 +15,11 @@ injectStore(store) ReactDOM.render( <Provider store={store}> <PersistGate loading={null} persistor={persistor}> - <React.StrictMode> + {/* <React.StrictMode> */} <BrowserRouter> <App /> </BrowserRouter> - </React.StrictMode> + {/* </React.StrictMode> */} </PersistGate> </Provider>, document.getElementById('root') diff --git a/frontend/src/swagger/CatalogItems.ts b/frontend/src/swagger/CatalogItems.ts index cfd3c2ee13dc18620af08cf7a5166c478a87d4c6..1b0cb076bfdfa819336b76458474a7c038f2a7d8 100644 --- a/frontend/src/swagger/CatalogItems.ts +++ b/frontend/src/swagger/CatalogItems.ts @@ -25,7 +25,6 @@ export class CatalogItems<SecurityDataType = unknown> extends HttpClient<Securit this.request<CatalogItemDto, any>({ path: `/catalog-items/${id}`, method: "GET", - format: "json", ...params, }); /** diff --git a/frontend/src/swagger/Path.ts b/frontend/src/swagger/Path.ts new file mode 100644 index 0000000000000000000000000000000000000000..945e865cab46eef1d5730ad761a1e26ad0e6ef08 --- /dev/null +++ b/frontend/src/swagger/Path.ts @@ -0,0 +1,30 @@ +/* eslint-disable */ +/* tslint:disable */ +/* + * --------------------------------------------------------------- + * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## + * ## ## + * ## AUTHOR: acacode ## + * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## + * --------------------------------------------------------------- + */ + +import { PathDto } from "./data-contracts"; +import { HttpClient, RequestParams } from "./http-client"; + +export class Path<SecurityDataType = unknown> extends HttpClient<SecurityDataType> { + /** + * No description + * + * @tags path-controller + * @name GetPath + * @request GET:/path + */ + getPath = (query: { pathDto: PathDto }, params: RequestParams = {}) => + this.request<PathDto, any>({ + path: `/path`, + method: "GET", + query: query, + ...params, + }); +} diff --git a/frontend/src/swagger/data-contracts.ts b/frontend/src/swagger/data-contracts.ts index c8808aa6e24927ef96bdf5065e7c68526ab55984..07a88c83782cb93738f8ae8b4f31a6f7fce5d009 100644 --- a/frontend/src/swagger/data-contracts.ts +++ b/frontend/src/swagger/data-contracts.ts @@ -13,20 +13,21 @@ export interface CatalogItemDto { /** @format uuid */ id?: string; name?: string; - - /** @format int32 */ - certainty?: number; + alternativeNames?: string[]; + writtenForms?: string[]; + types?: string[]; + countries?: string[]; + bibliography?: string[]; /** @format double */ longitude?: number; /** @format double */ latitude?: number; - bibliography?: string[]; - countries?: string[]; - writtenForms?: string[]; - alternativeNames?: string[]; - types?: string[]; + + /** @format int32 */ + certainty?: number; + description?: string; } export interface PasswordDto { @@ -51,3 +52,8 @@ export interface TitlePageDto { title?: string; content?: string; } + +export interface PathDto { + text?: string; + foundCatalogItems?: CatalogItemDto[][]; +} diff --git a/frontend/src/swagger/http-client.ts b/frontend/src/swagger/http-client.ts index e994ab9d1446ea82fd72e2afd648f81053cfcc16..ab339058ae540160ed08247a257d1a8fc896b280 100644 --- a/frontend/src/swagger/http-client.ts +++ b/frontend/src/swagger/http-client.ts @@ -54,7 +54,7 @@ export enum ContentType { } export class HttpClient<SecurityDataType = unknown> { - public baseUrl: string = "/api"; + public baseUrl: string = "http://localhost:8080"; private securityData: SecurityDataType | null = null; private securityWorker?: ApiConfig<SecurityDataType>["securityWorker"]; private abortControllers = new Map<CancelToken, AbortController>(); diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 42913e2efcc6e390a76f6c744b6a3862f7387752..9a33cf0b9343581e44de201301b6a6257ff466e3 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -7759,6 +7759,11 @@ redent@^3.0.0: indent-string "^4.0.0" strip-indent "^3.0.0" +redux-devtools-extension@^2.13.9: + version "2.13.9" + resolved "https://registry.yarnpkg.com/redux-devtools-extension/-/redux-devtools-extension-2.13.9.tgz#6b764e8028b507adcb75a1cae790f71e6be08ae7" + integrity sha512-cNJ8Q/EtjhQaZ71c8I9+BPySIBVEKssbPpskBfsXqb8HJ002A3KRVHfeRzwRo6mGPqsm7XuHTqNSNeS1Khig0A== + redux-persist@*, redux-persist@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/redux-persist/-/redux-persist-6.0.0.tgz#b4d2972f9859597c130d40d4b146fecdab51b3a8"