diff --git a/hackathon_site/dashboard/frontend/src/App.tsx b/hackathon_site/dashboard/frontend/src/App.tsx index bee989b..ec4aec5 100644 --- a/hackathon_site/dashboard/frontend/src/App.tsx +++ b/hackathon_site/dashboard/frontend/src/App.tsx @@ -19,6 +19,8 @@ import Orders from "pages/Orders/Orders"; import Teams from "pages/Teams/Teams"; import Reports from "pages/Reports/Reports"; import Inventory from "pages/Inventory/Inventory"; +import Threedprinting from "pages/Threedprinting/Threedprinting"; +// import ThreeDPrinting from "pages/ThreeDPrinting/ThreeDPrinting" import Cart from "pages/Cart/Cart"; import IncidentForm from "pages/IncidentForm/IncidentForm"; import NotFound from "pages/NotFound/NotFound"; @@ -84,6 +86,11 @@ const UnconnectedApp = () => { path="/inventory" component={withUserCheck("both", Inventory)} /> + { )} + {isParticipantOrAdmin && ( + + + } + > + 3D Printing Services + + + )} {isParticipant && ( { - const categories = useSelector(categorySelectors.selectAll); + const categories = useSelector(categorySelectorsFiltered); const isCategoriesLoading = useSelector(isCategoriesLoadingSelector); const isHardwareLoading = useSelector(isHardwareLoadingSelector); return ( @@ -169,12 +171,14 @@ export const InventoryFilter = ({ handleReset, handleSubmit }: FormikValues) => export const EnhancedInventoryFilter = () => { const dispatch = useDispatch(); - + const nonThreeDIds = useSelector(selectNonThreeDCategoryIds); const onSubmit = ({ ordering, in_stock, categories }: InventoryFilterValues) => { + const selectedIds = categories.map((id) => parseInt(id, 10)); const filters: HardwareFilters = { ordering, in_stock: in_stock || undefined, // If false, it will be cleared below - category_ids: categories.map((id) => parseInt(id, 10)), + // category_ids: categories.map((id) => parseInt(id, 10)), + category_ids: selectedIds.length > 0 ? selectedIds : nonThreeDIds, }; dispatch(setFilters(filters)); 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 = ( + + Add to cart ({totalCredits} Credits) + + ); + return ( + <> + + + Qty + + {createQuantityList(dropdownNum)} + + + + {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 + + } + > + Link + + > + )} + {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) => ( + + ))} + + > + )} + + + + ); +}; + +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/components/orders/OrdersFilter/OrderFilter.tsx b/hackathon_site/dashboard/frontend/src/components/orders/OrdersFilter/OrderFilter.tsx index f67103b..b7dccf6 100644 --- a/hackathon_site/dashboard/frontend/src/components/orders/OrdersFilter/OrderFilter.tsx +++ b/hackathon_site/dashboard/frontend/src/components/orders/OrdersFilter/OrderFilter.tsx @@ -1,21 +1,22 @@ -import React from "react"; -import { Formik, Field, FieldProps, FormikValues } from "formik"; -import Typography from "@material-ui/core/Typography"; import Button from "@material-ui/core/Button"; +import Checkbox from "@material-ui/core/Checkbox"; import Chip from "@material-ui/core/Chip"; -import Paper from "@material-ui/core/Paper"; import Divider from "@material-ui/core/Divider"; -import Checkbox from "@material-ui/core/Checkbox"; +import FormControlLabel from "@material-ui/core/FormControlLabel"; +import FormGroup from "@material-ui/core/FormGroup"; +import Paper from "@material-ui/core/Paper"; import Radio from "@material-ui/core/Radio"; import RadioGroup from "@material-ui/core/RadioGroup"; -import FormGroup from "@material-ui/core/FormGroup"; -import FormControlLabel from "@material-ui/core/FormControlLabel"; -import { OrderOrdering, OrderStatus, OrderFilters } from "api/types"; +import Typography from "@material-ui/core/Typography"; +import { OrderFilters, OrderOrdering, OrderStatus } from "api/types"; import styles from "components/sharedStyles/Filter.module.scss"; +import { Field, FieldProps, Formik, FormikValues } from "formik"; import { useDispatch, useSelector } from "react-redux"; import { + adminOrderFiltersSelector, adminOrderNumStatusesSelector, clearFilters, + getOrderStatusCounts, getOrdersWithFilters, setFilters, } from "slices/order/adminOrderSlice"; @@ -165,6 +166,7 @@ const OrderFilter = ({ handleReset, handleSubmit }: FormikValues) => { export const EnhancedOrderFilter = () => { const dispatch = useDispatch(); + const savedFilters = useSelector(adminOrderFiltersSelector); // Filter Bug Fix const handleSubmit = ({ ordering, status }: OrderFilters) => { const limit = 1000; @@ -179,15 +181,19 @@ export const EnhancedOrderFilter = () => { const handleReset = () => { dispatch(clearFilters({ saveSearch: true })); + dispatch(getOrderStatusCounts()); dispatch(getOrdersWithFilters()); }; return ( { + // const initialValues = { + // search: "", + // }; + const savedFilters = useSelector(adminOrderFiltersSelector); const initialValues = { - search: "", - }; + search: savedFilters.search ?? "", + }; // Filter Bux Fix const onSubmit = ({ search }: SearchValues) => { setFilters({ search }); @@ -81,6 +85,7 @@ export const EnhancedOrderSearch = ({ return ( { const dispatch = useDispatch(); @@ -42,7 +46,6 @@ const Inventory = () => { const isMoreLoading = useSelector(isMoreLoadingSelector); const isLoading = useSelector(isLoadingSelector); const userType = useSelector(userTypeSelector); - const [mobileOpen, setMobileOpen] = React.useState(false); const toggleFilter = () => { setMobileOpen(!mobileOpen); @@ -53,20 +56,30 @@ const Inventory = () => { }; const refreshHardware = () => { + if (nonThreeDPrintingIDs !== undefined) { + dispatch(setFilters({ category_ids: nonThreeDPrintingIDs })); + } dispatch(getHardwareWithFilters()); }; - // When the page is loaded, clear filters and fetch fresh inventory data useEffect(() => { - dispatch(clearFilters()); - dispatch(getHardwareWithFilters()); dispatch(getCategories()); - // Reload team-related data for participants on page reload for accurate credit usage + }, [dispatch]); + + const nonThreeDPrintingIDs = useSelector(selectNonThreeDCategoryIds); + console.log("NonThreeDPrintingIDs are ", nonThreeDPrintingIDs); + + useEffect(() => { + if (nonThreeDPrintingIDs && nonThreeDPrintingIDs.length > 0) { + // dispatch(clearFilters()); + dispatch(setFilters({ category_ids: nonThreeDPrintingIDs })); + dispatch(getHardwareWithFilters()); + } if (userType === "participant") { dispatch(getCurrentTeam()); dispatch(getTeamOrders()); } - }, [dispatch, userType]); + }, [dispatch, userType, nonThreeDPrintingIDs]); return ( <> @@ -99,6 +112,13 @@ const Inventory = () => { } type={"info"} /> + {userType === "participant" && } diff --git a/hackathon_site/dashboard/frontend/src/pages/Orders/Orders.tsx b/hackathon_site/dashboard/frontend/src/pages/Orders/Orders.tsx index 66a0c16..b8a4b15 100644 --- a/hackathon_site/dashboard/frontend/src/pages/Orders/Orders.tsx +++ b/hackathon_site/dashboard/frontend/src/pages/Orders/Orders.tsx @@ -12,6 +12,7 @@ import styles from "./Orders.module.scss"; import { useDispatch, useSelector } from "react-redux"; import { adminOrderSelectors, + getOrderStatusCounts, getOrdersWithFilters, clearFilters, } from "slices/order/adminOrderSlice"; @@ -26,7 +27,8 @@ const Orders = () => { }; useEffect(() => { - dispatch(clearFilters()); + // dispatch(clearFilters()); // Filter Bug Fix + dispatch(getOrderStatusCounts()); // Filter Bug Fix dispatch(getOrdersWithFilters()); }, [dispatch]); diff --git a/hackathon_site/dashboard/frontend/src/pages/Threedprinting/Threedprinting.module.scss b/hackathon_site/dashboard/frontend/src/pages/Threedprinting/Threedprinting.module.scss new file mode 100644 index 0000000..bd2004a --- /dev/null +++ b/hackathon_site/dashboard/frontend/src/pages/Threedprinting/Threedprinting.module.scss @@ -0,0 +1,110 @@ +@import "./../../assets/abstracts/variables"; +@import "./../../assets/abstracts/mixins"; + +.threedprinting { + display: flex; + flex-direction: column; + max-width: 1800px; + width: 100%; + + &Body { + display: flex; + margin-top: 30px; + + &Toolbar { + @include flexPosition($jusCon: flex-end); + width: 100%; + margin-bottom: 25px; + + &Div { + @include flexPosition($jusCon: space-between, $alIte: center); + width: auto; + } + + &Search { + background-color: color(white); + margin-right: 8px; + width: 250px; + } + + &Divider { + margin: 0 25px; + } + + &Refresh { + @include flexPosition($jusCon: space-between, $alIte: center); + white-space: nowrap; + } + } + + @include responsive(sm-down) { + &Toolbar { + flex-direction: column; + margin-bottom: 5px; + + &Div { + width: 100%; + } + + &Div:last-child { + margin: 10px 0; + } + + &Divider { + display: none; + } + + &Refresh { + margin-left: 0; + } + } + } + + @include responsive(md-down) { + margin-top: 15px; + + &Toolbar { + &Refresh { + margin-left: 20px; + } + + &Search { + width: 100%; + } + + &Div:nth-child(1) { + width: 100%; + } + } + } + } + + &FilterDrawer { + width: 280px; + flex-shrink: 0; + z-index: 1; + &Top { + width: 100%; + @include flexPosition($jusCon: space-between, $alIte: center); + + background-color: color(light1); + padding: 8px 15px; + svg { + fill: color(grey); + } + @include responsive(sm-down) { + padding: 4px 15px; + } + + > h2 { + @include responsive(md-down) { + font-size: size(md) !important; + } + } + } + } + + &LoadDivider { + margin: 30px 0; + } +} diff --git a/hackathon_site/dashboard/frontend/src/pages/Threedprinting/Threedprinting.test.tsx b/hackathon_site/dashboard/frontend/src/pages/Threedprinting/Threedprinting.test.tsx new file mode 100644 index 0000000..ba463e5 --- /dev/null +++ b/hackathon_site/dashboard/frontend/src/pages/Threedprinting/Threedprinting.test.tsx @@ -0,0 +1,333 @@ +import React from "react"; + +import { + makeMockApiListResponse, + render, + waitFor, + when, + fireEvent, + promiseResolveWithDelay, + makeMockApiResponse, +} from "testing/utils"; + +import Inventory from "pages/Inventory/Inventory"; +import { mockCategories, mockHardware } from "testing/mockData"; + +import { get, stripHostnameReturnFilters } from "api/api"; + +jest.mock("api/api", () => ({ + ...jest.requireActual("api/api"), + get: jest.fn(), +})); +const mockedGet = get as jest.MockedFunction; + +const hardwareUri = "/api/hardware/hardware/"; +const categoriesUri = "/api/hardware/categories/"; + +describe("Inventory Page", () => { + it("Clears filters and fetches fresh data on load", () => { + render(); + + expect(get).toHaveBeenCalledWith(hardwareUri, {}); + expect(get).toHaveBeenCalledWith(categoriesUri, {}); + }); + + it("Has necessary page elements", async () => { + // Mock inventory data to make sure the inventory grid and filters + // are being rendered + const hardwareApiResponse = makeMockApiListResponse(mockHardware); + const categoryApiResponse = makeMockApiListResponse(mockCategories); + + when(mockedGet) + .calledWith(hardwareUri, {}) + .mockResolvedValue(hardwareApiResponse); + when(mockedGet) + .calledWith(categoriesUri, {}) + .mockResolvedValue(categoryApiResponse); + + const { getByText } = render(); + + await waitFor(() => { + expect(getByText(mockHardware[0].name)).toBeInTheDocument(); + expect(getByText(mockCategories[0].name)).toBeInTheDocument(); + }); + + expect(getByText(`${mockHardware.length} results`)).toBeInTheDocument(); + }); + + it("Shows count and load more button when there is more hardware to fetch", async () => { + // Mock hardware api response with a limit of mockHardware.length - 2 + const limit = mockHardware.length - 2; + const hardwareApiResponse = makeMockApiListResponse( + mockHardware.slice(0, limit), + null, + null, + mockHardware.length + ); + const categoryApiResponse = makeMockApiListResponse(mockCategories); + + when(mockedGet) + .calledWith(hardwareUri, {}) + .mockResolvedValue(hardwareApiResponse); + when(mockedGet) + .calledWith(categoriesUri, {}) + .mockResolvedValue(categoryApiResponse); + + const { getByText, getByTestId } = render(); + + await waitFor(() => { + expect(getByText(mockHardware[0].name)).toBeInTheDocument(); + }); + + expect(getByTestId("inventoryCountDivider")).toBeInTheDocument(); + expect( + getByText(`SHOWING ${limit} OF ${mockHardware.length} ITEMS`) + ).toBeInTheDocument(); + expect(getByText(/load more/i)).toBeInTheDocument(); + expect(getByText(`${mockHardware.length} results`)).toBeInTheDocument(); + }); + + it("Shows count and no load more button when there is no more hardware to fetch", async () => { + // Mock hardware api response + const hardwareApiResponse = makeMockApiListResponse(mockHardware); + const categoryApiResponse = makeMockApiListResponse(mockCategories); + + when(mockedGet) + .calledWith(hardwareUri, {}) + .mockResolvedValue(hardwareApiResponse); + when(mockedGet) + .calledWith(categoriesUri, {}) + .mockResolvedValue(categoryApiResponse); + + const { getByText, queryByText, getByTestId } = render(); + + await waitFor(() => { + expect(getByText(mockHardware[0].name)).toBeInTheDocument(); + }); + + expect(getByTestId("inventoryCountDivider")).toBeInTheDocument(); + expect( + getByText(`SHOWING ${mockHardware.length} OF ${mockHardware.length} ITEMS`) + ).toBeInTheDocument(); + expect(queryByText(/load more/i)).not.toBeInTheDocument(); + expect(getByText(`${mockHardware.length} results`)).toBeInTheDocument(); + }); + + it("Displays a message when no items are found", async () => { + // Mock hardware api response + const hardwareApiResponse = makeMockApiListResponse([]); + const categoryApiResponse = makeMockApiListResponse(mockCategories); + + when(mockedGet) + .calledWith(hardwareUri, {}) + .mockResolvedValue(hardwareApiResponse); + when(mockedGet) + .calledWith(categoriesUri, {}) + .mockResolvedValue(categoryApiResponse); + + const { getByText, queryByTestId } = render(); + + expect(queryByTestId("inventoryCountDivider")).not.toBeInTheDocument(); + expect(getByText(/loading/i)).toBeInTheDocument(); + await waitFor(() => { + expect(getByText(/no items found/i)).toBeInTheDocument(); + }); + }); + + it("Shows product overview when hardware item is clicked", async () => { + const hardwareDetailUri = "/api/hardware/hardware/1/"; + const newHardwareData = { + ...mockHardware[0], + name: "Randome hardware", + model_number: "90", + manufacturer: "Tesla", + datasheet: "", + quantity_available: 5, + max_per_team: 6, + picture: "https://example.com/datasheet", + categories: [2], + quantity_remaining: 10, + notes: "notes on temp", + }; + + const hardwareApiResponse = makeMockApiListResponse(mockHardware); + const categoryApiResponse = makeMockApiListResponse(mockCategories); + const hardwareDetailApiResponse = makeMockApiResponse(newHardwareData); + + when(mockedGet) + .calledWith(hardwareUri, {}) + .mockResolvedValue(hardwareApiResponse); + when(mockedGet) + .calledWith(categoriesUri, {}) + .mockResolvedValue(categoryApiResponse); + when(mockedGet) + .calledWith(hardwareDetailUri) + .mockResolvedValue(hardwareDetailApiResponse); + + const { getByText } = render(); + + await waitFor(() => { + mockHardware.forEach((hardware) => + expect(getByText(hardware.name)).toBeInTheDocument() + ); + }); + + fireEvent.click(getByText(mockHardware[0].name)); + + await waitFor(() => { + expect(get).toHaveBeenNthCalledWith(3, hardwareDetailUri); + expect(getByText("Product Overview")).toBeVisible(); + expect( + getByText(`- Max ${newHardwareData.max_per_team} of this item`) + ).toBeInTheDocument(); + expect( + getByText( + `- Max ${mockCategories[1].max_per_team} of items under category ${mockCategories[1].name}` + ) + ).toBeInTheDocument(); + expect(getByText(newHardwareData.model_number)).toBeInTheDocument(); + expect(getByText(newHardwareData.manufacturer)).toBeInTheDocument(); + expect(getByText(newHardwareData.notes)).toBeInTheDocument(); + }); + }); + + it("Loads more hardware", async () => { + // Mock hardware api response with a limit of mockHardware.length - 2 + const limit = mockHardware.length - 2; + const nextURL = `http://localhost:8000${hardwareUri}?offset=${limit}`; + const hardwareApiResponse = makeMockApiListResponse( + mockHardware.slice(0, limit), + nextURL, + null, + mockHardware.length + ); + const hardwareNextApiResponse = makeMockApiListResponse( + mockHardware.slice(limit), + null, + hardwareUri, + mockHardware.length + ); + const categoryApiResponse = makeMockApiListResponse(mockCategories); + + const { path, filters } = stripHostnameReturnFilters(nextURL); + + when(mockedGet) + .calledWith(hardwareUri, {}) + .mockResolvedValue(hardwareApiResponse); + when(mockedGet) + .calledWith(path, filters) + .mockResolvedValue(hardwareNextApiResponse); + when(mockedGet) + .calledWith(categoriesUri, {}) + .mockResolvedValue(categoryApiResponse); + + const { getByText, queryByText } = render(); + + await waitFor(() => { + expect(getByText(mockHardware[0].name)).toBeInTheDocument(); + expect(queryByText(mockHardware[limit].name)).not.toBeInTheDocument(); + }); + expect( + getByText(`SHOWING ${limit} OF ${mockHardware.length} ITEMS`) + ).toBeInTheDocument(); + + fireEvent.click(getByText(/load more/i)); + await waitFor(() => { + mockHardware + .slice(limit) + .forEach((hardware) => + expect(getByText(hardware.name)).toBeInTheDocument() + ); + }); + expect( + getByText(`SHOWING ${mockHardware.length} OF ${mockHardware.length} ITEMS`) + ).toBeInTheDocument(); + }); + + it("Can refresh hardware", async () => { + const hardwareApiResponse = makeMockApiListResponse(mockHardware); + const hardwareApiResponseAfterRefresh = makeMockApiListResponse( + mockHardware.slice(2) + ); + const categoryApiResponse = makeMockApiListResponse(mockCategories); + + // first mocked resolved value is the original hardware response + // second mock is after refresh + when(mockedGet) + .calledWith(hardwareUri, {}) + .mockResolvedValueOnce(hardwareApiResponse) + .mockResolvedValue(hardwareApiResponseAfterRefresh); + when(mockedGet) + .calledWith(categoriesUri, {}) + .mockResolvedValue(categoryApiResponse); + + const { getByText, getByTestId, queryByText } = render(); + await waitFor(() => { + expect(getByText(mockHardware[0].name)).toBeInTheDocument(); + expect(getByText(mockCategories[0].name)).toBeInTheDocument(); + }); + expect(getByText(`${mockHardware.length} results`)).toBeInTheDocument(); + + fireEvent.click(getByTestId("refreshInventory")); + await waitFor(() => { + expect(queryByText(mockHardware[0].name)).not.toBeInTheDocument(); + expect(queryByText(mockHardware[1].name)).not.toBeInTheDocument(); + expect(getByText(mockHardware[2].name)).toBeInTheDocument(); + }); + expect(getByText(`${mockHardware.length - 2} results`)).toBeInTheDocument(); + }); + + it("Renders loading icon on load more button, then displays hardware", async () => { + const limit = mockHardware.length - 2; + const nextURL = `http://localhost:8000${hardwareUri}?offset=${limit}`; + const hardwareApiResponse = makeMockApiListResponse( + mockHardware.slice(0, limit), + nextURL, + null, + mockHardware.length + ); + const hardwareNextApiResponse = makeMockApiListResponse( + mockHardware.slice(limit), + null, + hardwareUri, + mockHardware.length + ); + const categoryApiResponse = makeMockApiListResponse(mockCategories); + + const { path, filters } = stripHostnameReturnFilters(nextURL); + + when(mockedGet) + .calledWith(hardwareUri, {}) + .mockResolvedValue(hardwareApiResponse); + when(mockedGet) + .calledWith(path, filters) + .mockReturnValue(promiseResolveWithDelay(hardwareNextApiResponse, 500)); + when(mockedGet) + .calledWith(categoriesUri, {}) + .mockResolvedValue(categoryApiResponse); + + const { getByText, queryByText, queryByTestId } = render(); + + await waitFor(() => { + expect(getByText(mockHardware[0].name)).toBeInTheDocument(); + expect(queryByText(mockHardware[limit].name)).not.toBeInTheDocument(); + }); + + fireEvent.click(getByText(/load more/i)); + + await waitFor(() => { + expect( + queryByTestId("load-more-hardware-circular-progress") + ).toBeInTheDocument(); + }); + + await waitFor(() => { + expect( + queryByTestId("load-more-hardware-circular-progress") + ).not.toBeInTheDocument(); + for (let hardware of mockHardware) { + expect(getByText(hardware.name)).toBeInTheDocument(); + } + }); + }); +}); diff --git a/hackathon_site/dashboard/frontend/src/pages/Threedprinting/Threedprinting.tsx b/hackathon_site/dashboard/frontend/src/pages/Threedprinting/Threedprinting.tsx new file mode 100644 index 0000000..994847c --- /dev/null +++ b/hackathon_site/dashboard/frontend/src/pages/Threedprinting/Threedprinting.tsx @@ -0,0 +1,238 @@ +import React, { useEffect } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import Drawer from "@material-ui/core/Drawer"; +import Typography from "@material-ui/core/Typography"; +import Divider from "@material-ui/core/Divider"; +import IconButton from "@material-ui/core/IconButton"; +import Hidden from "@material-ui/core/Hidden"; +import Button from "@material-ui/core/Button"; +import RefreshIcon from "@material-ui/icons/Refresh"; +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 { + clear3dClearFilters, + getHardware3dWithFilters, + getHardware3dNextPage, + hardware3dCountSelector, + hardware3dSelectors, + is3dMoreLoadingSelector, + is3dLoadingSelector, + set3dFilters, +} from "slices/hardware/hardware3dSlice"; + +import { + getCategories, + categorySelectorsFiltered, + selectThreeDPrintingId, +} from "slices/hardware/categorySlice"; +import { Grid } from "@material-ui/core"; +import { userTypeSelector } from "slices/users/userSlice"; +import DateRestrictionAlert from "components/general/DateRestrictionAlert/DateRestrictionAlert"; +import { getCurrentTeam } from "slices/event/teamSlice"; +import { getTeamOrders } from "slices/order/orderSlice"; + +const Threedprinting = () => { + const dispatch = useDispatch(); + 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); + const toggleFilter = () => { + setMobileOpen(!mobileOpen); + }; + + const getMoreHardware = () => { + dispatch(getHardware3dNextPage()); + }; + + const refreshHardware = () => { + if (threeDPrintingId !== undefined) { + dispatch(set3dFilters({ category_ids: [threeDPrintingId] })); + } + dispatch(getHardware3dWithFilters()); + }; + + // When the page is loaded, clear filters and fetch fresh inventory data + // useEffect(() => { + // dispatch(clearFilters()); + // if(threeDPrintingId != undefined){ + // dispatch(set3dFilters({ category_ids: [threeDPrintingId] })); + // } + // dispatch(getHardware3dWithFilters()); + // dispatch(getCategories()); + // // Reload team-related data for participants on page reload for accurate credit usage + // if (userType === "participant") { + // dispatch(getCurrentTeam()); + // dispatch(getTeamOrders()); + // } + // }, [dispatch, userType]); + + useEffect(() => { + dispatch(getCategories()); + }, [dispatch]); + + const threeDPrintingId = useSelector(selectThreeDPrintingId); + console.log("ThreeDprinting ID is ", threeDPrintingId); + + useEffect(() => { + if (threeDPrintingId) { + // dispatch(clearFilters()); + dispatch(set3dFilters({ category_ids: [threeDPrintingId] })); + dispatch(getHardware3dWithFilters()); + } + if (userType === "participant") { + dispatch(getCurrentTeam()); + dispatch(getTeamOrders()); + } + }, [dispatch, userType, threeDPrintingId]); + + return ( + <> + + {/* */} + + + {/* + + Filters + + + + + + */} + + 3D Printing Services + + {userType === "participant" && } + + + {/* + + + + */} + + + + + + + + + + + + } + onClick={toggleFilter} + > + Filter + + + + + + {count} results + + + + + + + + + {count > 0 && ( + + )} + + {count > 0 + ? `SHOWING ${itemsInStore} OF ${count} ITEMS` + : isLoading + ? "LOADING" + : "NO ITEMS FOUND"} + + {count !== itemsInStore && ( + + {isMoreLoading ? ( + + ) : ( + "Load more" + )} + + )} + + + + > + ); +}; + +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); + } +); diff --git a/hackathon_site/dashboard/frontend/src/slices/hardware/hardware3dSlice.test.ts b/hackathon_site/dashboard/frontend/src/slices/hardware/hardware3dSlice.test.ts new file mode 100644 index 0000000..00f9973 --- /dev/null +++ b/hackathon_site/dashboard/frontend/src/slices/hardware/hardware3dSlice.test.ts @@ -0,0 +1,301 @@ +import { + createSlice, + createEntityAdapter, + PayloadAction, + createSelector, + createAsyncThunk, +} from "@reduxjs/toolkit"; +import { RootState, AppDispatch } from "slices/store"; + +import { APIListResponse, Hardware, HardwareFilters } from "api/types"; +import { get, stripHostnameReturnFilters } from "api/api"; +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"; +const hardwareAdapter = createEntityAdapter(); +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/order/adminOrderSlice.ts b/hackathon_site/dashboard/frontend/src/slices/order/adminOrderSlice.ts index 4444830..225012d 100644 --- a/hackathon_site/dashboard/frontend/src/slices/order/adminOrderSlice.ts +++ b/hackathon_site/dashboard/frontend/src/slices/order/adminOrderSlice.ts @@ -26,10 +26,23 @@ interface adminOrderExtraState { numStatuses: numStatuses; } +// Filter bug fix +const FILTERS_KEY = "adminOrderFilters"; +const loadSavedFilters = (): OrderFilters => { + console.log("Setting filters"); + try { + const raw = localStorage.getItem(FILTERS_KEY); + return raw ? (JSON.parse(raw) as OrderFilters) : ({} as OrderFilters); + } catch { + return {} as OrderFilters; + } +}; + const extraState: adminOrderExtraState = { isLoading: false, error: null, - filters: {} as OrderFilters, + // filters: {} as OrderFilters, // Filter bug fix + filters: loadSavedFilters(), needNumStatuses: true, numStatuses: {}, }; @@ -45,6 +58,37 @@ interface RejectValue { message: any; } +// Filter Bug Fix +export const getOrderStatusCounts = createAsyncThunk< + APIListResponse, + undefined, + { state: RootState; rejectValue: RejectValue; dispatch: AppDispatch } +>( + `${adminOrderReducerName}/getOrderStatusCounts`, + async (_, { rejectWithValue, dispatch }) => { + try { + // IMPORTANT: no filters here + const response = await get>( + "/api/hardware/orders/", + {} + ); + response.data.results = response.data.results.filter((o) => o?.team_code); + return response.data; + } catch (e: any) { + dispatch( + displaySnackbar({ + message: e.response.message, + options: { variant: "error" }, + }) + ); + return rejectWithValue({ + status: e.response.status, + message: e.response.message ?? e.response.data, + }); + } + } +); + export const getOrdersWithFilters = createAsyncThunk< APIListResponse, undefined, @@ -100,6 +144,7 @@ const adminOrderSlice = createSlice({ ...(search && { search }), ...(limit && { limit }), }; + localStorage.setItem(FILTERS_KEY, JSON.stringify(state.filters)); // Filter bug fix }, clearFilters: ( @@ -114,9 +159,42 @@ const adminOrderSlice = createSlice({ if (payload?.saveSearch && search) { state.filters.search = search; } + localStorage.setItem(FILTERS_KEY, JSON.stringify(state.filters)); }, }, extraReducers: (builder) => { + builder.addCase(getOrderStatusCounts.fulfilled, (state, { payload }) => { + function numOrdersByStatus(status: OrderStatus, orders: Order[]) { + let count = 0; + orders.forEach((order) => { + if (order.status === status) count++; + }); + return count; + } + + state.needNumStatuses = false; + state.numStatuses["Submitted"] = numOrdersByStatus( + "Submitted", + payload.results + ); + state.numStatuses["Ready for Pickup"] = numOrdersByStatus( + "Ready for Pickup", + payload.results + ); + state.numStatuses["Picked Up"] = numOrdersByStatus( + "Picked Up", + payload.results + ); + state.numStatuses["Cancelled"] = numOrdersByStatus( + "Cancelled", + payload.results + ); + state.numStatuses["Returned"] = numOrdersByStatus( + "Returned", + payload.results + ); + }); + builder.addCase(getOrdersWithFilters.pending, (state) => { state.isLoading = true; state.error = 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,