diff --git a/backend/apps/ecommerce/resolvers.py b/backend/apps/ecommerce/resolvers.py index a88c2ae0c..9afb217ef 100644 --- a/backend/apps/ecommerce/resolvers.py +++ b/backend/apps/ecommerce/resolvers.py @@ -38,9 +38,22 @@ def resolve_user_orders(self, info): def resolve_all_user_orders(self, info): return Order.objects.all() + @login_required @staff_member_required - def resolve_all_shop_orders(self, info): - return Order.objects.filter(product__shop_item=True) + def resolve_paginated_shop_orders(self, info, limit, offset): + # Apply pagination if limit and offset are provided + orders = Order.objects.filter(product__shop_item=True).order_by( + "delivered_product", "payment_status", "timestamp" + ) + if offset is None: + offset = 0 + orders = orders[offset:] + + if limit is None or limit > 300: + # Add hard cap of maximum 300 orders per query. A bigger query would crash the website + limit = 300 + orders = orders[:limit] + return orders @staff_member_required def resolve_orders_by_status(self, info: "ResolveInfo", product_id, status): diff --git a/backend/apps/ecommerce/schema.py b/backend/apps/ecommerce/schema.py index 128889b00..d86acc0da 100644 --- a/backend/apps/ecommerce/schema.py +++ b/backend/apps/ecommerce/schema.py @@ -19,7 +19,7 @@ class EcommerceQueries(graphene.ObjectType, EcommerceResolvers): order = graphene.Field(OrderType, order_id=graphene.ID(required=True)) user_orders = graphene.List(NonNull(OrderType)) all_user_orders = graphene.List(NonNull(OrderType)) - all_shop_orders = graphene.List(NonNull(OrderType)) + paginated_shop_orders = graphene.List(graphene.NonNull(OrderType), limit=graphene.Int(), offset=graphene.Int()) orders_by_status = graphene.Field( OrdersByStatusType, product_id=graphene.ID(required=True), status=graphene.String(required=True) diff --git a/backend/apps/ecommerce/tests.py b/backend/apps/ecommerce/tests.py index 9fb9d553d..ef755d9d1 100644 --- a/backend/apps/ecommerce/tests.py +++ b/backend/apps/ecommerce/tests.py @@ -457,3 +457,85 @@ def test_delivered_product(self) -> None: order = Order.objects.get(pk=order.id) self.assertTrue(order.delivered_product) + + +class PaginatedShopOrdersResolverTests(ExtendedGraphQLTestCase): + def setUp(self): + # Create a staff user using the StaffUserFactory + self.staff_user = StaffUserFactory(username="staffuser") + + # Ensure the user satisfies the requirements for the new decorators + self.staff_user.is_staff = True # Required for @staff_member_required + self.staff_user.is_superuser = True # Required for @superuser_required if added + self.staff_user.save() + + # Create a product with `shop_item=True` to pass the resolver's filter condition + self.product = ProductFactory( + name="Test Product", + price=decimal.Decimal("1000.00"), + description="A test product description", + max_buyable_quantity=2, + total_quantity=5, + shop_item=True, # Ensure this matches the resolver's filter condition + ) + + # Create multiple orders associated with the product and user + self.orders = [ + OrderFactory( + product=self.product, + user=self.staff_user, + payment_status=Order.PaymentStatus.INITIATED, + ) + for i in range(10) + ] + + def test_paginated_shop_orders_with_fragment_and_product(self): + query = """ + query paginatedShopOrders($limit: Int, $offset: Int) { + paginatedShopOrders(limit: $limit, offset: $offset) { + ...Order + } + } + + fragment Order on OrderType { + id + quantity + totalPrice + paymentStatus + timestamp + deliveredProduct + product { + ...Product + } + } + + fragment Product on ProductType { + id + name + price + description + maxBuyableQuantity + shopItem + } + """ + + # Execute the query using the query method from ExtendedGraphQLTestCase + response = self.query(query, variables={"limit": 5, "offset": 2}, user=self.staff_user) # Use staff user + data = json.loads(response.content) + + # Check if the response data matches expectations + self.assertIn("data", data) + self.assertIn("paginatedShopOrders", data["data"]) + self.assertEqual(len(data["data"]["paginatedShopOrders"]), 5) + + # Verify the structure of the nested product field in the first order + first_order = data["data"]["paginatedShopOrders"][0] + self.assertIn("product", first_order) + self.assertEqual(first_order["product"]["name"], "Test Product") + self.assertEqual(first_order["product"]["price"], "1000.00") # Adjusted for consistency + self.assertTrue(first_order["product"]["shopItem"]) + + # Additional checks for other fields + self.assertIn("quantity", first_order) + self.assertIn("paymentStatus", first_order) + self.assertIn("timestamp", first_order) diff --git a/backend/schema.json b/backend/schema.json index 8ec891e3d..4d9817037 100644 --- a/backend/schema.json +++ b/backend/schema.json @@ -301,11 +301,32 @@ } }, { - "args": [], + "args": [ + { + "defaultValue": null, + "description": null, + "name": "limit", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + { + "defaultValue": null, + "description": null, + "name": "offset", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + } + ], "deprecationReason": null, "description": null, "isDeprecated": false, - "name": "allShopOrders", + "name": "paginatedShopOrders", "type": { "kind": "LIST", "name": null, diff --git a/frontend/src/app/_components/LandingHero/OrganizationsSlider.tsx b/frontend/src/app/_components/LandingHero/OrganizationsSlider.tsx index d627ed2e2..79fa5be07 100644 --- a/frontend/src/app/_components/LandingHero/OrganizationsSlider.tsx +++ b/frontend/src/app/_components/LandingHero/OrganizationsSlider.tsx @@ -9,10 +9,10 @@ import { Swiper, SwiperSlide } from "swiper/react"; // Import Swiper styles import "swiper/css"; -import { Organization, OrganizationLink } from "./OrganizationLink"; - import { Link } from "@/app/components/Link"; +import { Organization, OrganizationLink } from "./OrganizationLink"; + const organizations: Readonly = [ { name: "Janus Sosial", internalUrl: "/janus" }, { name: "Bindeleddet", externalUrl: "https://www.bindeleddet.no" }, diff --git a/frontend/src/app/_components/LandingHero/index.tsx b/frontend/src/app/_components/LandingHero/index.tsx index fe2d6d7c3..a8b745c38 100644 --- a/frontend/src/app/_components/LandingHero/index.tsx +++ b/frontend/src/app/_components/LandingHero/index.tsx @@ -3,11 +3,11 @@ import { Box, Button, Container, Unstable_Grid2 as Grid, Typography } from "@mui/material"; import Image from "next/image"; -import { OrganizationsSlider } from "./OrganizationsSlider"; - import { Link } from "@/app/components/Link"; import Hero from "~/public/static/landing/hero.webp"; +import { OrganizationsSlider } from "./OrganizationsSlider"; + export const LandingHero: React.FC = () => { return ( <> diff --git a/frontend/src/components/pages/organization/OrgShop.tsx b/frontend/src/components/pages/organization/OrgShop.tsx index bc624ec7b..f6404a007 100644 --- a/frontend/src/components/pages/organization/OrgShop.tsx +++ b/frontend/src/components/pages/organization/OrgShop.tsx @@ -1,32 +1,58 @@ import { useQuery } from "@apollo/client"; import { Box, Grid, Stack, Typography } from "@mui/material"; +import { useTheme } from "@mui/material/styles"; +import { useState } from "react"; -import { AdminOrganizationFragment, AllShopOrdersDocument } from "@/generated/graphql"; +import { AdminOrganizationFragment, PaginatedShopOrdersDocument } from "@/generated/graphql"; import { ShopSale } from "../orgs/ShopSale"; +import { TableStepper } from "../orgs/TableStepper"; type Props = { organization: AdminOrganizationFragment; }; export const OrgProducts: React.FC = ({ organization }) => { - const { data, error } = useQuery(AllShopOrdersDocument); + const theme = useTheme(); + const limit = 5; + const [page, setPage] = useState(0); + + const handlePageChange = (newValue: number) => { + setPage(newValue); + }; + + const { data, error, loading } = useQuery(PaginatedShopOrdersDocument, { + variables: { + limit: limit + 1, // The number of orders you want to fetch + offset: page * limit, // The starting index (e.g., 0 for the first set of results) + }, + }); if (error) return

