diff --git a/client/src/pages/catalogue/api/fetch-favorite-instrument-ids.ts b/client/src/pages/catalogue/api/fetch-favorite-instrument-ids.ts new file mode 100644 index 00000000..058ea885 --- /dev/null +++ b/client/src/pages/catalogue/api/fetch-favorite-instrument-ids.ts @@ -0,0 +1,17 @@ +import axios from "axios"; +import { SERVER_URL } from "shared/config"; + +export const fetchFavoriteInstrumentIds = async () => { + const { data, status } = await axios.get( + `${SERVER_URL}/api/favorite/list`, + { + withCredentials: true, + }, + ); + + if (status !== 200) { + throw new Error("Failed to extract favorite instruments"); + } + + return data; +}; diff --git a/client/src/pages/catalogue/api/fetch-instruments.ts b/client/src/pages/catalogue/api/fetch-instruments.ts new file mode 100644 index 00000000..e87ed1ef --- /dev/null +++ b/client/src/pages/catalogue/api/fetch-instruments.ts @@ -0,0 +1,26 @@ +import axios from "axios"; +import { Page } from "domain/model/page"; +import { + API_INSTRUMENTS, + CATALOGUE_DEFAULT_PAGE_NUMBER, + CATALOGUE_DEFAULT_PAGE_SIZE, + SERVER_URL, +} from "shared/config"; + +export const fetchInstruments = async () => { + const { data, status } = await axios.post( + `${SERVER_URL}${API_INSTRUMENTS}`, + {}, + { + params: { + pageNumber: CATALOGUE_DEFAULT_PAGE_NUMBER, + pageSize: CATALOGUE_DEFAULT_PAGE_SIZE, + }, + }, + ); + if (status !== 200) { + throw new Error(`Failed to extract instruments`); + } + + return data; +}; diff --git a/client/src/pages/catalogue/api/loader.ts b/client/src/pages/catalogue/api/loader.ts index 2d6f3613..e98306aa 100644 --- a/client/src/pages/catalogue/api/loader.ts +++ b/client/src/pages/catalogue/api/loader.ts @@ -1,21 +1,18 @@ -import axios from "axios"; -import { Instruments } from "domain/model/instrument"; import { Page } from "domain/model/page"; -import { API_INSTRUMENTS, SERVER_URL } from "shared/config/backend"; +import { fetchFavoriteInstrumentIds } from "pages/catalogue"; +import { fetchInstruments } from "pages/catalogue/api/fetch-instruments"; -export const loader = async (): Promise => { - const { data, status } = await axios.post( - `${SERVER_URL}${API_INSTRUMENTS}`, - {}, - { - params: { - pageNumber: 1, - pageSize: 3, - }, - }, - ); - if (status !== 200) { - throw new Error(`Failed to extract instruments`); - } - return data.content; +export interface CatalogueLoader { + instrumentPage: Page; + favoriteInstrumentIds: number[]; +} + +export const loader = async (): Promise => { + const instruments = await fetchInstruments(); + const favoriteInstrumentIds = await fetchFavoriteInstrumentIds(); + + return { + instrumentPage: instruments, + favoriteInstrumentIds: favoriteInstrumentIds, + }; }; diff --git a/client/src/pages/catalogue/index.ts b/client/src/pages/catalogue/index.ts index 89792ffa..0addcaf9 100644 --- a/client/src/pages/catalogue/index.ts +++ b/client/src/pages/catalogue/index.ts @@ -1,5 +1,7 @@ import { Catalogue } from "./ui/Catalogue"; import { loader } from "./api/loader"; +import { CatalogueLoader } from "./api/loader"; +import { fetchFavoriteInstrumentIds } from "./api/fetch-favorite-instrument-ids"; -export { Catalogue }; -export { loader }; +export { Catalogue, loader, fetchFavoriteInstrumentIds }; +export type { CatalogueLoader }; diff --git a/client/src/pages/catalogue/ui/Catalogue.css b/client/src/pages/catalogue/ui/Catalogue.css index c85666ac..335bf7d7 100644 --- a/client/src/pages/catalogue/ui/Catalogue.css +++ b/client/src/pages/catalogue/ui/Catalogue.css @@ -11,7 +11,7 @@ } } - #catalogue-filter-serp-wrapper { + #catalogue-wrapper { display: flex; flex-direction: row; background-color: darkorchid; @@ -21,7 +21,7 @@ flex: 1; } - #catalogue-serp-and-navbar-wrapper { + #catalogue-serp-navbar-wrapper { background-color: aqua; flex: 2; diff --git a/client/src/pages/catalogue/ui/Catalogue.tsx b/client/src/pages/catalogue/ui/Catalogue.tsx index c99136ff..908b05a6 100644 --- a/client/src/pages/catalogue/ui/Catalogue.tsx +++ b/client/src/pages/catalogue/ui/Catalogue.tsx @@ -18,20 +18,29 @@ import { } from "shared/config/frontend"; import { getInstrumentsByCriteria } from "shared/api/list-instruments-by-criteria"; import { Page } from "domain/model/page"; +import { SearchBarForm } from "./SearchBarForm"; +import { NavigationBar } from "./NavigationBar"; +import { CatalogueLoader, fetchFavoriteInstrumentIds } from "pages/catalogue"; export function Catalogue() { useJwt(); - const initialInstruments = useLoaderData() as Instruments; // https://github.com/remix-run/react-router/discussions/9792 - const [instruments, setInstruments] = - useState(initialInstruments); + const loader = useLoaderData() as CatalogueLoader; // https://github.com/remix-run/react-router/discussions/9792 + const [instruments, setInstruments] = useState( + loader.instrumentPage.content, + ); const [instrumentName, setInstrumentName] = useState(null); const [filters, setFilters] = useState(DEFAULT_FILTER); const [pageNumber, setPageNumber] = useState( CATALOGUE_DEFAULT_PAGE_NUMBER, ); const totalPages = useRef(0); + const [favoriteInstrumentIds, setFavoriteInstrumentIds] = useState( + loader.favoriteInstrumentIds, + ); useEffect(() => { + fetchFavoriteInstrumentIds().then((ids) => setFavoriteInstrumentIds(ids)); + if (instrumentName === "") { filters.instrumentName = null; } @@ -42,6 +51,7 @@ export function Catalogue() { pageNumber: pageNumber, pageSize: CATALOGUE_DEFAULT_PAGE_SIZE, } as Page; + getInstrumentsByCriteria(filters, page).then((r) => { setInstruments(r.content); totalPages.current = r.totalPages; @@ -52,38 +62,26 @@ export function Catalogue() {
-
- { - setInstrumentName(e.target.value); - }} - /> -
+ -
+
{ setFilters(newFilters); - console.log("Called catalogue filter widget"); }} role={Jwt.extractFromLocalStorage()?.toRole()} /> -
- -
- {pageNumber > 1 && ( - - )} - {pageNumber < totalPages.current && ( - - )} -
+ +
+ +
diff --git a/client/src/pages/catalogue/ui/NavigationBar.tsx b/client/src/pages/catalogue/ui/NavigationBar.tsx new file mode 100644 index 00000000..732171ab --- /dev/null +++ b/client/src/pages/catalogue/ui/NavigationBar.tsx @@ -0,0 +1,24 @@ +import React from "react"; + +interface Props { + pageNumber: number; + totalPages: number; + setPageNumber: (pageNumber: number) => void; +} + +export const NavigationBar = (props: Props) => { + return ( +
+ {props.pageNumber > 1 && ( + + )} + {props.pageNumber < props.totalPages && ( + + )} +
+ ); +}; diff --git a/client/src/pages/catalogue/ui/SearchBarForm.tsx b/client/src/pages/catalogue/ui/SearchBarForm.tsx new file mode 100644 index 00000000..68e8c2b6 --- /dev/null +++ b/client/src/pages/catalogue/ui/SearchBarForm.tsx @@ -0,0 +1,19 @@ +import React from "react"; + +interface Props { + setInstrumentName: (name: string) => void; +} + +export const SearchBarForm = (props: Props) => { + return ( +
+ { + props.setInstrumentName(e.target.value); + }} + /> +
+ ); +}; diff --git a/client/src/widgets/catalogue-serp/ui/CatalogueSerpWidget.tsx b/client/src/widgets/catalogue-serp/ui/CatalogueSerpWidget.tsx index c0c8fe71..b3731851 100644 --- a/client/src/widgets/catalogue-serp/ui/CatalogueSerpWidget.tsx +++ b/client/src/widgets/catalogue-serp/ui/CatalogueSerpWidget.tsx @@ -1,23 +1,27 @@ import React from "react"; import "./CatalogueSerpWidget.css"; import { InstrumentDetails } from "./InstrumentDetails"; -import { InstrumentActions } from "./InstrumentActions"; +import { InstrumentActions } from "./actions/InstrumentActions"; import { Instruments } from "domain/model/instrument"; interface Props { instruments: Instruments; + favoriteInstrumentIds: number[]; } -export const CatalogueSerpWidget = ({ instruments }: Props) => { - console.log("instruments type:", typeof instruments); - console.log("instruments value:", instruments); - +export const CatalogueSerpWidget = ({ + instruments, + favoriteInstrumentIds, +}: Props) => { return (
{instruments.map((instrument) => (
- +
))}
diff --git a/client/src/widgets/catalogue-serp/ui/actions/AddToFavoriteButton.tsx b/client/src/widgets/catalogue-serp/ui/actions/AddToFavoriteButton.tsx index 555209b2..682f398d 100644 --- a/client/src/widgets/catalogue-serp/ui/actions/AddToFavoriteButton.tsx +++ b/client/src/widgets/catalogue-serp/ui/actions/AddToFavoriteButton.tsx @@ -1,26 +1,43 @@ -import { Instrument } from "domain/model/instrument"; import axios from "axios"; import { SERVER_URL } from "shared/config"; -import { API_FAVORITE } from "shared/config/backend"; +import { useState } from "react"; interface Props { - instrument: Instrument; + instrumentId: number; + favorite: boolean; } export const AddToFavoriteButton = (props: Props) => { - const handleAddToFavorite = () => { - axios.post(`${SERVER_URL}${API_FAVORITE}`, { - instrumentId: props.instrument.id, - }); + const [favorite, setFavorite] = useState(props.favorite); + + const toggleFavorite = async () => { + if (favorite) { + await axios.post( + `${SERVER_URL}/api/favorite/remove`, + { + instrumentId: props.instrumentId, + }, + { + withCredentials: true, + }, + ); + } else { + await axios.post( + `${SERVER_URL}/api/favorite/add`, + { + instrumentId: props.instrumentId, + }, + { + withCredentials: true, + }, + ); + } + setFavorite(!favorite); }; return ( - ); }; diff --git a/client/src/widgets/catalogue-serp/ui/InstrumentActions.css b/client/src/widgets/catalogue-serp/ui/actions/InstrumentActions.css similarity index 100% rename from client/src/widgets/catalogue-serp/ui/InstrumentActions.css rename to client/src/widgets/catalogue-serp/ui/actions/InstrumentActions.css diff --git a/client/src/widgets/catalogue-serp/ui/InstrumentActions.tsx b/client/src/widgets/catalogue-serp/ui/actions/InstrumentActions.tsx similarity index 70% rename from client/src/widgets/catalogue-serp/ui/InstrumentActions.tsx rename to client/src/widgets/catalogue-serp/ui/actions/InstrumentActions.tsx index a1a24de1..268a37ef 100644 --- a/client/src/widgets/catalogue-serp/ui/InstrumentActions.tsx +++ b/client/src/widgets/catalogue-serp/ui/actions/InstrumentActions.tsx @@ -4,16 +4,17 @@ import Jwt from "domain/model/jwt"; import { Modal } from "widgets/modal"; import "./InstrumentActions.css"; import { Role } from "domain/model/role"; -import { RemoveInstrumentButton } from "./actions/RemoveInstrumentButton"; -import { GoToInstrumentButton } from "./actions/GoToInstrumentButton"; -import { EditInstrumentButton } from "./actions/EditInstrumentButton"; -import { AddToFavoriteButton } from "./actions/AddToFavoriteButton"; +import { RemoveInstrumentButton } from "./RemoveInstrumentButton"; +import { GoToInstrumentButton } from "./GoToInstrumentButton"; +import { EditInstrumentButton } from "./EditInstrumentButton"; +import { AddToFavoriteButton } from "./AddToFavoriteButton"; interface Props { instrument: Instrument; + favorite: boolean; } -export const InstrumentActions = ({ instrument }: Props) => { +export const InstrumentActions = ({ instrument, favorite }: Props) => { const [successModal, setSuccessModal] = useState(false); return ( @@ -28,7 +29,7 @@ export const InstrumentActions = ({ instrument }: Props) => { )} - + ("test") { diff --git a/server/app/src/main/kotlin/mu/muse/application/muse/RestConfiguration.kt b/server/app/src/main/kotlin/mu/muse/application/muse/RestConfiguration.kt index 3bd514a1..83eed0ae 100644 --- a/server/app/src/main/kotlin/mu/muse/application/muse/RestConfiguration.kt +++ b/server/app/src/main/kotlin/mu/muse/application/muse/RestConfiguration.kt @@ -2,6 +2,7 @@ package mu.muse.application.muse import mu.muse.rest.HelloEndpoint import mu.muse.rest.country.GetCountriesEndpoint +import mu.muse.rest.favorite.FavoriteEndpoint import mu.muse.rest.instruments.CreateInstrumentEndpoint import mu.muse.rest.instruments.DeleteInstrumentByIdEndpoint import mu.muse.rest.instruments.EditInstrumentEndpoint @@ -78,4 +79,7 @@ class RestConfiguration { @Bean fun registrationEndpoint(registerUser: RegisterUser) = RegistrationEndpoint(registerUser) + + @Bean + fun favoriteEndpoint() = FavoriteEndpoint() } diff --git a/server/app/src/main/kotlin/mu/muse/application/muse/SecurityConfiguration.kt b/server/app/src/main/kotlin/mu/muse/application/muse/SecurityConfiguration.kt index 9f3329ad..32650015 100644 --- a/server/app/src/main/kotlin/mu/muse/application/muse/SecurityConfiguration.kt +++ b/server/app/src/main/kotlin/mu/muse/application/muse/SecurityConfiguration.kt @@ -5,6 +5,9 @@ import io.jsonwebtoken.Jwts import io.jsonwebtoken.security.Keys import jakarta.servlet.http.HttpServletResponse import mu.muse.rest.API_COUNTRIES +import mu.muse.rest.API_FAVORITE_ADD +import mu.muse.rest.API_FAVORITE_LIST +import mu.muse.rest.API_FAVORITE_REMOVE import mu.muse.rest.API_GET_MANUFACTURER_NAMES import mu.muse.rest.API_INSTRUMENTS import mu.muse.rest.API_INSTRUMENT_BY_ID @@ -31,9 +34,9 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder import org.springframework.security.web.SecurityFilterChain import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter import org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter -import org.springframework.web.cors.CorsConfiguration import org.springframework.web.cors.CorsConfigurationSource -import org.springframework.web.cors.UrlBasedCorsConfigurationSource +import org.springframework.web.servlet.config.annotation.CorsRegistry +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer import java.security.Key @Configuration @@ -50,14 +53,16 @@ class SecurityConfiguration { fun jwtParser(jwtSecretKey: Key): JwtParser = Jwts.parserBuilder().setSigningKey(jwtSecretKey).build() @Bean - fun corsConfigurationSource(): CorsConfigurationSource { - val configuration = CorsConfiguration() - configuration.addAllowedOrigin("*") - configuration.addAllowedMethod("*") // Allow all HTTP methods - configuration.addAllowedHeader("*") // Allow all headers - val source = UrlBasedCorsConfigurationSource() - source.registerCorsConfiguration("/**", configuration) - return source + fun corsConfigurer(): WebMvcConfigurer { + return object : WebMvcConfigurer { + override fun addCorsMappings(registry: CorsRegistry) { + registry.addMapping("/api/**") + .allowedOrigins("http://localhost:3000") + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(true) + } + } } @Bean @@ -74,7 +79,8 @@ class SecurityConfiguration { http = http.sessionManagement { session -> session - .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + .sessionFixation { it.none() } + .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) .maximumSessions(1) .sessionRegistry(sessionRegistry) } @@ -91,6 +97,9 @@ class SecurityConfiguration { .requestMatchers(HttpMethod.GET, API_COUNTRIES).permitAll() .requestMatchers(HttpMethod.GET, API_GET_MANUFACTURER_NAMES).permitAll() .requestMatchers(HttpMethod.POST, API_REGISTRATION).permitAll() + .requestMatchers(HttpMethod.POST, API_FAVORITE_ADD).permitAll() + .requestMatchers(HttpMethod.POST, API_FAVORITE_REMOVE).permitAll() + .requestMatchers(HttpMethod.GET, API_FAVORITE_LIST).permitAll() .anyRequest().authenticated() } diff --git a/server/app/src/main/kotlin/mu/muse/rest/EndpointURL.kt b/server/app/src/main/kotlin/mu/muse/rest/EndpointURL.kt index 155df5c1..b689acac 100644 --- a/server/app/src/main/kotlin/mu/muse/rest/EndpointURL.kt +++ b/server/app/src/main/kotlin/mu/muse/rest/EndpointURL.kt @@ -14,3 +14,6 @@ const val API_GET_MANUFACTURER_NAMES = "$API/manufacturers" const val API_CREATE_INSTRUMENT = "$API/instrument/create" const val API_EDIT_INSTRUMENT = "$API/instrument/edit" const val API_REGISTRATION = "$API/registration" +const val API_FAVORITE_LIST = "$API/favorite/list" +const val API_FAVORITE_ADD = "$API/favorite/add" +const val API_FAVORITE_REMOVE = "$API/favorite/remove" diff --git a/server/app/src/main/kotlin/mu/muse/rest/favorite/FavoriteEndpoint.kt b/server/app/src/main/kotlin/mu/muse/rest/favorite/FavoriteEndpoint.kt new file mode 100644 index 00000000..0a85cf77 --- /dev/null +++ b/server/app/src/main/kotlin/mu/muse/rest/favorite/FavoriteEndpoint.kt @@ -0,0 +1,55 @@ +package mu.muse.rest.favorite + +import jakarta.servlet.http.HttpSession +import mu.muse.rest.API_FAVORITE_ADD +import mu.muse.rest.API_FAVORITE_LIST +import mu.muse.rest.API_FAVORITE_REMOVE +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RestController + +@RestController +class FavoriteEndpoint { + + companion object { + private const val FAVORITE_INSTRUMENTS_SESSION_KEY = "FAVORITE_INSTRUMENTS" + } + + @GetMapping(API_FAVORITE_LIST) + fun listFavorite(session: HttpSession): List { + val favorite = session.getAttribute(FAVORITE_INSTRUMENTS_SESSION_KEY) as MutableList? + if (favorite == null) { + return mutableListOf() + } + return favorite + } + + @PostMapping(API_FAVORITE_ADD) + fun addToFavorite(session: HttpSession, @RequestBody request: Request) { + val favorite = session.getAttribute(FAVORITE_INSTRUMENTS_SESSION_KEY) as MutableList? + if (favorite == null) { + session.setAttribute(FAVORITE_INSTRUMENTS_SESSION_KEY, mutableListOf(request.instrumentId)) + return + } + + if (request.instrumentId !in favorite) { + favorite.add(request.instrumentId) + } + + session.setAttribute(FAVORITE_INSTRUMENTS_SESSION_KEY, favorite) + } + + @PostMapping(API_FAVORITE_REMOVE) + fun removeFromFavorite(session: HttpSession, @RequestBody request: Request) { + val favorite = session.getAttribute(FAVORITE_INSTRUMENTS_SESSION_KEY) as MutableList? + if (favorite == null) { + return + } + + favorite.remove(request.instrumentId) + session.setAttribute(FAVORITE_INSTRUMENTS_SESSION_KEY, favorite) + } + + data class Request(val instrumentId: Long) +} diff --git a/server/app/src/main/resources/application.properties b/server/app/src/main/resources/application.properties index a1049e52..02a3af7a 100644 --- a/server/app/src/main/resources/application.properties +++ b/server/app/src/main/resources/application.properties @@ -6,3 +6,5 @@ security.jwt.secret-key=${MUSE_JWT_SECRET_KEY} security.jwt.expiration-time=3600000 management.endpoints.web.exposure.include=* + +server.servlet.session.cookie.name=SESSIONID