From a510303c5edcdf52932f2ebcc4265fa8c8ccb4dc Mon Sep 17 00:00:00 2001 From: Warrick Tsui Date: Fri, 16 Jan 2026 21:26:04 -0500 Subject: [PATCH 1/3] Local Storage Solution for Order Filters --- .../src/components/general/Navbar/Navbar.tsx | 18 +++++ .../orders/OrdersFilter/OrderFilter.tsx | 28 ++++--- .../orders/OrdersSearch/OrdersSearch.tsx | 17 ++-- .../frontend/src/pages/Orders/Orders.tsx | 4 +- .../src/slices/order/adminOrderSlice.ts | 80 ++++++++++++++++++- 5 files changed, 128 insertions(+), 19 deletions(-) diff --git a/hackathon_site/dashboard/frontend/src/components/general/Navbar/Navbar.tsx b/hackathon_site/dashboard/frontend/src/components/general/Navbar/Navbar.tsx index 46ba047..05d3735 100644 --- a/hackathon_site/dashboard/frontend/src/components/general/Navbar/Navbar.tsx +++ b/hackathon_site/dashboard/frontend/src/components/general/Navbar/Navbar.tsx @@ -5,6 +5,7 @@ import { connect, useSelector } from "react-redux"; // Images and logos import DashboardIcon from "@material-ui/icons/Dashboard"; import LocalMallIcon from "@material-ui/icons/LocalMall"; +import CategoryIcon from "@material-ui/icons/Category"; import AccountBalanceWalletIcon from "@material-ui/icons/AccountBalanceWallet"; import ListAlt from "@material-ui/icons/ListAlt"; import AccountBoxIcon from "@material-ui/icons/AccountBox"; @@ -118,6 +119,23 @@ export const UnconnectedNavbar = ({ logout, pathname }: NavBarProps) => { )} + {isParticipantOrAdmin && ( + + + + )} {isParticipant && ( + + +
+ + {count} results + + + + +
+ + + + {count > 0 && ( + + )} + + {count > 0 + ? `SHOWING ${itemsInStore} OF ${count} ITEMS` + : isLoading + ? "LOADING" + : "NO ITEMS FOUND"} + + {count !== itemsInStore && ( + + )} + + + + + ); +}; + +export default Threedprinting; diff --git a/hackathon_site/dashboard/frontend/src/slices/hardware/categorySlice.ts b/hackathon_site/dashboard/frontend/src/slices/hardware/categorySlice.ts index f81be1c..a7f1199 100644 --- a/hackathon_site/dashboard/frontend/src/slices/hardware/categorySlice.ts +++ b/hackathon_site/dashboard/frontend/src/slices/hardware/categorySlice.ts @@ -112,3 +112,23 @@ export const selectCategoriesByIds = createSelector( [categorySelectors.selectEntities, (state: RootState, ids: number[]) => ids], (entities, ids) => ids.map((id) => entities?.[id]) ); + +export const categorySelectorsFiltered = createSelector( + [categorySelectors.selectAll], + (categories) => categories.filter((cat) => cat.name !== "3D Printing") +); + +export const selectThreeDPrintingId = createSelector( + [categorySelectors.selectAll], + (categories) => categories.find((c) => c.name === "3D Printing")?.id +); + +export const selectNonThreeDCategoryIds = createSelector( + categorySelectorsFiltered, + (categories) => { + const threeD = categories.find((c) => c.name === "3D Printing"); + const threeDId = threeD?.id; + + return categories.filter((c) => c.id !== threeDId).map((c) => c.id); + } +); From 51d9806ed2233aba16a93ae2005c518962e45f8f Mon Sep 17 00:00:00 2001 From: Warrick Tsui Date: Fri, 16 Jan 2026 23:08:27 -0500 Subject: [PATCH 3/3] Adding 3D Printing --- .../InventoryGrid3D.module.scss | 11 + .../InventoryGrid3D/InventoryGrid3D.test.tsx | 58 ++ .../InventoryGrid3D/InventoryGrid3D.tsx | 66 +++ .../InventorySearch3D.test.tsx | 110 ++++ .../InventorySearch3D/InventorySearch3D.tsx | 109 ++++ .../ProductOverview3D.module.scss | 64 +++ .../ProductOverview3D.test.tsx | 494 ++++++++++++++++++ .../ProductOverview3D/ProductOverview3D.tsx | 456 ++++++++++++++++ .../pages/Threedprinting/Threedprinting.tsx | 64 ++- .../slices/hardware/hardware3dSlice.test.ts | 301 +++++++++++ .../src/slices/hardware/hardware3dSlice.ts | 310 +++++++++++ .../dashboard/frontend/src/slices/store.ts | 4 + 12 files changed, 2024 insertions(+), 23 deletions(-) create mode 100644 hackathon_site/dashboard/frontend/src/components/inventory/InventoryGrid3D/InventoryGrid3D.module.scss create mode 100644 hackathon_site/dashboard/frontend/src/components/inventory/InventoryGrid3D/InventoryGrid3D.test.tsx create mode 100644 hackathon_site/dashboard/frontend/src/components/inventory/InventoryGrid3D/InventoryGrid3D.tsx create mode 100644 hackathon_site/dashboard/frontend/src/components/inventory/InventorySearch3D/InventorySearch3D.test.tsx create mode 100644 hackathon_site/dashboard/frontend/src/components/inventory/InventorySearch3D/InventorySearch3D.tsx create mode 100644 hackathon_site/dashboard/frontend/src/components/inventory/ProductOverview3D/ProductOverview3D.module.scss create mode 100644 hackathon_site/dashboard/frontend/src/components/inventory/ProductOverview3D/ProductOverview3D.test.tsx create mode 100644 hackathon_site/dashboard/frontend/src/components/inventory/ProductOverview3D/ProductOverview3D.tsx create mode 100644 hackathon_site/dashboard/frontend/src/slices/hardware/hardware3dSlice.test.ts create mode 100644 hackathon_site/dashboard/frontend/src/slices/hardware/hardware3dSlice.ts diff --git a/hackathon_site/dashboard/frontend/src/components/inventory/InventoryGrid3D/InventoryGrid3D.module.scss b/hackathon_site/dashboard/frontend/src/components/inventory/InventoryGrid3D/InventoryGrid3D.module.scss new file mode 100644 index 0000000..42dff71 --- /dev/null +++ b/hackathon_site/dashboard/frontend/src/components/inventory/InventoryGrid3D/InventoryGrid3D.module.scss @@ -0,0 +1,11 @@ +@import "./../../../assets/abstracts/variables"; +@import "./../../../assets/abstracts/mixins"; + +.Item { + @include flexPosition($dir: column); + cursor: pointer; + + &:hover { + background-color: color(light2); + } +} diff --git a/hackathon_site/dashboard/frontend/src/components/inventory/InventoryGrid3D/InventoryGrid3D.test.tsx b/hackathon_site/dashboard/frontend/src/components/inventory/InventoryGrid3D/InventoryGrid3D.test.tsx new file mode 100644 index 0000000..07a5b0e --- /dev/null +++ b/hackathon_site/dashboard/frontend/src/components/inventory/InventoryGrid3D/InventoryGrid3D.test.tsx @@ -0,0 +1,58 @@ +import React from "react"; + +import { + render, + waitFor, + promiseResolveWithDelay, + makeMockApiListResponse, +} from "testing/utils"; + +import InventoryGrid from "components/inventory/InventoryGrid/InventoryGrid"; +import { get } from "api/api"; +import { mockHardware } from "testing/mockData"; +import { makeStore } from "slices/store"; +import { getHardwareWithFilters } from "slices/hardware/hardwareSlice"; + +jest.mock("api/api"); +const mockedGet = get as jest.MockedFunction; + +describe("", () => { + it("Renders all hardware from the store", async () => { + // To populate the store, dispatch a thunk while mocking the API response + const apiResponse = makeMockApiListResponse(mockHardware); + + mockedGet.mockResolvedValue(apiResponse); + + const store = makeStore(); + store.dispatch(getHardwareWithFilters()); + + const { getByText } = render(, { store }); + + await waitFor(() => { + mockHardware.forEach((hardware) => + expect(getByText(hardware.name)).toBeInTheDocument() + ); + }); + }); + + it("Displays a linear progress when loading", async () => { + const apiResponse = makeMockApiListResponse(mockHardware); + + mockedGet.mockReturnValue(promiseResolveWithDelay(apiResponse, 500)); + + const store = makeStore(); + store.dispatch(getHardwareWithFilters()); + + const { getByText, queryByTestId } = render(, { store }); + + await waitFor(() => { + expect(queryByTestId("linear-progress")).toBeInTheDocument(); + }); + + // After results have loaded, progress bar should disappear + await waitFor(() => { + expect(queryByTestId("linear-progress")).not.toBeInTheDocument(); + expect(getByText(mockHardware[0].name)).toBeInTheDocument(); + }); + }); +}); diff --git a/hackathon_site/dashboard/frontend/src/components/inventory/InventoryGrid3D/InventoryGrid3D.tsx b/hackathon_site/dashboard/frontend/src/components/inventory/InventoryGrid3D/InventoryGrid3D.tsx new file mode 100644 index 0000000..97aef7d --- /dev/null +++ b/hackathon_site/dashboard/frontend/src/components/inventory/InventoryGrid3D/InventoryGrid3D.tsx @@ -0,0 +1,66 @@ +import React from "react"; +import Grid from "@material-ui/core/Grid"; +import styles from "pages/Inventory/Inventory.module.scss"; +import Item from "components/inventory/Item/Item"; +import { useDispatch, useSelector } from "react-redux"; +// import { +// getUpdatedHardwareDetails, +// hardwareSelectors, +// isLoadingSelector, +// } from "slices/hardware/hardwareSlice"; +import { + getUpdatedHardware3dDetails, + hardware3dSelectors, + is3dLoadingSelector, +} from "slices/hardware/hardware3dSlice"; +import { LinearProgress } from "@material-ui/core"; +import hardwareImagePlaceholder from "assets/images/placeholders/no-hardware-image.svg"; +import { openProductOverview } from "slices/ui/uiSlice"; + +export const InventoryGrid = () => { + const dispatch = useDispatch(); + const items = useSelector(hardware3dSelectors.selectAll); + const isLoading = useSelector(is3dLoadingSelector); + + const openProductOverviewPanel = (hardwareId: number) => { + dispatch(getUpdatedHardware3dDetails(hardwareId)); + dispatch(openProductOverview()); + }; + + return isLoading ? ( + + ) : ( + + {items.length > 0 && + items.map((item) => ( + openProductOverviewPanel(item.id)} + > + + + ))} + + ); +}; + +export default InventoryGrid; diff --git a/hackathon_site/dashboard/frontend/src/components/inventory/InventorySearch3D/InventorySearch3D.test.tsx b/hackathon_site/dashboard/frontend/src/components/inventory/InventorySearch3D/InventorySearch3D.test.tsx new file mode 100644 index 0000000..45786b5 --- /dev/null +++ b/hackathon_site/dashboard/frontend/src/components/inventory/InventorySearch3D/InventorySearch3D.test.tsx @@ -0,0 +1,110 @@ +import React from "react"; +import { DeepPartial } from "redux"; + +import { render, fireEvent, waitFor } from "testing/utils"; + +import InventorySearch from "components/inventory/InventorySearch/InventorySearch"; +import { get } from "api/api"; +import { HardwareFilters } from "api/types"; +import { + hardwareReducerName, + initialState, + HardwareState, +} from "slices/hardware/hardwareSlice"; +import { RootState } from "slices/store"; + +jest.mock("api/api"); + +const makeState = (overrides: Partial): DeepPartial => ({ + [hardwareReducerName]: { + ...initialState, + ...overrides, + }, +}); + +const hardwareUri = "/api/hardware/hardware/"; + +describe("", () => { + it("Submits search query", async () => { + const { getByLabelText } = render(); + + const input = getByLabelText(/search items/i); + + fireEvent.change(input, { target: { value: "foobar" } }); + fireEvent.submit(input); + + const expectedFilters: HardwareFilters = { + search: "foobar", + }; + + await waitFor(() => { + expect(get).toHaveBeenCalledWith(hardwareUri, expectedFilters); + }); + }); + + it("Submits search query when clicking search button", async () => { + const { getByLabelText, getByTestId } = render(); + + const input = getByLabelText(/search items/i); + const searchButton = getByTestId("search-button"); + + fireEvent.change(input, { target: { value: "foobar" } }); + fireEvent.click(searchButton); + + const expectedFilters: HardwareFilters = { + search: "foobar", + }; + + await waitFor(() => { + expect(get).toHaveBeenCalledWith(hardwareUri, expectedFilters); + }); + }); + + it("Changes only the search filter when submitted", async () => { + const initialFilters: HardwareFilters = { + in_stock: true, + ordering: "-name", + category_ids: [1, 2], + search: "abc123", + }; + + const preloadedState = makeState({ filters: initialFilters }); + + const { getByLabelText } = render(, { preloadedState }); + + const input = getByLabelText(/search items/i); + fireEvent.change(input, { target: { value: "foobar" } }); + fireEvent.submit(input); + + const expectedFilters = { + ...initialFilters, + search: "foobar", + }; + + await waitFor(() => { + expect(get).toHaveBeenCalledWith(hardwareUri, expectedFilters); + }); + }); + + it("Removes the search filter and submits when cleared", async () => { + const initialFilters: HardwareFilters = { + in_stock: true, + ordering: "-name", + category_ids: [1, 2], + search: "abc123", + }; + + const preloadedState = makeState({ filters: initialFilters }); + + const { getByTestId } = render(, { preloadedState }); + + const clearButton = getByTestId("clear-button"); + fireEvent.click(clearButton); + + const { search, ...expectedFilters } = initialFilters; + + await waitFor(() => { + expect(get).toHaveBeenCalledWith(hardwareUri, expectedFilters); + }); + }); +}); diff --git a/hackathon_site/dashboard/frontend/src/components/inventory/InventorySearch3D/InventorySearch3D.tsx b/hackathon_site/dashboard/frontend/src/components/inventory/InventorySearch3D/InventorySearch3D.tsx new file mode 100644 index 0000000..e6c41c9 --- /dev/null +++ b/hackathon_site/dashboard/frontend/src/components/inventory/InventorySearch3D/InventorySearch3D.tsx @@ -0,0 +1,109 @@ +import React from "react"; +import TextField from "@material-ui/core/TextField"; +import InputAdornment from "@material-ui/core/InputAdornment"; +import IconButton from "@material-ui/core/IconButton"; +import CloseIcon from "@material-ui/icons/Close"; +import SearchIcon from "@material-ui/icons/Search"; +import { Formik, FormikValues } from "formik"; + +import styles from "pages/Inventory/Inventory.module.scss"; +import { + set3dFilters, + getHardware3dWithFilters, + is3dLoadingSelector, +} from "slices/hardware/hardware3dSlice"; +import { connect, ConnectedProps } from "react-redux"; +import { RootState } from "slices/store"; +import { Box } from "@material-ui/core"; + +interface SearchValues { + search: string; +} + +export const InventorySearch = ({ + handleChange, + handleReset, + handleSubmit, + values: { search }, +}: FormikValues) => ( +
+ + + + + + + ), + }} + value={search} + onChange={handleChange} + /> + + + + +
+); + +export const EnhancedInventorySearch = ({ + getHardware3dWithFilters, + set3dFilters, +}: ConnectedInventorySearchProps) => { + const initialValues = { + search: "", + }; + + const onSubmit = ({ search }: SearchValues) => { + set3dFilters({ search }); + getHardware3dWithFilters(); + }; + + const onReset = () => { + set3dFilters(initialValues); + getHardware3dWithFilters(); + }; + + return ( + + {(formikProps) => } + + ); +}; + +const mapStateToProps = (state: RootState) => ({ + isLoading: is3dLoadingSelector(state), +}); + +const connector = connect(mapStateToProps, { + getHardware3dWithFilters, + set3dFilters, +}); + +type ConnectedInventorySearchProps = ConnectedProps; + +export const ConnectedInventorySearch = connector(EnhancedInventorySearch); + +export default ConnectedInventorySearch; diff --git a/hackathon_site/dashboard/frontend/src/components/inventory/ProductOverview3D/ProductOverview3D.module.scss b/hackathon_site/dashboard/frontend/src/components/inventory/ProductOverview3D/ProductOverview3D.module.scss new file mode 100644 index 0000000..819085f --- /dev/null +++ b/hackathon_site/dashboard/frontend/src/components/inventory/ProductOverview3D/ProductOverview3D.module.scss @@ -0,0 +1,64 @@ +@import "./../../../assets/abstracts/mixins"; +@import "./../../../assets/abstracts/variables"; + +.heading { + margin-top: 20px; + font-weight: 500; +} + +.productOverview { + height: 100%; + width: 100%; + @include flexPosition($jusCon: space-between, $alIte: flex-start, $dir: column); + + &Div { + width: 100%; + } +} + +.mainSection { + @include flexPosition($jusCon: space-between); + width: 100%; + + h6 { + font-weight: 400; + } + + .quantityAvailable { + color: color(grey); + } + + .categoryItem { + background-color: color(blueLight); + margin: 2.5px 5px 2.5px 0; + } + img { + margin-left: 50px; + width: 40%; + max-width: 200px; + } +} + +.form { + width: 100%; + + &Control { + margin: 20px 0; + width: 120px; + } + + &Button { + background-color: color(light2); + width: calc(100% + 100px); + margin-left: -50px; + margin-bottom: -45px; + padding: 30px 50px; + + @include responsive(sm-down) { + width: calc(100% + 60px); + margin-left: -30px; + margin-bottom: -25px; + padding: 20px 30px; + } + } +} diff --git a/hackathon_site/dashboard/frontend/src/components/inventory/ProductOverview3D/ProductOverview3D.test.tsx b/hackathon_site/dashboard/frontend/src/components/inventory/ProductOverview3D/ProductOverview3D.test.tsx new file mode 100644 index 0000000..ae7a922 --- /dev/null +++ b/hackathon_site/dashboard/frontend/src/components/inventory/ProductOverview3D/ProductOverview3D.test.tsx @@ -0,0 +1,494 @@ +import React from "react"; +import ProductOverview, { + DetailInfoSection, + EnhancedAddToCartForm, +} from "./ProductOverview3D"; +import { + mockAdminUser, + mockCartItems, + mockCategories, + mockHardware, + mockUser, +} from "testing/mockData"; +import { + render, + fireEvent, + waitFor, + makeStoreWithEntities, + promiseResolveWithDelay, + when, + makeMockApiResponse, +} from "testing/utils"; +import { cartSelectors } from "slices/hardware/cartSlice"; +import { SnackbarProvider } from "notistack"; +import SnackbarNotifier from "components/general/SnackbarNotifier/SnackbarNotifier"; +import { get } from "api/api"; +import { getUpdatedHardwareDetails } from "slices/hardware/hardwareSlice"; + +jest.mock("api/api", () => ({ + ...jest.requireActual("api/api"), + get: jest.fn(), +})); +const mockedGet = get as jest.MockedFunction; + +const mockHardwareSignOutStartDate = jest.fn(); +const mockHardwareSignOutEndDate = jest.fn(); +jest.mock("constants.js", () => ({ + get hardwareSignOutStartDate() { + return mockHardwareSignOutStartDate(); + }, + get hardwareSignOutEndDate() { + return mockHardwareSignOutEndDate(); + }, +})); + +export const mockHardwareSignOutDates = ( + numDaysRelativeToStart?: number, + numDaysRelativeToEnd?: number +): { + start: Date; + end: Date; +} => { + const currentDate = new Date(); + const start = new Date(); + const end = new Date(); + start.setDate(currentDate.getDate() + (numDaysRelativeToStart ?? -1)); + end.setDate(currentDate.getDate() + (numDaysRelativeToEnd ?? 1)); + mockHardwareSignOutStartDate.mockReturnValue(start); + mockHardwareSignOutEndDate.mockReturnValue(end); + return { start, end }; +}; + +describe("", () => { + test("all 3 parts of the product overview is there", async () => { + const store = makeStoreWithEntities({ + hardwareState: { + isUpdateDetailsLoading: false, + hardwareIdInProductOverview: 1, + }, + ui: { + inventory: { + isProductOverviewVisible: true, + }, + }, + hardware: mockHardware, + categories: mockCategories, + }); + + const { getByText } = render(, { + store, + }); + + // Check if the main section, detailInfoSection, and add to cart section works + expect(getByText("Category")).toBeInTheDocument(); + expect(getByText("Datasheet")).toBeInTheDocument(); + expect(getByText("Add to cart")).toBeInTheDocument(); + }); + + test("Category label doesn't appear when there are no categories", async () => { + mockHardware[0].categories = []; + const store = makeStoreWithEntities({ + hardwareState: { + isUpdateDetailsLoading: false, + hardwareIdInProductOverview: 1, + }, + ui: { + inventory: { + isProductOverviewVisible: true, + }, + }, + hardware: mockHardware, + categories: mockCategories, + }); + + const { queryByText } = render(, { + store, + }); + + expect(queryByText("Category")).not.toBeInTheDocument(); + }); + + test("Displays a loader when loading", async () => { + const apiResponse = makeMockApiResponse({ ...mockHardware[1], id: 1 }, 200); + + when(mockedGet) + .calledWith("/api/hardware/hardware/1/") + .mockReturnValue(promiseResolveWithDelay(apiResponse, 500)); + + const store = makeStoreWithEntities({ + hardwareState: { + isUpdateDetailsLoading: false, + }, + ui: { + inventory: { + isProductOverviewVisible: true, + }, + }, + hardware: mockHardware, + }); + + const { getByText, queryByTestId } = render( + , + { store } + ); + + store.dispatch(getUpdatedHardwareDetails(1)); + + await waitFor(() => { + expect(queryByTestId("circular-progress")).toBeInTheDocument(); + }); + + // After results have loaded, loader should disappear + await waitFor(() => { + expect(queryByTestId("circular-progress")).not.toBeInTheDocument(); + expect(getByText(mockHardware[1].name)).toBeInTheDocument(); + }); + }); + + test("all 3 parts of the product overview is there when hardware has no optional fields", () => { + const store = makeStoreWithEntities({ + hardwareState: { + isUpdateDetailsLoading: false, + hardwareIdInProductOverview: mockHardware[9].id, + }, + ui: { + inventory: { + isProductOverviewVisible: true, + }, + }, + hardware: mockHardware, + categories: mockCategories, + }); + + const { getByText, queryByText } = render( + , + { + store, + } + ); + + // Check if the main section, detailInfoSection, and add to cart section works + expect(getByText("Category")).toBeInTheDocument(); + expect(getByText("Datasheet")).toBeInTheDocument(); + expect(getByText("Add to cart")).toBeInTheDocument(); + expect(queryByText("Notes")).not.toBeInTheDocument(); + }); + + test("minimum constraint between hardware and categories is used", () => { + const store = makeStoreWithEntities({ + hardware: [mockHardware[0]], + categories: mockCategories, + hardwareState: { + isUpdateDetailsLoading: false, + hardwareIdInProductOverview: mockHardware[0].id, + }, + ui: { + inventory: { + isProductOverviewVisible: true, + }, + }, + }); + + const minConstraint: number = mockCategories + .map( + (category) => + category.max_per_team ?? mockHardware[0].quantity_remaining + ) + .concat(mockHardware[0].max_per_team ? [mockHardware[0].max_per_team] : []) + .reduce((prev, curr) => Math.min(prev, curr)); + + const { getByText, queryByText, getByRole } = render( + , + { + store, + } + ); + + fireEvent.mouseDown(getByRole("button", { name: "Qty 1" })); + + expect(queryByText(minConstraint + 1)).not.toBeInTheDocument(); + expect(getByText(minConstraint)).toBeInTheDocument(); + }); + + it("displays error message when unable to get hardware", async () => { + const failureResponse = { + response: { + status: 500, + message: "Something went wrong", + }, + }; + + mockedGet.mockRejectedValue(failureResponse); + + const store = makeStoreWithEntities({ + hardware: mockHardware, + hardwareState: { + isUpdateDetailsLoading: false, + }, + ui: { + inventory: { + isProductOverviewVisible: true, + }, + }, + }); + + const { getByText } = render(, { + store, + }); + + store.dispatch(getUpdatedHardwareDetails(1)); + + await waitFor(() => { + expect( + getByText( + "Unable to display hardware. Please refresh the page and try again." + ) + ).toBeInTheDocument(); + }); + }); + + test("Add to Cart button adds hardware to cart store", async () => { + const store = makeStoreWithEntities({ + hardware: [mockHardware[0]], + categories: mockCategories, + hardwareState: { + isUpdateDetailsLoading: false, + hardwareIdInProductOverview: mockHardware[0].id, + }, + ui: { + inventory: { + isProductOverviewVisible: true, + }, + }, + }); + + const { getByText } = render( + + + + , + { + store, + } + ); + + // by default, quantity is 1 + const button = getByText("Add to cart"); + + await waitFor(() => { + fireEvent.click(button); + }); + + expect(cartSelectors.selectAll(store.getState())).toEqual([ + { hardware_id: mockHardware[0].id, quantity: 1 }, + ]); + expect( + getByText(`Added 1 ${mockHardware[0].name} item(s) to your cart.`) + ).toBeInTheDocument(); + }); + + test("Add to Cart button doesn't add hardware if user exceeds max per team limit", async () => { + const store = makeStoreWithEntities({ + hardware: [mockHardware[0]], + categories: mockCategories, + hardwareState: { + isUpdateDetailsLoading: false, + hardwareIdInProductOverview: mockHardware[0].id, + }, + ui: { + inventory: { + isProductOverviewVisible: true, + }, + }, + cartItems: mockCartItems, + }); + + const { getByText } = render( + + + + , + { + store, + } + ); + + expect( + getByText( + `You currently have ${mockCartItems.length} of this item in your cart.` + ) + ); + + // by default, quantity is 1 + const button = getByText("Add to cart"); + + await waitFor(() => { + fireEvent.click(button); + }); + + expect(cartSelectors.selectAll(store.getState())).toEqual(mockCartItems); + expect( + getByText( + "Adding this amount to your cart will exceed the quantity limit for this item." + ) + ).toBeInTheDocument(); + }); + + it("Displays quantityAvailable if admin user", () => { + const store = makeStoreWithEntities({ + hardwareState: { + isUpdateDetailsLoading: false, + hardwareIdInProductOverview: 1, + }, + ui: { + inventory: { + isProductOverviewVisible: true, + }, + }, + hardware: mockHardware, + user: { + userData: { + user: mockAdminUser, + isLoading: false, + error: null, + }, + }, + }); + + const { getByText } = render(, { + store, + }); + + expect( + getByText( + `${mockHardware[0].quantity_remaining} OF ${mockHardware[0].quantity_available} IN STOCK` + ) + ).toBeInTheDocument(); + }); + + it("Displays only quantity remaining if participant user", () => { + const store = makeStoreWithEntities({ + hardwareState: { + isUpdateDetailsLoading: false, + hardwareIdInProductOverview: 1, + }, + ui: { + inventory: { + isProductOverviewVisible: true, + }, + }, + hardware: mockHardware, + user: { + userData: { + user: mockUser, + isLoading: false, + error: null, + }, + }, + }); + + const { getByText } = render(, { + store, + }); + + expect( + getByText(`${mockHardware[0].quantity_remaining} IN STOCK`) + ).toBeInTheDocument(); + }); +}); + +describe("", () => { + test("button and select are disabled if quantityAvailable is 0", () => { + const { getByText, getByLabelText } = render( + + ); + + const button = getByText("Add to cart").closest("button"); + const select = getByLabelText("Qty"); + + expect(button).toBeDisabled(); + expect(select).toHaveClass("Mui-disabled"); + }); + + test("button and select are disabled if minimum constraint is 0", () => { + const { getByText, getByLabelText } = render( + + ); + + const button = getByText("Add to cart").closest("button"); + const select = getByLabelText("Qty"); + + expect(button).toBeDisabled(); + expect(select).toHaveClass("Mui-disabled"); + }); + + test("dropdown values are minimum between quantityAvailable and max per team", () => { + const { queryByText, getByText, getByRole } = render( + + ); + + fireEvent.mouseDown(getByRole("button", { name: "Qty 1" })); + + expect(queryByText("3")).not.toBeInTheDocument(); + expect(getByText("2")).toBeInTheDocument(); + }); + + test("dropdown value defaults to quantityAvailable when maxPerTeam is null", () => { + const { getByText, getByRole } = render( + + ); + + fireEvent.mouseDown(getByRole("button", { name: "Qty 1" })); + + expect(getByText("3")).toBeInTheDocument(); + }); + + test("button is disabled if date is not within hardware sign out period", () => { + mockHardwareSignOutDates(5, 10); + const { getByText } = render( + + ); + + const button = getByText("Add to cart").closest("button"); + expect(button).toBeDisabled(); + }); +}); + +describe("", () => { + test("constraints title doesn't appear when there are no constraints", () => { + const { queryByText } = render( + + ); + expect(queryByText("Constraints")).not.toBeInTheDocument(); + }); +}); diff --git a/hackathon_site/dashboard/frontend/src/components/inventory/ProductOverview3D/ProductOverview3D.tsx b/hackathon_site/dashboard/frontend/src/components/inventory/ProductOverview3D/ProductOverview3D.tsx new file mode 100644 index 0000000..463cceb --- /dev/null +++ b/hackathon_site/dashboard/frontend/src/components/inventory/ProductOverview3D/ProductOverview3D.tsx @@ -0,0 +1,456 @@ +import React from "react"; +import Typography from "@material-ui/core/Typography"; +import Button from "@material-ui/core/Button"; +import MenuItem from "@material-ui/core/MenuItem"; +import FormControl from "@material-ui/core/FormControl"; +import Select from "@material-ui/core/Select"; +import LaunchIcon from "@material-ui/icons/Launch"; +import InputLabel from "@material-ui/core/InputLabel"; +import Chip from "@material-ui/core/Chip"; +import CircularProgress from "@material-ui/core/CircularProgress"; +import SideSheetRight from "components/general/SideSheetRight/SideSheetRight"; + +import * as Yup from "yup"; +import { Formik, FormikValues } from "formik"; + +import styles from "./ProductOverview3D.module.scss"; +import { + closeProductOverview, + displaySnackbar, + isProductOverviewVisibleSelector, +} from "slices/ui/uiSlice"; +import { useDispatch, useSelector } from "react-redux"; +import { selectCategoriesByIds } from "slices/hardware/categorySlice"; +import { RootState } from "slices/store"; +import { addToCart, cartSelectors } from "slices/hardware/cartSlice"; +import { + hardwareInProductOverviewSelector, + isUpdateDetailsLoading, + removeProductOverviewItem, +} from "slices/hardware/hardwareSlice"; + +import { + hardware3dInProductOverviewSelector, + is3dUpdateDetailsLoading, + remove3dProductOverviewItem, +} from "slices/hardware/hardware3dSlice"; + +import { Category } from "api/types"; +import hardwareImagePlaceholder from "assets/images/placeholders/no-hardware-image.svg"; +import { hardwareSignOutEndDate, hardwareSignOutStartDate } from "constants.js"; +import { Tooltip } from "@material-ui/core"; +import { userTypeSelector, isTestUserSelector } from "slices/users/userSlice"; + +export const ERROR_MESSAGES = { + quantityMissing: "Quantity is required", +}; + +const addToCartFormSchema = Yup.object().shape({ + quantity: Yup.number().required(ERROR_MESSAGES.quantityMissing), +}); + +const createQuantityList = (number: number) => { + let entry = []; + + for (let i = 1; i <= number; i++) { + entry.push( + + {i} + + ); + } + + return entry; +}; + +interface AddToCartFormProps extends FormikValues { + quantityRemaining: number; + maxPerTeam: number | null; +} +export const AddToCartForm = ({ + quantityRemaining, + credits, + maxPerTeam, + handleSubmit, + handleChange, + values: { quantity }, +}: AddToCartFormProps) => { + const isTestUser = useSelector(isTestUserSelector); + const dropdownNum = + maxPerTeam !== null + ? Math.min(quantityRemaining, maxPerTeam) + : quantityRemaining; + const totalCredits = quantity * credits; + + const currentDateTime = new Date(); + const isOutsideSignOutPeriod = + currentDateTime < hardwareSignOutStartDate || + currentDateTime > hardwareSignOutEndDate; + + const addToCartButton = ( + + ); + return ( + <> +
+ + Qty + + +
+ {isOutsideSignOutPeriod ? ( + + {addToCartButton} + + ) : ( + addToCartButton + )} +
+
+ + ); +}; + +interface EnhancedAddToCartFormProps { + quantityRemaining: number; + credits: number; + hardwareId: number; + name: string; + maxPerTeam: number | null; +} +export const EnhancedAddToCartForm = ({ + quantityRemaining, + credits, + hardwareId, + name, + maxPerTeam, +}: EnhancedAddToCartFormProps) => { + const dispatch = useDispatch(); + const currentQuantityInCart = + useSelector((state: RootState) => cartSelectors.selectById(state, hardwareId)) + ?.quantity ?? 0; + + const onSubmit = (formikValues: { quantity: string }) => { + const numQuantity: number = parseInt(formikValues.quantity); + if (currentQuantityInCart + numQuantity <= (maxPerTeam ?? quantityRemaining)) { + dispatch( + addToCart({ + hardware_id: hardwareId, + quantity: numQuantity, + credits: credits, + }) + ); + dispatch( + displaySnackbar({ + message: `Added ${numQuantity} ${name} item(s) to your cart.`, + options: { + variant: "success", + }, + }) + ); + } else { + dispatch( + displaySnackbar({ + message: `Adding this amount to your cart will exceed the quantity limit for this item.`, + options: { + variant: "warning", + }, + }) + ); + } + }; + + return ( + + {(formikProps) => ( + <> + {currentQuantityInCart > 0 && ( + + You currently have {currentQuantityInCart} of this item in + your cart. + + )} + + + )} + + ); +}; + +interface DetailInfoSectionProps { + manufacturer: string; + modelNumber: string; + datasheet: string; + notes?: string; + constraints: string[]; +} +export const DetailInfoSection = ({ + manufacturer, + modelNumber, + datasheet, + notes, + constraints, +}: DetailInfoSectionProps) => { + return ( + <> + {/*{constraints?.length > 0 && (*/} + {/* <>*/} + {/* */} + {/* Constraints*/} + {/* */} + {/* {constraints.map((constraint, i) => (*/} + {/* - {constraint}*/} + {/* ))}*/} + {/* */} + {/*)}*/} + + Manufacturer + + {manufacturer} + {modelNumber && ( + <> + + Model Number + + {modelNumber} + + )} + {datasheet && ( + <> + + Datasheet + + + + )} + {notes && ( + <> + + Notes + + {notes.split("\n").map((note, i) => ( + {note} + ))} + + )} + + ); +}; + +interface MainSectionProps { + name: string; + quantityAvailable: number; + quantityRemaining: number; + categories: string[]; + picture: string; + credits: number; +} +const MainSection = ({ + name, + quantityAvailable, + quantityRemaining, + categories, + picture, + credits, +}: MainSectionProps) => { + const userType = useSelector(userTypeSelector); + const availability = + quantityRemaining === 0 ? ( + OUT OF STOCK + ) : userType === "participant" ? ( + + {Math.max(quantityRemaining, 0)} IN STOCK + + ) : ( + + {Math.max(quantityRemaining, 0)} OF {quantityAvailable} IN STOCK + + ); + + return ( +
+
+ {name} + {availability} + {credits && ( + + Credits: {credits} + + )} + {categories.length > 0 && ( + <> + + Category + +
+ {categories.map((category, i) => ( + + ))} +
+ + )} +
+ {name} +
+ ); +}; + +export const ProductOverview = ({ + showAddToCartButton, +}: { + showAddToCartButton: boolean; +}) => { + let categoryNames: string[] = []; + let maxPerTeam: number | null = null; + let constraints: string[] = []; + + const isLoading = useSelector(is3dUpdateDetailsLoading); + + const dispatch = useDispatch(); + const closeProductOverviewPanel = () => { + dispatch(closeProductOverview()); + dispatch(remove3dProductOverviewItem()); + }; + + const isProductOverviewVisible: boolean = useSelector( + isProductOverviewVisibleSelector + ); + const hardware = useSelector(hardware3dInProductOverviewSelector); + const categories = useSelector((state: RootState) => + selectCategoriesByIds(state, hardware?.categories || []) + ); + + maxPerTeam = hardware?.max_per_team ?? null; + constraints = !!hardware?.max_per_team + ? [`Max ${hardware.max_per_team} of this item`] + : []; + + if (categories.length > 0) { + categoryNames = categories + .filter((category): category is Category => !!category) + .map((category) => category.name); + for (const category of categories) { + if (category?.max_per_team !== undefined) { + constraints.push( + `Max ${category.max_per_team} of items under category ${category.name}` + ); + maxPerTeam = + maxPerTeam === null + ? category.max_per_team + : Math.min(category.max_per_team, maxPerTeam); + } + } + } + + return ( + + {isLoading ? ( + + ) : hardware ? ( +
+
+ + +
+ + {showAddToCartButton && ( + + )} +
+ ) : ( + + Unable to display hardware. Please refresh the page and try again. + + )} +
+ ); +}; + +export default ProductOverview; diff --git a/hackathon_site/dashboard/frontend/src/pages/Threedprinting/Threedprinting.tsx b/hackathon_site/dashboard/frontend/src/pages/Threedprinting/Threedprinting.tsx index 0d7de78..994847c 100644 --- a/hackathon_site/dashboard/frontend/src/pages/Threedprinting/Threedprinting.tsx +++ b/hackathon_site/dashboard/frontend/src/pages/Threedprinting/Threedprinting.tsx @@ -11,24 +11,41 @@ import CloseIcon from "@material-ui/icons/Close"; import FilterListIcon from "@material-ui/icons/FilterList"; import AlertBox from "components/general/AlertBox/AlertBox"; import InventorySearch from "components/inventory/InventorySearch/InventorySearch"; +import InventorySearch3D from "components/inventory/InventorySearch3D/InventorySearch3D"; + import CircularProgress from "@material-ui/core/CircularProgress"; import styles from "./Threedprinting.module.scss"; import Header from "components/general/Header/Header"; import InventoryFilter from "components/inventory/InventoryFilter/InventoryFilter"; import InventoryGrid from "components/inventory/InventoryGrid/InventoryGrid"; +import InventoryGrid3D from "components/inventory/InventoryGrid3D/InventoryGrid3D"; + import ProductOverview from "components/inventory/ProductOverview/ProductOverview"; +import ProductOverview3D from "components/inventory/ProductOverview3D/ProductOverview3D"; + +// import { +// clearFilters, +// getHardwareWithFilters, +// getHardwareNextPage, +// hardwareCountSelector, +// hardwareSelectors, +// isMoreLoadingSelector, +// isLoadingSelector, +// setFilters, +// } from "slices/hardware/hardwareSlice"; import { - clearFilters, - getHardwareWithFilters, - getHardwareNextPage, - hardwareCountSelector, - hardwareSelectors, - isMoreLoadingSelector, - isLoadingSelector, - setFilters, -} from "slices/hardware/hardwareSlice"; + clear3dClearFilters, + getHardware3dWithFilters, + getHardware3dNextPage, + hardware3dCountSelector, + hardware3dSelectors, + is3dMoreLoadingSelector, + is3dLoadingSelector, + set3dFilters, +} from "slices/hardware/hardware3dSlice"; + import { getCategories, categorySelectorsFiltered, @@ -42,10 +59,10 @@ import { getTeamOrders } from "slices/order/orderSlice"; const Threedprinting = () => { const dispatch = useDispatch(); - const itemsInStore = useSelector(hardwareSelectors.selectTotal); - const count = useSelector(hardwareCountSelector); - const isMoreLoading = useSelector(isMoreLoadingSelector); - const isLoading = useSelector(isLoadingSelector); + const itemsInStore = useSelector(hardware3dSelectors.selectTotal); + const count = useSelector(hardware3dCountSelector); + const isMoreLoading = useSelector(is3dMoreLoadingSelector); + const isLoading = useSelector(is3dLoadingSelector); const userType = useSelector(userTypeSelector); const [mobileOpen, setMobileOpen] = React.useState(false); @@ -54,23 +71,23 @@ const Threedprinting = () => { }; const getMoreHardware = () => { - dispatch(getHardwareNextPage()); + dispatch(getHardware3dNextPage()); }; const refreshHardware = () => { if (threeDPrintingId !== undefined) { - dispatch(setFilters({ category_ids: [threeDPrintingId] })); + dispatch(set3dFilters({ category_ids: [threeDPrintingId] })); } - dispatch(getHardwareWithFilters()); + dispatch(getHardware3dWithFilters()); }; // When the page is loaded, clear filters and fetch fresh inventory data // useEffect(() => { // dispatch(clearFilters()); // if(threeDPrintingId != undefined){ - // dispatch(setFilters({ category_ids: [threeDPrintingId] })); + // dispatch(set3dFilters({ category_ids: [threeDPrintingId] })); // } - // dispatch(getHardwareWithFilters()); + // dispatch(getHardware3dWithFilters()); // dispatch(getCategories()); // // Reload team-related data for participants on page reload for accurate credit usage // if (userType === "participant") { @@ -89,8 +106,8 @@ const Threedprinting = () => { useEffect(() => { if (threeDPrintingId) { // dispatch(clearFilters()); - dispatch(setFilters({ category_ids: [threeDPrintingId] })); - dispatch(getHardwareWithFilters()); + dispatch(set3dFilters({ category_ids: [threeDPrintingId] })); + dispatch(getHardware3dWithFilters()); } if (userType === "participant") { dispatch(getCurrentTeam()); @@ -101,7 +118,8 @@ const Threedprinting = () => { return ( <>
- + {/* */} +
{/* {
- +
{
- + {count > 0 && ( (); +export const initialState = hardwareAdapter.getInitialState(extraState); +export type HardwareState = typeof initialState; + +// Thunks +interface RejectValue { + status: number; + message: any; +} + +export const getHardwareWithFilters = createAsyncThunk< + APIListResponse, + { keepOld?: boolean } | undefined, + { state: RootState; rejectValue: RejectValue; dispatch: AppDispatch } +>( + `${hardwareReducerName}/getHardwareWithFilters`, + async (_, { dispatch, getState, rejectWithValue }) => { + const filters = hardwareFiltersSelector(getState()); + + try { + const response = await get>( + "/api/hardware/hardware/", + filters + ); + return response.data; + } catch (e: any) { + dispatch( + displaySnackbar({ + message: `Failed to fetch hardware data: Error ${e.response.status}`, + options: { variant: "error" }, + }) + ); + return rejectWithValue({ + status: e.response.status, + message: e.response.data, + }); + } + } +); + +export const getHardwareNextPage = createAsyncThunk< + APIListResponse | null, + void, + { state: RootState; rejectValue: RejectValue; dispatch: AppDispatch } +>( + `${hardwareReducerName}/getHardwareNextPage`, + async (_, { dispatch, getState, rejectWithValue }) => { + try { + const nextFromState = hardwareNextSelector(getState()); + if (nextFromState) { + const { path, filters } = stripHostnameReturnFilters(nextFromState); + const response = await get>(path, filters); + return response.data; + } + // return empty response if there is no nextURL + return null; + } catch (e: any) { + dispatch( + displaySnackbar({ + message: `Failed to fetch hardware data: Error ${e.response.status}`, + options: { variant: "error" }, + }) + ); + return rejectWithValue({ + status: e.response.status, + message: e.response.data, + }); + } + } +); + +export const getUpdatedHardwareDetails = createAsyncThunk< + Hardware | null, + number, + { state: RootState; rejectValue: RejectValue; dispatch: AppDispatch } +>( + `${hardwareReducerName}/getHardwareDetails`, + async (hardware_id, { dispatch, getState, rejectWithValue }) => { + try { + const response = await get( + `/api/hardware/hardware/${hardware_id}/` + ); + return response.data; + } catch (e: any) { + dispatch( + displaySnackbar({ + message: `Failed to fetch hardware data: Error ${e.response.status}`, + options: { variant: "error" }, + }) + ); + return rejectWithValue({ + status: e.response.status, + message: e.response.data, + }); + } + } +); + +// Slice +const hardwareSlice = createSlice({ + name: hardwareReducerName, + initialState, + reducers: { + /** + * Update the filters for the Hardware API + * + * To clear a particular filter, set the field to undefined. + */ + setFilters: ( + state: HardwareState, + { payload }: PayloadAction + ) => { + const { in_stock, hardware_ids, category_ids, search, ordering } = { + ...state.filters, + ...payload, + }; + + // Remove values that are empty or falsy + state.filters = { + ...(in_stock && { in_stock }), + ...(hardware_ids && hardware_ids.length > 0 && { hardware_ids }), + ...(category_ids && category_ids.length > 0 && { category_ids }), + ...(search && { search }), + ...(ordering && { ordering }), + }; + }, + + clearFilters: ( + state: HardwareState, + { payload }: PayloadAction<{ saveSearch?: boolean } | undefined> + ) => { + const { search } = state.filters; + + state.filters = {}; + + if (payload?.saveSearch && search) { + state.filters.search = search; + } + }, + + removeProductOverviewItem: (state: HardwareState) => { + state.hardwareIdInProductOverview = null; + }, + }, + extraReducers: (builder) => { + builder.addCase(getHardwareWithFilters.pending, (state) => { + state.isLoading = true; + state.error = null; + }); + + builder.addCase( + getHardwareWithFilters.fulfilled, + (state, { payload, meta }) => { + state.isLoading = false; + state.error = null; + state.next = payload.next; + state.count = payload.count; + + if (meta.arg?.keepOld) { + hardwareAdapter.setMany(state, payload.results); + } else { + hardwareAdapter.setAll(state, payload.results); + } + } + ); + + builder.addCase(getHardwareWithFilters.rejected, (state, { payload }) => { + state.isMoreLoading = false; + state.error = payload?.message || "Something went wrong"; + }); + + builder.addCase(getHardwareNextPage.pending, (state) => { + state.isLoading = false; + state.isMoreLoading = true; + state.error = null; + }); + + builder.addCase(getHardwareNextPage.fulfilled, (state, { payload }) => { + state.isMoreLoading = false; + state.error = null; + if (payload) { + state.next = payload.next; + state.count = payload.count; + hardwareAdapter.addMany(state, payload.results); + } + }); + + builder.addCase(getHardwareNextPage.rejected, (state, { payload }) => { + state.isMoreLoading = false; + state.error = payload?.message || "Something went wrong"; + }); + + builder.addCase(getUpdatedHardwareDetails.pending, (state) => { + state.isUpdateDetailsLoading = true; + }); + + builder.addCase(getUpdatedHardwareDetails.fulfilled, (state, { payload }) => { + if (payload) { + state.isUpdateDetailsLoading = false; + state.hardwareIdInProductOverview = payload.id; + hardwareAdapter.updateOne(state, { + id: payload.id, + changes: payload, + }); + } + }); + + builder.addCase(getUpdatedHardwareDetails.rejected, (state, { payload }) => { + state.isUpdateDetailsLoading = false; + }); + }, +}); + +export const { actions, reducer } = hardwareSlice; +export default reducer; + +export const { setFilters, clearFilters, removeProductOverviewItem } = actions; + +// Selectors +export const hardwareSliceSelector = (state: RootState) => state[hardwareReducerName]; + +export const hardwareSelectors = hardwareAdapter.getSelectors(hardwareSliceSelector); + +export const isLoadingSelector = createSelector( + [hardwareSliceSelector], + (hardwareSlice) => hardwareSlice.isLoading +); + +export const isMoreLoadingSelector = createSelector( + [hardwareSliceSelector], + (hardwareSlice) => hardwareSlice.isMoreLoading +); + +export const isUpdateDetailsLoading = createSelector( + [hardwareSliceSelector], + (hardwareSlice) => hardwareSlice.isUpdateDetailsLoading +); + +export const hardwareCountSelector = createSelector( + [hardwareSliceSelector], + (hardwareSlice) => hardwareSlice.count +); + +export const hardwareFiltersSelector = createSelector( + [hardwareSliceSelector], + (hardwareSlice) => hardwareSlice.filters +); + +export const hardwareNextSelector = createSelector( + [hardwareSliceSelector], + (hardwareSlice) => hardwareSlice.next +); + +export const selectHardwareByIds = createSelector( + [hardwareSelectors.selectEntities, (state: RootState, ids: number[]) => ids], + (entities, ids) => ids.map((id) => entities?.[id]) +); + +export const hardwareInProductOverviewSelector = createSelector( + [hardwareSelectors.selectEntities, hardwareSliceSelector], + (entities, hardwareSlice) => + hardwareSlice.hardwareIdInProductOverview + ? entities[hardwareSlice.hardwareIdInProductOverview] + : null +); diff --git a/hackathon_site/dashboard/frontend/src/slices/hardware/hardware3dSlice.ts b/hackathon_site/dashboard/frontend/src/slices/hardware/hardware3dSlice.ts new file mode 100644 index 0000000..ce98623 --- /dev/null +++ b/hackathon_site/dashboard/frontend/src/slices/hardware/hardware3dSlice.ts @@ -0,0 +1,310 @@ +import { + createAsyncThunk, + createEntityAdapter, + createSelector, + createSlice, + PayloadAction, +} from "@reduxjs/toolkit"; +import { AppDispatch, RootState } from "slices/store"; + +import { get, stripHostnameReturnFilters } from "api/api"; +import { APIListResponse, Hardware, HardwareFilters } from "api/types"; +import { displaySnackbar } from "slices/ui/uiSlice"; + +interface HardwareExtraState { + isUpdateDetailsLoading: boolean; + isLoading: boolean; + isMoreLoading: boolean; + error: string | null; + next: string | null; + filters: HardwareFilters; + count: number; + hardwareIdInProductOverview: number | null; +} + +const extraState: HardwareExtraState = { + isUpdateDetailsLoading: false, + isLoading: false, + isMoreLoading: false, + error: null, + next: null, + filters: {}, + count: 0, + hardwareIdInProductOverview: null, +}; + +// export const hardwareReducerName = "hardware"; +export const hardwareReducerName = "hardware3d"; + +const hardwareAdapter = createEntityAdapter(); +export const initialState = hardwareAdapter.getInitialState(extraState); +export type HardwareState = typeof initialState; + +// Thunks +interface RejectValue { + status: number; + message: any; +} + +export const getHardware3dWithFilters = createAsyncThunk< + APIListResponse, + { keepOld?: boolean } | undefined, + { state: RootState; rejectValue: RejectValue; dispatch: AppDispatch } +>( + `${hardwareReducerName}/getHardware3dWithFilters`, + async (_, { dispatch, getState, rejectWithValue }) => { + const filters = hardware3dFiltersSelector(getState()); + + try { + const response = await get>( + "/api/hardware/hardware/", + filters + ); + return response.data; + } catch (e: any) { + dispatch( + displaySnackbar({ + message: `Failed to fetch hardware data: Error ${e.response.status}`, + options: { variant: "error" }, + }) + ); + return rejectWithValue({ + status: e.response.status, + message: e.response.data, + }); + } + } +); + +export const getHardware3dNextPage = createAsyncThunk< + APIListResponse | null, + void, + { state: RootState; rejectValue: RejectValue; dispatch: AppDispatch } +>( + `${hardwareReducerName}/getHardware3dNextPage`, + async (_, { dispatch, getState, rejectWithValue }) => { + try { + const nextFromState = hardware3dNextSelector(getState()); + if (nextFromState) { + const { path, filters } = stripHostnameReturnFilters(nextFromState); + const response = await get>(path, filters); + return response.data; + } + // return empty response if there is no nextURL + return null; + } catch (e: any) { + dispatch( + displaySnackbar({ + message: `Failed to fetch hardware data: Error ${e.response.status}`, + options: { variant: "error" }, + }) + ); + return rejectWithValue({ + status: e.response.status, + message: e.response.data, + }); + } + } +); + +export const getUpdatedHardware3dDetails = createAsyncThunk< + Hardware | null, + number, + { state: RootState; rejectValue: RejectValue; dispatch: AppDispatch } +>( + `${hardwareReducerName}/getHardwareDetails`, + async (hardware_id, { dispatch, getState, rejectWithValue }) => { + try { + const response = await get( + `/api/hardware/hardware/${hardware_id}/` + ); + return response.data; + } catch (e: any) { + dispatch( + displaySnackbar({ + message: `Failed to fetch hardware data: Error ${e.response.status}`, + options: { variant: "error" }, + }) + ); + return rejectWithValue({ + status: e.response.status, + message: e.response.data, + }); + } + } +); + +// Slice +const hardwareSlice = createSlice({ + name: hardwareReducerName, + initialState, + reducers: { + /** + * Update the filters for the Hardware API + * + * To clear a particular filter, set the field to undefined. + */ + setFilters: ( + state: HardwareState, + { payload }: PayloadAction + ) => { + const { in_stock, hardware_ids, category_ids, search, ordering } = { + ...state.filters, + ...payload, + }; + + // Remove values that are empty or falsy + state.filters = { + ...(in_stock && { in_stock }), + ...(hardware_ids && hardware_ids.length > 0 && { hardware_ids }), + ...(category_ids && category_ids.length > 0 && { category_ids }), + ...(search && { search }), + ...(ordering && { ordering }), + }; + }, + + clearFilters: ( + state: HardwareState, + { payload }: PayloadAction<{ saveSearch?: boolean } | undefined> + ) => { + const { search } = state.filters; + + state.filters = {}; + + if (payload?.saveSearch && search) { + state.filters.search = search; + } + }, + + removeProductOverviewItem: (state: HardwareState) => { + state.hardwareIdInProductOverview = null; + }, + }, + extraReducers: (builder) => { + builder.addCase(getHardware3dWithFilters.pending, (state) => { + state.isLoading = true; + state.error = null; + }); + + builder.addCase( + getHardware3dWithFilters.fulfilled, + (state, { payload, meta }) => { + state.isLoading = false; + state.error = null; + state.next = payload.next; + state.count = payload.count; + + if (meta.arg?.keepOld) { + hardwareAdapter.setMany(state, payload.results); + } else { + hardwareAdapter.setAll(state, payload.results); + } + } + ); + + builder.addCase(getHardware3dWithFilters.rejected, (state, { payload }) => { + state.isMoreLoading = false; + state.error = payload?.message || "Something went wrong"; + }); + + builder.addCase(getHardware3dNextPage.pending, (state) => { + state.isLoading = false; + state.isMoreLoading = true; + state.error = null; + }); + + builder.addCase(getHardware3dNextPage.fulfilled, (state, { payload }) => { + state.isMoreLoading = false; + state.error = null; + if (payload) { + state.next = payload.next; + state.count = payload.count; + hardwareAdapter.addMany(state, payload.results); + } + }); + + builder.addCase(getHardware3dNextPage.rejected, (state, { payload }) => { + state.isMoreLoading = false; + state.error = payload?.message || "Something went wrong"; + }); + + builder.addCase(getUpdatedHardware3dDetails.pending, (state) => { + state.isUpdateDetailsLoading = true; + }); + + builder.addCase(getUpdatedHardware3dDetails.fulfilled, (state, { payload }) => { + if (payload) { + state.isUpdateDetailsLoading = false; + state.hardwareIdInProductOverview = payload.id; + hardwareAdapter.updateOne(state, { + id: payload.id, + changes: payload, + }); + } + }); + + builder.addCase(getUpdatedHardware3dDetails.rejected, (state, { payload }) => { + state.isUpdateDetailsLoading = false; + }); + }, +}); + +export const { actions, reducer } = hardwareSlice; +export default reducer; + +// export const { setFilters, clearFilters, removeProductOverviewItem } = actions; + +// Selectors + +export const { + setFilters: set3dFilters, + clearFilters: clear3dClearFilters, + removeProductOverviewItem: remove3dProductOverviewItem, +} = actions; + +export const hardwareSliceSelector = (state: RootState) => state[hardwareReducerName]; +export const hardware3dSelectors = hardwareAdapter.getSelectors(hardwareSliceSelector); +// export const hardware3dSelectors = hardware3dSelectors; + +export const is3dLoadingSelector = createSelector( + [hardwareSliceSelector], + (hardwareSlice) => hardwareSlice.isLoading +); + +export const is3dMoreLoadingSelector = createSelector( + [hardwareSliceSelector], + (hardwareSlice) => hardwareSlice.isMoreLoading +); + +export const is3dUpdateDetailsLoading = createSelector( + [hardwareSliceSelector], + (hardwareSlice) => hardwareSlice.isUpdateDetailsLoading +); + +export const hardware3dCountSelector = createSelector( + [hardwareSliceSelector], + (hardwareSlice) => hardwareSlice.count +); + +export const hardware3dFiltersSelector = createSelector( + [hardwareSliceSelector], + (hardwareSlice) => hardwareSlice.filters +); + +export const hardware3dNextSelector = createSelector( + [hardwareSliceSelector], + (hardwareSlice) => hardwareSlice.next +); + +export const selectHardwareByIds = createSelector( + [hardware3dSelectors.selectEntities, (state: RootState, ids: number[]) => ids], + (entities, ids) => ids.map((id) => entities?.[id]) +); + +export const hardware3dInProductOverviewSelector = createSelector( + [hardware3dSelectors.selectEntities, hardwareSliceSelector], + (entities, hardwareSlice) => + hardwareSlice.hardwareIdInProductOverview + ? entities[hardwareSlice.hardwareIdInProductOverview] + : null +); diff --git a/hackathon_site/dashboard/frontend/src/slices/store.ts b/hackathon_site/dashboard/frontend/src/slices/store.ts index 0759e73..68798bd 100644 --- a/hackathon_site/dashboard/frontend/src/slices/store.ts +++ b/hackathon_site/dashboard/frontend/src/slices/store.ts @@ -17,6 +17,9 @@ import { import userReducer, { userReducerName } from "slices/users/userSlice"; import uiReducer, { uiReducerName } from "slices/ui/uiSlice"; import hardwareReducer, { hardwareReducerName } from "slices/hardware/hardwareSlice"; +import hardware3dReducer, { + hardwareReducerName as hardware3dReducerName, +} from "slices/hardware/hardware3dSlice"; import orderReducer, { orderReducerName } from "slices/order/orderSlice"; import categoryReducer, { categoryReducerName } from "slices/hardware/categorySlice"; import cartReducer, { cartReducerName } from "slices/hardware/cartSlice"; @@ -35,6 +38,7 @@ const reducers = { [teamDetailReducerName]: teamDetailReducer, [categoryReducerName]: categoryReducer, [hardwareReducerName]: hardwareReducer, + [hardware3dReducerName]: hardware3dReducer, [orderReducerName]: orderReducer, [userReducerName]: userReducer, [uiReducerName]: uiReducer,