Error

; - console.log(data); - if (organization.name !== "Janus linjeforening") { + if (organization.name.toLowerCase() !== "janus linjeforening") { return (

Per nå har kun Janus tilgang på buttikk administrasjon. Etter hvert vil vi åpne for at flere kan bruke siden

); } - if (data?.allShopOrders?.length === 0) { + if (data?.paginatedShopOrders!.length === 0) { return

Ingen ordre

; } + // Check if there is a next page (if we received less than `limit` items) + const hasNextPage = (data?.paginatedShopOrders && data.paginatedShopOrders.length < limit) ?? false; + return ( <> - - + + + Navn på kunde @@ -36,33 +62,46 @@ export const OrgProducts: React.FC = ({ organization }) => { Antall bestilt - Betalt status + Betalt status - + Mulige handlinger - Har vi levert varen + Har vi levert varen - - {data?.allShopOrders?.map((order) => { - return ( - - - - + + {loading ? ( + Loading... + ) : ( + data && ( + <> + + {data?.paginatedShopOrders?.slice(0, limit).map((order) => { + return ( + + + + + + ); + })} - ); - })} - + + + + + + ) + )} ); }; diff --git a/frontend/src/components/pages/orgs/ShopSale.tsx b/frontend/src/components/pages/orgs/ShopSale.tsx index 0f4054f92..8c00d67f7 100644 --- a/frontend/src/components/pages/orgs/ShopSale.tsx +++ b/frontend/src/components/pages/orgs/ShopSale.tsx @@ -23,8 +23,8 @@ export const ShopSale: React.FC = ({ name, product_name, quantity, has_pa deliverProduct({ variables: { orderId: order_id } }); } return ( - - + + {name} @@ -36,7 +36,7 @@ export const ShopSale: React.FC = ({ name, product_name, quantity, has_pa Betalt: {has_paid ? "Ja" : "Nei"} - + @@ -69,7 +69,7 @@ export const ShopSale: React.FC = ({ name, product_name, quantity, has_pa - Varen er {delivered ? "levert" : "ikke levert"} + {delivered ? "Levert" : "Ikke levert"} ); diff --git a/frontend/src/components/pages/orgs/TableStepper.tsx b/frontend/src/components/pages/orgs/TableStepper.tsx new file mode 100644 index 000000000..1c84a2074 --- /dev/null +++ b/frontend/src/components/pages/orgs/TableStepper.tsx @@ -0,0 +1,41 @@ +import { KeyboardArrowLeft, KeyboardArrowRight, KeyboardDoubleArrowLeft, KeyboardDoubleArrowRight } from "@mui/icons-material"; +import { Button, Typography, Stack } from "@mui/material"; +import { useTheme } from "@mui/material/styles"; +import React from "react"; + +type Props = { + hasNextPage: boolean; + page: number; + handlePageChange: (page: number) => void; +}; + +export const TableStepper: React.FC = ({ hasNextPage, page, handlePageChange }) => { + const theme = useTheme(); + const nextPage = () => handlePageChange(page + 1); + const prevPage = () => handlePageChange(page > 0 ? page - 1 : 0); + const resetPage = () => handlePageChange(0) + + return ( + + + + + + + {page + 1} + + + + ); +}; diff --git a/frontend/src/generated/graphql.tsx b/frontend/src/generated/graphql.tsx index 09d3a0a86..35be7f996 100644 --- a/frontend/src/generated/graphql.tsx +++ b/frontend/src/generated/graphql.tsx @@ -963,7 +963,6 @@ export type Queries = { allCategories?: Maybe>; allEvents?: Maybe>; allOrganizations?: Maybe>; - allShopOrders?: Maybe>; allUserOrders?: Maybe>; allUsers?: Maybe>; archiveByTypes: Array; @@ -990,6 +989,7 @@ export type Queries = { order?: Maybe; ordersByStatus?: Maybe; organization?: Maybe; + paginatedShopOrders?: Maybe>; product?: Maybe; products?: Maybe>; response?: Maybe; @@ -1090,6 +1090,11 @@ export type QueriesOrganizationArgs = { slug?: InputMaybe; }; +export type QueriesPaginatedShopOrdersArgs = { + limit?: InputMaybe; + offset?: InputMaybe; +}; + export type QueriesProductArgs = { productId: Scalars["ID"]["input"]; }; @@ -1911,11 +1916,14 @@ export type AllUserOrdersQuery = { }> | null; }; -export type AllShopOrdersQueryVariables = Exact<{ [key: string]: never }>; +export type PaginatedShopOrdersQueryVariables = Exact<{ + limit?: InputMaybe; + offset?: InputMaybe; +}>; -export type AllShopOrdersQuery = { +export type PaginatedShopOrdersQuery = { __typename?: "Queries"; - allShopOrders?: Array<{ + paginatedShopOrders?: Array<{ __typename?: "OrderType"; id: string; quantity: number; @@ -6643,19 +6651,43 @@ export const AllUserOrdersDocument = { }, ], } as unknown as DocumentNode; -export const AllShopOrdersDocument = { +export const PaginatedShopOrdersDocument = { kind: "Document", definitions: [ { kind: "OperationDefinition", operation: "query", - name: { kind: "Name", value: "allShopOrders" }, + name: { kind: "Name", value: "paginatedShopOrders" }, + variableDefinitions: [ + { + kind: "VariableDefinition", + variable: { kind: "Variable", name: { kind: "Name", value: "limit" } }, + type: { kind: "NamedType", name: { kind: "Name", value: "Int" } }, + }, + { + kind: "VariableDefinition", + variable: { kind: "Variable", name: { kind: "Name", value: "offset" } }, + type: { kind: "NamedType", name: { kind: "Name", value: "Int" } }, + }, + ], selectionSet: { kind: "SelectionSet", selections: [ { kind: "Field", - name: { kind: "Name", value: "allShopOrders" }, + name: { kind: "Name", value: "paginatedShopOrders" }, + arguments: [ + { + kind: "Argument", + name: { kind: "Name", value: "limit" }, + value: { kind: "Variable", name: { kind: "Name", value: "limit" } }, + }, + { + kind: "Argument", + name: { kind: "Name", value: "offset" }, + value: { kind: "Variable", name: { kind: "Name", value: "offset" } }, + }, + ], selectionSet: { kind: "SelectionSet", selections: [{ kind: "FragmentSpread", name: { kind: "Name", value: "Order" } }], @@ -6718,7 +6750,7 @@ export const AllShopOrdersDocument = { }, }, ], -} as unknown as DocumentNode; +} as unknown as DocumentNode; export const CreateEventDocument = { kind: "Document", definitions: [ diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts index c7c21c610..7ba248c80 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -1030,7 +1030,6 @@ export type Queries = { allCategories: Maybe>; allEvents: Maybe>; allOrganizations: Maybe>; - allShopOrders: Maybe>; allUserOrders: Maybe>; allUsers: Maybe>; archiveByTypes: Array; @@ -1057,6 +1056,7 @@ export type Queries = { order: Maybe; ordersByStatus: Maybe; organization: Maybe; + paginatedShopOrders: Maybe>; product: Maybe; products: Maybe>; response: Maybe; @@ -1177,6 +1177,12 @@ export type QueriesOrganizationArgs = { }; +export type QueriesPaginatedShopOrdersArgs = { + limit: InputMaybe; + offset: InputMaybe; +}; + + export type QueriesProductArgs = { productId: Scalars['ID']['input']; }; diff --git a/frontend/src/graphql/ecommerce/queries.graphql b/frontend/src/graphql/ecommerce/queries.graphql index 0c5f5acb1..b66bda868 100644 --- a/frontend/src/graphql/ecommerce/queries.graphql +++ b/frontend/src/graphql/ecommerce/queries.graphql @@ -27,8 +27,8 @@ query allUserOrders { } } -query allShopOrders { - allShopOrders { +query paginatedShopOrders($limit: Int, $offset: Int) { + paginatedShopOrders(limit: $limit, offset: $offset) { ...Order } } \ No newline at end of file diff --git a/frontend/src/layouts/footer/HallOfFame/constants.ts b/frontend/src/layouts/footer/HallOfFame/constants.ts index 853ada6e9..89a22eb09 100644 --- a/frontend/src/layouts/footer/HallOfFame/constants.ts +++ b/frontend/src/layouts/footer/HallOfFame/constants.ts @@ -13,12 +13,12 @@ export const rubberdokMembers: Record = { { name: "Frederik Egelund Edvardsen", class: 3 }, { name: "Andreas Tauge", class: 2 }, { name: "Kristian Tveråmo Aastveit", class: 3 }, - { name: "Inger Elinor Skomedal", class: 1}, - { name: "Erik Aas", class: 1}, + { name: "Inger Elinor Skomedal", class: 1 }, + { name: "Erik Aas", class: 1 }, { name: "Oscar Gangstad Westbye", class: 1 }, { name: "Jan Zabielski", class: 1 }, { name: "Tien Tran", class: 1 }, - { name: "Oda Fu Zhe Runde", class: 1}, + { name: "Oda Fu Zhe Runde", class: 1 }, ], "2023/2024": [ { name: "Magnus Hafstad", class: 3, position: "Prosjektleder" }, diff --git a/frontend/src/pages/orgs/[orgId]/index.tsx b/frontend/src/pages/orgs/[orgId]/index.tsx index edf0e5a1d..a6d89d3c5 100644 --- a/frontend/src/pages/orgs/[orgId]/index.tsx +++ b/frontend/src/pages/orgs/[orgId]/index.tsx @@ -35,12 +35,19 @@ const OrganizationDetailPage: NextPageWithLayout = () => { {data?.organization && ( - - {activeTab == 0 && data.organization.events && } - {activeTab == 1 && data.organization.listings && } - {activeTab == 2 && data.organization && } - {activeTab == 3 && data.organization && } - + <> + + {activeTab == 0 && data.organization.events && } + {activeTab == 1 && data.organization.listings && ( + + )} + {activeTab == 2 && data.organization && } + + {/*Separate stack because of different spacing*/} + + {activeTab == 3 && data.organization && } + + )}