From c227c975e31c9ec998a17fc75cd775cd7b93153b Mon Sep 17 00:00:00 2001 From: andrzejewsky Date: Thu, 19 Dec 2024 16:27:41 +0100 Subject: [PATCH 01/22] Reordering products in collection --- package-lock.json | 46 +-- package.json | 2 +- .../CollectionProducts/CollectionProducts.tsx | 165 ++--------- .../CollectionProducts/Pagination.tsx | 31 ++ .../CollectionProducts/ProductTableItem.tsx | 132 +++++++++ .../ProductTableSkeleton.tsx | 69 +++++ .../CollectionProducts/ProductsTable.tsx | 158 ++++++++++ .../components/CollectionProducts/types.ts | 5 + .../CollectionProducts/useProductDrag.ts | 45 +++ .../CollectionProducts/useProductEdges.ts | 68 +++++ .../CollectionProducts/useProductReorder.ts | 37 +++ .../useProductReorderOptimistic.ts | 60 ++++ .../ProductReorder/ReorderPopover.tsx | 81 ++++++ src/collections/mutations.ts | 39 +++ src/collections/queries.ts | 8 +- src/collections/views/CollectionDetails.tsx | 34 ++- src/graphql/hooks.generated.ts | 271 +++++------------- src/graphql/types.generated.ts | 42 +-- src/icons/Drag.tsx | 28 ++ 19 files changed, 910 insertions(+), 411 deletions(-) create mode 100644 src/collections/components/CollectionProducts/Pagination.tsx create mode 100644 src/collections/components/CollectionProducts/ProductTableItem.tsx create mode 100644 src/collections/components/CollectionProducts/ProductTableSkeleton.tsx create mode 100644 src/collections/components/CollectionProducts/ProductsTable.tsx create mode 100644 src/collections/components/CollectionProducts/types.ts create mode 100644 src/collections/components/CollectionProducts/useProductDrag.ts create mode 100644 src/collections/components/CollectionProducts/useProductEdges.ts create mode 100644 src/collections/components/CollectionProducts/useProductReorder.ts create mode 100644 src/collections/components/CollectionProducts/useProductReorderOptimistic.ts create mode 100644 src/collections/components/ProductReorder/ReorderPopover.tsx create mode 100644 src/icons/Drag.tsx diff --git a/package-lock.json b/package-lock.json index b80091e4a22..75fc6af206a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "BSD-3-Clause", "dependencies": { "@apollo/client": "3.4.17", - "@dnd-kit/core": "^6.0.8", + "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^7.0.2", "@dnd-kit/utilities": "^3.2.1", "@editorjs/editorjs": "^2.24.3", @@ -1668,9 +1668,9 @@ } }, "node_modules/@dnd-kit/accessibility": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.0.1.tgz", - "integrity": "sha512-HXRrwS9YUYQO9lFRc/49uO/VICbM+O+ZRpFDe9Pd1rwVv2PCNkRiTZRdxrDgng/UkvdC3Re9r2vwPpXXrWeFzg==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", "dependencies": { "tslib": "^2.0.0" }, @@ -1679,12 +1679,12 @@ } }, "node_modules/@dnd-kit/core": { - "version": "6.0.8", - "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.0.8.tgz", - "integrity": "sha512-lYaoP8yHTQSLlZe6Rr9qogouGUz9oRUj4AHhDQGQzq/hqaJRpFo65X+JKsdHf8oUFBzx5A+SJPUvxAwTF2OabA==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "dependencies": { - "@dnd-kit/accessibility": "^3.0.0", - "@dnd-kit/utilities": "^3.2.1", + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { @@ -1706,9 +1706,9 @@ } }, "node_modules/@dnd-kit/utilities": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.1.tgz", - "integrity": "sha512-OOXqISfvBw/1REtkSK2N3Fi2EQiLMlWUlqnOK/UpOISqBZPWpE6TqL+jcPtMOkE8TqYGiURvRdPSI9hltNUjEA==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", "dependencies": { "tslib": "^2.0.0" }, @@ -22477,20 +22477,20 @@ } }, "@dnd-kit/accessibility": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.0.1.tgz", - "integrity": "sha512-HXRrwS9YUYQO9lFRc/49uO/VICbM+O+ZRpFDe9Pd1rwVv2PCNkRiTZRdxrDgng/UkvdC3Re9r2vwPpXXrWeFzg==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", "requires": { "tslib": "^2.0.0" } }, "@dnd-kit/core": { - "version": "6.0.8", - "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.0.8.tgz", - "integrity": "sha512-lYaoP8yHTQSLlZe6Rr9qogouGUz9oRUj4AHhDQGQzq/hqaJRpFo65X+JKsdHf8oUFBzx5A+SJPUvxAwTF2OabA==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "requires": { - "@dnd-kit/accessibility": "^3.0.0", - "@dnd-kit/utilities": "^3.2.1", + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" } }, @@ -22504,9 +22504,9 @@ } }, "@dnd-kit/utilities": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.1.tgz", - "integrity": "sha512-OOXqISfvBw/1REtkSK2N3Fi2EQiLMlWUlqnOK/UpOISqBZPWpE6TqL+jcPtMOkE8TqYGiURvRdPSI9hltNUjEA==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", "requires": { "tslib": "^2.0.0" } diff --git a/package.json b/package.json index 6a21e20e4cd..7b3c0cfde2a 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ }, "dependencies": { "@apollo/client": "3.4.17", - "@dnd-kit/core": "^6.0.8", + "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^7.0.2", "@dnd-kit/utilities": "^3.2.1", "@editorjs/editorjs": "^2.24.3", diff --git a/src/collections/components/CollectionProducts/CollectionProducts.tsx b/src/collections/components/CollectionProducts/CollectionProducts.tsx index 51d977b5923..e71266b7011 100644 --- a/src/collections/components/CollectionProducts/CollectionProducts.tsx +++ b/src/collections/components/CollectionProducts/CollectionProducts.tsx @@ -1,55 +1,13 @@ -// @ts-strict-ignore -import { Button } from "@dashboard/components/Button"; import { DashboardCard } from "@dashboard/components/Card"; -import { ChannelsAvailabilityDropdown } from "@dashboard/components/ChannelsAvailabilityDropdown"; -import Checkbox from "@dashboard/components/Checkbox"; -import ResponsiveTable from "@dashboard/components/ResponsiveTable"; -import { TableButtonWrapper } from "@dashboard/components/TableButtonWrapper/TableButtonWrapper"; -import TableCellAvatar from "@dashboard/components/TableCellAvatar"; -import { AVATAR_MARGIN } from "@dashboard/components/TableCellAvatar/Avatar"; -import TableHead from "@dashboard/components/TableHead"; -import { TablePaginationWithContext } from "@dashboard/components/TablePagination"; -import TableRowLink from "@dashboard/components/TableRowLink"; import { CollectionDetailsQuery } from "@dashboard/graphql"; -import { productUrl } from "@dashboard/products/urls"; import { mapEdgesToItems } from "@dashboard/utils/maps"; -import { TableBody, TableCell, TableFooter } from "@material-ui/core"; -import { DeleteIcon, IconButton, makeStyles } from "@saleor/macaw-ui"; -import { Skeleton } from "@saleor/macaw-ui-next"; +import { Button, Skeleton } from "@saleor/macaw-ui-next"; import React from "react"; import { FormattedMessage, useIntl } from "react-intl"; -import { maybe, renderCollection } from "../../../misc"; import { ListActions, PageListProps } from "../../../types"; - -const useStyles = makeStyles( - theme => ({ - colActions: { - width: `calc(76px + ${theme.spacing(1)})`, - marginRight: theme.spacing(-2), - }, - colName: { - paddingLeft: 0, - width: "auto", - }, - colNameLabel: { - marginLeft: AVATAR_MARGIN, - }, - colPublished: { - width: 200, - }, - colType: { - width: 200, - }, - table: { - tableLayout: "fixed", - }, - tableRow: { - cursor: "pointer", - }, - }), - { name: "CollectionProducts" }, -); +import { ProductsTable } from "./ProductsTable"; +import { ProductTableSkeleton } from "./ProductTableSkeleton"; export interface CollectionProductsProps extends PageListProps, ListActions { collection: CollectionDetailsQuery["collection"]; @@ -69,7 +27,6 @@ const CollectionProducts: React.FC = props => { toggleAll, toolbar, } = props; - const classes = useStyles(props); const intl = useIntl(); const products = mapEdgesToItems(collection?.products); const numberOfColumns = products?.length === 0 ? 4 : 5; @@ -86,7 +43,7 @@ const CollectionProducts: React.FC = props => { description: "products in collection", }, { - name: maybe(() => collection.name, "..."), + name: collection.name || "...", }, ) ) : ( @@ -94,108 +51,32 @@ const CollectionProducts: React.FC = props => { )} - - - - - - - - - - - - - - - - - - - - - - - {renderCollection( - products, - product => { - const isSelected = product ? isChecked(product.id) : false; - - return ( - - - toggle(product.id)} - /> - - product.thumbnail.url)} - > - {maybe(() => product.name, )} - - - {maybe(() => product.productType.name, )} - - - {product && !product?.channelListings?.length ? ( - "-" - ) : product?.channelListings !== undefined ? ( - - ) : ( - - )} - - - - onProductUnassign(product.id, event)} - > - - - - - - ); - }, - () => ( - - - - - - ), - )} - - + /> + ) : ( + + )} ); }; diff --git a/src/collections/components/CollectionProducts/Pagination.tsx b/src/collections/components/CollectionProducts/Pagination.tsx new file mode 100644 index 00000000000..0b931c55b7b --- /dev/null +++ b/src/collections/components/CollectionProducts/Pagination.tsx @@ -0,0 +1,31 @@ +import { usePaginatorContext } from "@dashboard/hooks/usePaginator"; +import { Box, Button, ChevronLeftIcon, ChevronRightIcon } from "@saleor/macaw-ui-next"; +import React from "react"; + +export const Pagination = () => { + const { hasNextPage, hasPreviousPage, loadNextPage, loadPreviousPage } = usePaginatorContext(); + + return ( + + + + + + + + Reorder selected products + + + Move products by their positions. + + + + Move by positions + + + + + + + + + + + + + + + + + ); +}; diff --git a/src/collections/mutations.ts b/src/collections/mutations.ts index 0edb43a8f1a..2ed115b53b6 100644 --- a/src/collections/mutations.ts +++ b/src/collections/mutations.ts @@ -129,3 +129,42 @@ export const collectionChannelListingUpdate = gql` } } `; + +export const reorderProductsInCollection = gql` + mutation ReorderProductsInCollection( + $collectionId: ID! + $moves: [MoveProductInput!]! + $first: Int + $after: String + $last: Int + $before: String + ) { + collectionReorderProducts(collectionId: $collectionId, moves: $moves) { + collection { + id + products( + first: $first + after: $after + before: $before + last: $last + sortBy: { field: COLLECTION, direction: DESC } + ) { + edges { + node { + ...CollectionProduct + } + } + pageInfo { + endCursor + hasNextPage + hasPreviousPage + startCursor + } + } + } + errors { + message + } + } + } +`; diff --git a/src/collections/queries.ts b/src/collections/queries.ts index 4fccd237139..d7a2dc5dc2a 100644 --- a/src/collections/queries.ts +++ b/src/collections/queries.ts @@ -41,7 +41,13 @@ export const collectionDetails = gql` query CollectionDetails($id: ID!, $first: Int, $after: String, $last: Int, $before: String) { collection(id: $id) { ...CollectionDetails - products(first: $first, after: $after, before: $before, last: $last) { + products( + first: $first + after: $after + before: $before + last: $last + sortBy: { field: COLLECTION, direction: DESC } + ) { edges { node { ...CollectionProduct diff --git a/src/collections/views/CollectionDetails.tsx b/src/collections/views/CollectionDetails.tsx index 060f275c9b5..52173270b2c 100644 --- a/src/collections/views/CollectionDetails.tsx +++ b/src/collections/views/CollectionDetails.tsx @@ -4,7 +4,6 @@ import ActionDialog from "@dashboard/components/ActionDialog"; import useAppChannel from "@dashboard/components/AppLayout/AppChannelContext"; import { Container } from "@dashboard/components/AssignContainerDialog"; import AssignProductDialog from "@dashboard/components/AssignProductDialog"; -import { Button } from "@dashboard/components/Button"; import ChannelsAvailabilityDialog from "@dashboard/components/ChannelsAvailabilityDialog"; import NotFoundPage from "@dashboard/components/NotFoundPage"; import { WindowTitle } from "@dashboard/components/WindowTitle"; @@ -34,12 +33,14 @@ import { arrayDiff } from "@dashboard/utils/arrays"; import createDialogActionHandlers from "@dashboard/utils/handlers/dialogActionHandlers"; import createMetadataUpdateHandler from "@dashboard/utils/handlers/metadataUpdateHandler"; import { getParsedDataForJsonStringField } from "@dashboard/utils/richText/misc"; +import { Button } from "@saleor/macaw-ui-next"; import React from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { getMutationErrors, getMutationState, maybe } from "../../misc"; import CollectionDetailsPage from "../components/CollectionDetailsPage/CollectionDetailsPage"; import { CollectionUpdateData } from "../components/CollectionDetailsPage/form"; +import { ReorderPopover } from "../components/ProductReorder/ReorderPopover"; import { collectionListUrl, collectionUrl, @@ -302,19 +303,24 @@ export const CollectionDetails: React.FC = ({ id, params }} saveButtonBarState={formTransitionState} toolbar={ - + <> + + + } isChecked={isSelected} selected={listElements.length} diff --git a/src/graphql/hooks.generated.ts b/src/graphql/hooks.generated.ts index f9fb2f14561..594652911fb 100644 --- a/src/graphql/hooks.generated.ts +++ b/src/graphql/hooks.generated.ts @@ -5669,6 +5669,68 @@ export function useCollectionChannelListingUpdateMutation(baseOptions?: ApolloRe export type CollectionChannelListingUpdateMutationHookResult = ReturnType; export type CollectionChannelListingUpdateMutationResult = Apollo.MutationResult; export type CollectionChannelListingUpdateMutationOptions = Apollo.BaseMutationOptions; +export const ReorderProductsInCollectionDocument = gql` + mutation ReorderProductsInCollection($collectionId: ID!, $moves: [MoveProductInput!]!, $first: Int, $after: String, $last: Int, $before: String) { + collectionReorderProducts(collectionId: $collectionId, moves: $moves) { + collection { + id + products( + first: $first + after: $after + before: $before + last: $last + sortBy: {field: COLLECTION, direction: DESC} + ) { + edges { + node { + ...CollectionProduct + } + } + pageInfo { + endCursor + hasNextPage + hasPreviousPage + startCursor + } + } + } + errors { + message + } + } +} + ${CollectionProductFragmentDoc}`; +export type ReorderProductsInCollectionMutationFn = Apollo.MutationFunction; + +/** + * __useReorderProductsInCollectionMutation__ + * + * To run a mutation, you first call `useReorderProductsInCollectionMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useReorderProductsInCollectionMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [reorderProductsInCollectionMutation, { data, loading, error }] = useReorderProductsInCollectionMutation({ + * variables: { + * collectionId: // value for 'collectionId' + * moves: // value for 'moves' + * first: // value for 'first' + * after: // value for 'after' + * last: // value for 'last' + * before: // value for 'before' + * }, + * }); + */ +export function useReorderProductsInCollectionMutation(baseOptions?: ApolloReactHooks.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return ApolloReactHooks.useMutation(ReorderProductsInCollectionDocument, options); + } +export type ReorderProductsInCollectionMutationHookResult = ReturnType; +export type ReorderProductsInCollectionMutationResult = Apollo.MutationResult; +export type ReorderProductsInCollectionMutationOptions = Apollo.BaseMutationOptions; export const CollectionListDocument = gql` query CollectionList($first: Int, $after: String, $last: Int, $before: String, $filter: CollectionFilterInput, $sort: CollectionSortingInput, $channel: String) { collections( @@ -5735,7 +5797,13 @@ export const CollectionDetailsDocument = gql` query CollectionDetails($id: ID!, $first: Int, $after: String, $last: Int, $before: String) { collection(id: $id) { ...CollectionDetails - products(first: $first, after: $after, before: $before, last: $last) { + products( + first: $first + after: $after + before: $before + last: $last + sortBy: {field: COLLECTION, direction: DESC} + ) { edges { node { ...CollectionProduct @@ -9648,196 +9716,6 @@ export function useCustomerGiftCardListLazyQuery(baseOptions?: ApolloReactHooks. export type CustomerGiftCardListQueryHookResult = ReturnType; export type CustomerGiftCardListLazyQueryHookResult = ReturnType; export type CustomerGiftCardListQueryResult = Apollo.QueryResult; -export const HomeAnaliticsDocument = gql` - query HomeAnalitics($channel: String!, $hasPermissionToManageOrders: Boolean!) { - salesToday: ordersTotal(period: TODAY, channel: $channel) @include(if: $hasPermissionToManageOrders) { - gross { - amount - currency - } - } -} - `; - -/** - * __useHomeAnaliticsQuery__ - * - * To run a query within a React component, call `useHomeAnaliticsQuery` and pass it any options that fit your needs. - * When your component renders, `useHomeAnaliticsQuery` returns an object from Apollo Client that contains loading, error, and data properties - * you can use to render your UI. - * - * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; - * - * @example - * const { data, loading, error } = useHomeAnaliticsQuery({ - * variables: { - * channel: // value for 'channel' - * hasPermissionToManageOrders: // value for 'hasPermissionToManageOrders' - * }, - * }); - */ -export function useHomeAnaliticsQuery(baseOptions: ApolloReactHooks.QueryHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return ApolloReactHooks.useQuery(HomeAnaliticsDocument, options); - } -export function useHomeAnaliticsLazyQuery(baseOptions?: ApolloReactHooks.LazyQueryHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return ApolloReactHooks.useLazyQuery(HomeAnaliticsDocument, options); - } -export type HomeAnaliticsQueryHookResult = ReturnType; -export type HomeAnaliticsLazyQueryHookResult = ReturnType; -export type HomeAnaliticsQueryResult = Apollo.QueryResult; -export const HomeActivitiesDocument = gql` - query HomeActivities($hasPermissionToManageOrders: Boolean!) { - activities: homepageEvents(last: 10) @include(if: $hasPermissionToManageOrders) { - edges { - node { - amount - composedId - date - email - emailType - id - message - orderNumber - oversoldItems - quantity - type - user { - id - email - } - } - } - } -} - `; - -/** - * __useHomeActivitiesQuery__ - * - * To run a query within a React component, call `useHomeActivitiesQuery` and pass it any options that fit your needs. - * When your component renders, `useHomeActivitiesQuery` returns an object from Apollo Client that contains loading, error, and data properties - * you can use to render your UI. - * - * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; - * - * @example - * const { data, loading, error } = useHomeActivitiesQuery({ - * variables: { - * hasPermissionToManageOrders: // value for 'hasPermissionToManageOrders' - * }, - * }); - */ -export function useHomeActivitiesQuery(baseOptions: ApolloReactHooks.QueryHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return ApolloReactHooks.useQuery(HomeActivitiesDocument, options); - } -export function useHomeActivitiesLazyQuery(baseOptions?: ApolloReactHooks.LazyQueryHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return ApolloReactHooks.useLazyQuery(HomeActivitiesDocument, options); - } -export type HomeActivitiesQueryHookResult = ReturnType; -export type HomeActivitiesLazyQueryHookResult = ReturnType; -export type HomeActivitiesQueryResult = Apollo.QueryResult; -export const HomeTopProductsDocument = gql` - query HomeTopProducts($channel: String!, $hasPermissionToManageProducts: Boolean!) { - productTopToday: reportProductSales(period: TODAY, first: 5, channel: $channel) @include(if: $hasPermissionToManageProducts) { - edges { - node { - id - revenue(period: TODAY) { - gross { - amount - currency - } - } - attributes { - values { - id - name - } - } - product { - id - name - thumbnail { - url - } - } - quantityOrdered - } - } - } -} - `; - -/** - * __useHomeTopProductsQuery__ - * - * To run a query within a React component, call `useHomeTopProductsQuery` and pass it any options that fit your needs. - * When your component renders, `useHomeTopProductsQuery` returns an object from Apollo Client that contains loading, error, and data properties - * you can use to render your UI. - * - * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; - * - * @example - * const { data, loading, error } = useHomeTopProductsQuery({ - * variables: { - * channel: // value for 'channel' - * hasPermissionToManageProducts: // value for 'hasPermissionToManageProducts' - * }, - * }); - */ -export function useHomeTopProductsQuery(baseOptions: ApolloReactHooks.QueryHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return ApolloReactHooks.useQuery(HomeTopProductsDocument, options); - } -export function useHomeTopProductsLazyQuery(baseOptions?: ApolloReactHooks.LazyQueryHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return ApolloReactHooks.useLazyQuery(HomeTopProductsDocument, options); - } -export type HomeTopProductsQueryHookResult = ReturnType; -export type HomeTopProductsLazyQueryHookResult = ReturnType; -export type HomeTopProductsQueryResult = Apollo.QueryResult; -export const HomeNotificationsDocument = gql` - query homeNotifications($channel: String!) { - productsOutOfStock: products( - filter: {stockAvailability: OUT_OF_STOCK} - channel: $channel - ) { - totalCount - } -} - `; - -/** - * __useHomeNotificationsQuery__ - * - * To run a query within a React component, call `useHomeNotificationsQuery` and pass it any options that fit your needs. - * When your component renders, `useHomeNotificationsQuery` returns an object from Apollo Client that contains loading, error, and data properties - * you can use to render your UI. - * - * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; - * - * @example - * const { data, loading, error } = useHomeNotificationsQuery({ - * variables: { - * channel: // value for 'channel' - * }, - * }); - */ -export function useHomeNotificationsQuery(baseOptions: ApolloReactHooks.QueryHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return ApolloReactHooks.useQuery(HomeNotificationsDocument, options); - } -export function useHomeNotificationsLazyQuery(baseOptions?: ApolloReactHooks.LazyQueryHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return ApolloReactHooks.useLazyQuery(HomeNotificationsDocument, options); - } -export type HomeNotificationsQueryHookResult = ReturnType; -export type HomeNotificationsLazyQueryHookResult = ReturnType; -export type HomeNotificationsQueryResult = Apollo.QueryResult; export const MenuCreateDocument = gql` mutation MenuCreate($input: MenuCreateInput!) { menuCreate(input: $input) { @@ -20040,19 +19918,12 @@ export const WelcomePageActivitiesDocument = gql` activities: homepageEvents(last: 10) @include(if: $hasPermissionToManageOrders) { edges { node { - date - email - message - orderNumber - type - user { - email - } + ...Activities } } } } - `; + ${ActivitiesFragmentDoc}`; /** * __useWelcomePageActivitiesQuery__ diff --git a/src/graphql/types.generated.ts b/src/graphql/types.generated.ts index 6821582cdab..0c901459069 100644 --- a/src/graphql/types.generated.ts +++ b/src/graphql/types.generated.ts @@ -9350,6 +9350,18 @@ export type CollectionChannelListingUpdateMutationVariables = Exact<{ export type CollectionChannelListingUpdateMutation = { __typename: 'Mutation', collectionChannelListingUpdate: { __typename: 'CollectionChannelListingUpdate', errors: Array<{ __typename: 'CollectionChannelListingError', code: ProductErrorCode, field: string | null, message: string | null, channels: Array | null }> } | null }; +export type ReorderProductsInCollectionMutationVariables = Exact<{ + collectionId: Scalars['ID']; + moves: Array | MoveProductInput; + first?: InputMaybe; + after?: InputMaybe; + last?: InputMaybe; + before?: InputMaybe; +}>; + + +export type ReorderProductsInCollectionMutation = { __typename: 'Mutation', collectionReorderProducts: { __typename: 'CollectionReorderProducts', collection: { __typename: 'Collection', id: string, products: { __typename: 'ProductCountableConnection', edges: Array<{ __typename: 'ProductCountableEdge', node: { __typename: 'Product', id: string, name: string, productType: { __typename: 'ProductType', id: string, name: string }, thumbnail: { __typename: 'Image', url: string } | null, channelListings: Array<{ __typename: 'ProductChannelListing', id: string, isPublished: boolean, publishedAt: any | null, isAvailableForPurchase: boolean | null, availableForPurchaseAt: any | null, visibleInListings: boolean, channel: { __typename: 'Channel', id: string, name: string, currencyCode: string } }> | null } }>, pageInfo: { __typename: 'PageInfo', endCursor: string | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor: string | null } } | null } | null, errors: Array<{ __typename: 'CollectionError', message: string | null }> } | null }; + export type CollectionListQueryVariables = Exact<{ first?: InputMaybe; after?: InputMaybe; @@ -10653,36 +10665,6 @@ export type CustomerGiftCardListQueryVariables = Exact<{ export type CustomerGiftCardListQuery = { __typename: 'Query', giftCards: { __typename: 'GiftCardCountableConnection', edges: Array<{ __typename: 'GiftCardCountableEdge', node: { __typename: 'GiftCard', id: string, last4CodeChars: string, expiryDate: any | null, isActive: boolean, currentBalance: { __typename: 'Money', amount: number, currency: string } } }> } | null }; -export type HomeAnaliticsQueryVariables = Exact<{ - channel: Scalars['String']; - hasPermissionToManageOrders: Scalars['Boolean']; -}>; - - -export type HomeAnaliticsQuery = { __typename: 'Query', salesToday: { __typename: 'TaxedMoney', gross: { __typename: 'Money', amount: number, currency: string } } | null }; - -export type HomeActivitiesQueryVariables = Exact<{ - hasPermissionToManageOrders: Scalars['Boolean']; -}>; - - -export type HomeActivitiesQuery = { __typename: 'Query', activities: { __typename: 'OrderEventCountableConnection', edges: Array<{ __typename: 'OrderEventCountableEdge', node: { __typename: 'OrderEvent', amount: number | null, composedId: string | null, date: any | null, email: string | null, emailType: OrderEventsEmailsEnum | null, id: string, message: string | null, orderNumber: string | null, oversoldItems: Array | null, quantity: number | null, type: OrderEventsEnum | null, user: { __typename: 'User', id: string, email: string } | null } }> } | null }; - -export type HomeTopProductsQueryVariables = Exact<{ - channel: Scalars['String']; - hasPermissionToManageProducts: Scalars['Boolean']; -}>; - - -export type HomeTopProductsQuery = { __typename: 'Query', productTopToday: { __typename: 'ProductVariantCountableConnection', edges: Array<{ __typename: 'ProductVariantCountableEdge', node: { __typename: 'ProductVariant', id: string, quantityOrdered: number | null, revenue: { __typename: 'TaxedMoney', gross: { __typename: 'Money', amount: number, currency: string } } | null, attributes: Array<{ __typename: 'SelectedAttribute', values: Array<{ __typename: 'AttributeValue', id: string, name: string | null }> }>, product: { __typename: 'Product', id: string, name: string, thumbnail: { __typename: 'Image', url: string } | null } } }> } | null }; - -export type HomeNotificationsQueryVariables = Exact<{ - channel: Scalars['String']; -}>; - - -export type HomeNotificationsQuery = { __typename: 'Query', productsOutOfStock: { __typename: 'ProductCountableConnection', totalCount: number | null } | null }; - export type MenuCreateMutationVariables = Exact<{ input: MenuCreateInput; }>; diff --git a/src/icons/Drag.tsx b/src/icons/Drag.tsx new file mode 100644 index 00000000000..c684bd75fa3 --- /dev/null +++ b/src/icons/Drag.tsx @@ -0,0 +1,28 @@ +import { createSvgIcon } from "@material-ui/core/utils"; +import React from "react"; + +const Drag = createSvgIcon( + <> + + + + + + + + + , + "ArrowSort", +); + +export default Drag; From 587cb94b3ed41f216d9cf3d193322bc9a6d92913 Mon Sep 17 00:00:00 2001 From: andrzejewsky Date: Mon, 23 Dec 2024 12:48:32 +0100 Subject: [PATCH 02/22] Fix collectionId --- .../components/CollectionProducts/useCollectionId.ts | 11 +++++++++++ .../components/CollectionProducts/useProductEdges.ts | 9 +++------ .../CollectionProducts/useProductReorder.ts | 8 ++------ 3 files changed, 16 insertions(+), 12 deletions(-) create mode 100644 src/collections/components/CollectionProducts/useCollectionId.ts diff --git a/src/collections/components/CollectionProducts/useCollectionId.ts b/src/collections/components/CollectionProducts/useCollectionId.ts new file mode 100644 index 00000000000..61d36e62cbb --- /dev/null +++ b/src/collections/components/CollectionProducts/useCollectionId.ts @@ -0,0 +1,11 @@ +import useRouter from "use-react-router"; + +export const useCollectionId = () => { + const { + match: { + params: { id: collectionId }, + }, + } = useRouter<{ id: string }>(); + + return decodeURIComponent(collectionId); +}; diff --git a/src/collections/components/CollectionProducts/useProductEdges.ts b/src/collections/components/CollectionProducts/useProductEdges.ts index 74b65709062..b2c398e986c 100644 --- a/src/collections/components/CollectionProducts/useProductEdges.ts +++ b/src/collections/components/CollectionProducts/useProductEdges.ts @@ -2,15 +2,12 @@ import { useApolloClient } from "@apollo/client"; import { PAGINATE_BY } from "@dashboard/config"; import { CollectionDetailsDocument, CollectionDetailsQuery } from "@dashboard/graphql"; import { useLocalPaginationState } from "@dashboard/hooks/useLocalPaginator"; -import useRouter from "use-react-router"; + +import { useCollectionId } from "./useCollectionId"; export const useProductEdges = () => { const client = useApolloClient(); - const { - match: { - params: { id: collectionId }, - }, - } = useRouter<{ id: string }>(); + const collectionId = useCollectionId(); const [paginationState] = useLocalPaginationState(PAGINATE_BY); const queryData = client.readQuery({ diff --git a/src/collections/components/CollectionProducts/useProductReorder.ts b/src/collections/components/CollectionProducts/useProductReorder.ts index 91f12aa0058..3ec9f52063b 100644 --- a/src/collections/components/CollectionProducts/useProductReorder.ts +++ b/src/collections/components/CollectionProducts/useProductReorder.ts @@ -1,16 +1,12 @@ import { PAGINATE_BY } from "@dashboard/config"; import { useReorderProductsInCollectionMutation } from "@dashboard/graphql"; import { useLocalPaginationState } from "@dashboard/hooks/useLocalPaginator"; -import useRouter from "use-react-router"; +import { useCollectionId } from "./useCollectionId"; import { useProductReorderOptimistic } from "./useProductReorderOptimistic"; export const useProductReorder = () => { - const { - match: { - params: { id: collectionId }, - }, - } = useRouter<{ id: string }>(); + const collectionId = useCollectionId(); const [paginationState] = useLocalPaginationState(PAGINATE_BY); const { createOptimisticResponse } = useProductReorderOptimistic(); From 4f484a6989b2c4dbae94c74ee06efefed2978355 Mon Sep 17 00:00:00 2001 From: andrzejewsky Date: Mon, 23 Dec 2024 13:33:44 +0100 Subject: [PATCH 03/22] Fix quering --- src/collections/mutations.ts | 16 ++++++++++++++-- src/graphql/hooks.generated.ts | 16 ++++++++++++++-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/collections/mutations.ts b/src/collections/mutations.ts index 2ed115b53b6..9399e7b2e62 100644 --- a/src/collections/mutations.ts +++ b/src/collections/mutations.ts @@ -25,7 +25,13 @@ export const assignCollectionProduct = gql` collectionAddProducts(collectionId: $collectionId, products: $productIds) { collection { id - products(first: $first, after: $after, before: $before, last: $last) { + products( + first: $first + after: $after + before: $before + last: $last + sortBy: { field: COLLECTION, direction: DESC } + ) { edges { node { ...CollectionProduct @@ -81,7 +87,13 @@ export const unassignCollectionProduct = gql` collectionRemoveProducts(collectionId: $collectionId, products: $productIds) { collection { id - products(first: $first, after: $after, before: $before, last: $last) { + products( + first: $first + after: $after + before: $before + last: $last + sortBy: { field: COLLECTION, direction: DESC } + ) { edges { node { id diff --git a/src/graphql/hooks.generated.ts b/src/graphql/hooks.generated.ts index 594652911fb..1ff4ecac0ea 100644 --- a/src/graphql/hooks.generated.ts +++ b/src/graphql/hooks.generated.ts @@ -5408,7 +5408,13 @@ export const CollectionAssignProductDocument = gql` collectionAddProducts(collectionId: $collectionId, products: $productIds) { collection { id - products(first: $first, after: $after, before: $before, last: $last) { + products( + first: $first + after: $after + before: $before + last: $last + sortBy: {field: COLLECTION, direction: DESC} + ) { edges { node { ...CollectionProduct @@ -5539,7 +5545,13 @@ export const UnassignCollectionProductDocument = gql` collectionRemoveProducts(collectionId: $collectionId, products: $productIds) { collection { id - products(first: $first, after: $after, before: $before, last: $last) { + products( + first: $first + after: $after + before: $before + last: $last + sortBy: {field: COLLECTION, direction: DESC} + ) { edges { node { id From 64d649950dc4a8cf34bece49891034be02c69a3f Mon Sep 17 00:00:00 2001 From: andrzejewsky Date: Fri, 17 Jan 2025 16:38:47 +0100 Subject: [PATCH 04/22] Tests, translations --- .changeset/four-lions-happen.md | 5 + locale/defaultMessages.json | 18 ++ .../CollectionProducts/keepProductOrder.ts | 23 +++ .../components/CollectionProducts/types.ts | 6 +- .../CollectionProducts/useProductDrag.test.ts | 48 +++++ .../useProductEdges.test.ts | 186 ++++++++++++++++++ .../CollectionProducts/useProductEdges.ts | 29 +-- .../useProductReorder.test.ts | 81 ++++++++ .../useProductReorderOptimistic.test.ts | 131 ++++++++++++ .../ProductReorder/ReorderPopover.tsx | 27 +-- src/collections/views/CollectionDetails.tsx | 3 +- 11 files changed, 529 insertions(+), 28 deletions(-) create mode 100644 .changeset/four-lions-happen.md create mode 100644 src/collections/components/CollectionProducts/keepProductOrder.ts create mode 100644 src/collections/components/CollectionProducts/useProductDrag.test.ts create mode 100644 src/collections/components/CollectionProducts/useProductEdges.test.ts create mode 100644 src/collections/components/CollectionProducts/useProductReorder.test.ts create mode 100644 src/collections/components/CollectionProducts/useProductReorderOptimistic.test.ts diff --git a/.changeset/four-lions-happen.md b/.changeset/four-lions-happen.md new file mode 100644 index 00000000000..ef5cad476b6 --- /dev/null +++ b/.changeset/four-lions-happen.md @@ -0,0 +1,5 @@ +--- +"saleor-dashboard": patch +--- + +Now you can re-order products within the collection. diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index 4c55a0a5dad..cc49db88c99 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -821,6 +821,9 @@ "context": "price or ordered products", "string": "Price" }, + "32uBJ8": { + "string": "Move products by their positions." + }, "34F7Jk": { "context": "filter range separator", "string": "and" @@ -2965,6 +2968,9 @@ "context": "dialog content", "string": "You are not able to modify this group members. Solve this problem to continue with request." }, + "H/r5m6": { + "string": "Move down" + }, "H/xj8R": { "context": "VariantDetailsChannelsAvailabilityCard item subtitle published", "string": "Published since {getPublishedAt}" @@ -7443,6 +7449,9 @@ "kFsTMN": { "string": "Delete customers" }, + "kGQJcD": { + "string": "Reorder" + }, "kIcyUo": { "context": "column header", "string": "Slug" @@ -9250,6 +9259,9 @@ "context": "order history message", "string": "Restocked {quantity} items" }, + "wPayk9": { + "string": "Reorder selected products" + }, "wQdR8M": { "string": "Add search engine title and description to make this category easier to find" }, @@ -9298,6 +9310,9 @@ "context": "button", "string": "Create Webhook" }, + "wmFdws": { + "string": "Move up" + }, "wmdHhD": { "context": "button", "string": "Create Warehouse" @@ -9762,6 +9777,9 @@ "context": "cta button label", "string": "Explore Updates" }, + "zpbMmC": { + "string": "Move by positions" + }, "zqarUF": { "context": "modal information under title", "string": "Select an address you want to use from the list below" diff --git a/src/collections/components/CollectionProducts/keepProductOrder.ts b/src/collections/components/CollectionProducts/keepProductOrder.ts new file mode 100644 index 00000000000..efd304b1490 --- /dev/null +++ b/src/collections/components/CollectionProducts/keepProductOrder.ts @@ -0,0 +1,23 @@ +import { CollectionDetailsQuery } from "@dashboard/graphql"; + +export type CollectionProducts = NonNullable< + NonNullable["products"] +>; + +/* + The API needs to take items in the same order as they came, and the reversed one when we move items down (negative position). + This function is designed to keep that order. +*/ +export const keepProductOrder = + (listElements: string[], products: CollectionProducts) => (position: number) => { + const orderedProducts = products.edges + .map(edge => edge.node.id) + .map(nodeId => listElements.find(id => nodeId == id)) + .filter(Boolean); + + if (position < 0) { + return orderedProducts.reverse(); + } + + return orderedProducts; + }; diff --git a/src/collections/components/CollectionProducts/types.ts b/src/collections/components/CollectionProducts/types.ts index 965d7c8360d..eee722c0b21 100644 --- a/src/collections/components/CollectionProducts/types.ts +++ b/src/collections/components/CollectionProducts/types.ts @@ -1,5 +1,7 @@ import { CollectionDetailsQuery } from "@dashboard/graphql"; -export type Product = NonNullable< +export type Edges = NonNullable< NonNullable["products"]>["edges"] ->[number]["node"]; +>; + +export type Product = Edges[number]["node"]; diff --git a/src/collections/components/CollectionProducts/useProductDrag.test.ts b/src/collections/components/CollectionProducts/useProductDrag.test.ts new file mode 100644 index 00000000000..b82e04674ea --- /dev/null +++ b/src/collections/components/CollectionProducts/useProductDrag.test.ts @@ -0,0 +1,48 @@ +import type { DragEndEvent } from "@dnd-kit/core"; +import { act, renderHook } from "@testing-library/react-hooks"; + +import { Product } from "./types"; +import { useProductDrag } from "./useProductDrag"; +import { useProductReorder } from "./useProductReorder"; + +jest.mock("@dnd-kit/core"); +jest.mock("./useProductReorder"); + +describe("CollectionProducts/useProductDrag", () => { + const initialProducts = [ + { id: "1", name: "Product 1" }, + { id: "2", name: "Product 2" }, + { id: "3", name: "Product 3" }, + ] as Product[]; + + it("should reorder items on drag end", () => { + // Arrange + const move = jest.fn(); + + (useProductReorder as jest.Mock).mockReturnValue({ + move, + data: { loading: false }, + }); + + const { result } = renderHook(() => useProductDrag(initialProducts)); + + const dragEndEvent = { + active: { id: "1" }, + over: { id: "2" }, + } as DragEndEvent; + + // Act + act(() => { + result.current.handleDragEnd(dragEndEvent); + }); + + // Assert + expect(result.current.items).toEqual([ + { id: "2", name: "Product 2" }, + { id: "1", name: "Product 1" }, + { id: "3", name: "Product 3" }, + ]); + + expect(move).toHaveBeenCalledWith(["1"], -1); + }); +}); diff --git a/src/collections/components/CollectionProducts/useProductEdges.test.ts b/src/collections/components/CollectionProducts/useProductEdges.test.ts new file mode 100644 index 00000000000..0af23a2ccf2 --- /dev/null +++ b/src/collections/components/CollectionProducts/useProductEdges.test.ts @@ -0,0 +1,186 @@ +import { useApolloClient } from "@apollo/client"; +import { renderHook } from "@testing-library/react-hooks"; + +import { useProductEdges } from "./useProductEdges"; + +jest.mock("@apollo/client"); +jest.mock("./useCollectionId"); + +describe("CollectionProducts/useProductEdges", () => { + const mockReadQuery = jest.fn(); + const mockClient = { readQuery: mockReadQuery }; + + beforeEach(() => { + (useApolloClient as jest.Mock).mockReturnValue(mockClient); + }); + + it("shifts a single product", () => { + mockReadQuery.mockReturnValue({ + collection: { + products: { + edges: [ + { node: { id: "1" } }, + { node: { id: "2" } }, + { node: { id: "3" } }, + { node: { id: "4" } }, + ], + }, + }, + }); + + const { result } = renderHook(() => useProductEdges()); + const shiftedEdges = result.current.shift(["3"], 1); + + expect(shiftedEdges).toEqual([ + { node: { id: "1" } }, + { node: { id: "2" } }, + { node: { id: "4" } }, + { node: { id: "3" } }, + ]); + }); + + it("shifts multiple products down", () => { + mockReadQuery.mockReturnValue({ + collection: { + products: { + edges: [ + { node: { id: "1" } }, + { node: { id: "2" } }, + { node: { id: "3" } }, + { node: { id: "4" } }, + { node: { id: "5" } }, + { node: { id: "6" } }, + { node: { id: "7" } }, + ], + }, + }, + }); + + const { result } = renderHook(() => useProductEdges()); + const shiftedEdges = result.current.shift(["1", "2"], 1); + + expect(shiftedEdges).toEqual([ + { node: { id: "3" } }, + { node: { id: "1" } }, + { node: { id: "2" } }, + { node: { id: "4" } }, + { node: { id: "5" } }, + { node: { id: "6" } }, + { node: { id: "7" } }, + ]); + }); + + it("shifts multiple and random products down", () => { + mockReadQuery.mockReturnValue({ + collection: { + products: { + edges: [ + { node: { id: "1" } }, + { node: { id: "2" } }, + { node: { id: "3" } }, + { node: { id: "4" } }, + { node: { id: "5" } }, + { node: { id: "6" } }, + { node: { id: "7" } }, + ], + }, + }, + }); + + const { result } = renderHook(() => useProductEdges()); + const shiftedEdges = result.current.shift(["1", "4", "6"], 1); + + expect(shiftedEdges).toEqual([ + { node: { id: "2" } }, + { node: { id: "1" } }, + { node: { id: "3" } }, + { node: { id: "5" } }, + { node: { id: "4" } }, + { node: { id: "7" } }, + { node: { id: "6" } }, + ]); + }); + + it("shifts multiple single products up", () => { + mockReadQuery.mockReturnValue({ + collection: { + products: { + edges: [ + { node: { id: "1" } }, + { node: { id: "2" } }, + { node: { id: "3" } }, + { node: { id: "4" } }, + { node: { id: "5" } }, + { node: { id: "6" } }, + { node: { id: "7" } }, + ], + }, + }, + }); + + const { result } = renderHook(() => useProductEdges()); + const shiftedEdges = result.current.shift(["5", "6"], -1); + + expect(shiftedEdges).toEqual([ + { node: { id: "1" } }, + { node: { id: "2" } }, + { node: { id: "3" } }, + { node: { id: "5" } }, + { node: { id: "6" } }, + { node: { id: "4" } }, + { node: { id: "7" } }, + ]); + }); + + it("shifts multiple random products up", () => { + mockReadQuery.mockReturnValue({ + collection: { + products: { + edges: [ + { node: { id: "1" } }, + { node: { id: "2" } }, + { node: { id: "3" } }, + { node: { id: "4" } }, + { node: { id: "5" } }, + { node: { id: "6" } }, + { node: { id: "7" } }, + ], + }, + }, + }); + + const { result } = renderHook(() => useProductEdges()); + const shiftedEdges = result.current.shift(["2", "3", "6"], -1); + + expect(shiftedEdges).toEqual([ + { node: { id: "2" } }, + { node: { id: "3" } }, + { node: { id: "1" } }, + { node: { id: "4" } }, + { node: { id: "6" } }, + { node: { id: "5" } }, + { node: { id: "7" } }, + ]); + }); + + it("should identify if shift exceeds page", () => { + mockReadQuery.mockReturnValue({ + collection: { + products: { + edges: [ + { node: { id: "1" } }, + { node: { id: "2" } }, + { node: { id: "3" } }, + { node: { id: "4" } }, + ], + }, + }, + }); + + const { result } = renderHook(() => useProductEdges()); + const { isExceed, exceededProductIds } = result.current.isShiftExceedPage(["1"], -1); + + expect(isExceed).toBe(true); + expect(exceededProductIds).toEqual(["1"]); + }); +}); diff --git a/src/collections/components/CollectionProducts/useProductEdges.ts b/src/collections/components/CollectionProducts/useProductEdges.ts index b2c398e986c..8c63f5b0c1f 100644 --- a/src/collections/components/CollectionProducts/useProductEdges.ts +++ b/src/collections/components/CollectionProducts/useProductEdges.ts @@ -20,25 +20,30 @@ export const useProductEdges = () => { const edges = queryData?.collection?.products?.edges || []; - const shift = (productIds: string[], shiftAmount: number) => { - const idsSet = new Set(productIds); - const shiftedArray = [...edges]; + const shift = (idsToShift: string[], shiftAmount: number) => { + const edgesIds = edges.map(edge => edge.node.id); + const newArray = [...edgesIds]; - const indicesToShift = shiftedArray - .map((item, index) => (idsSet.has(item.node.id) ? index : -1)) - .filter(index => index !== -1); + idsToShift.sort((a, b) => { + if (shiftAmount > 0) { + return newArray.indexOf(b) - newArray.indexOf(a); + } + + return newArray.indexOf(a) - newArray.indexOf(b); + }); - indicesToShift.forEach(index => { - const newIndex = index + shiftAmount; + idsToShift.forEach(id => { + const index = newArray.indexOf(id); - if (newIndex >= 0 && newIndex < shiftedArray.length) { - const [movedItem] = shiftedArray.splice(index, 1); + if (index !== -1) { + const newIndex = index + shiftAmount; - shiftedArray.splice(newIndex, 0, movedItem); + newArray.splice(index, 1); + newArray.splice(newIndex, 0, id); } }); - return shiftedArray; + return newArray.map(id => edges.find(edge => edge.node.id === id)).filter(Boolean); }; const isShiftExceedPage = (productIds: string[], shiftAmount: number) => { diff --git a/src/collections/components/CollectionProducts/useProductReorder.test.ts b/src/collections/components/CollectionProducts/useProductReorder.test.ts new file mode 100644 index 00000000000..d05434bb5e5 --- /dev/null +++ b/src/collections/components/CollectionProducts/useProductReorder.test.ts @@ -0,0 +1,81 @@ +import { useReorderProductsInCollectionMutation } from "@dashboard/graphql"; +import { useLocalPaginationState } from "@dashboard/hooks/useLocalPaginator"; +import { act, renderHook } from "@testing-library/react-hooks"; + +import { useProductReorder } from "./useProductReorder"; +import { useProductReorderOptimistic } from "./useProductReorderOptimistic"; + +jest.mock("@dashboard/graphql", () => ({ + useReorderProductsInCollectionMutation: jest.fn(), +})); + +jest.mock("@dashboard/hooks/useLocalPaginator", () => ({ + useLocalPaginationState: jest.fn(), +})); + +jest.mock("./useProductReorderOptimistic", () => ({ + useProductReorderOptimistic: jest.fn(), +})); + +jest.mock("./useCollectionId", () => ({ + useCollectionId: jest.fn(() => "collection-id-1"), +})); + +describe("CollectionProducts/useProductReorder", () => { + const mockReorder = jest.fn(); + const mockCreateOptimisticResponse = jest.fn(); + const mockPaginationState = { page: 1, pageSize: 10 }; + + beforeEach(() => { + (useReorderProductsInCollectionMutation as jest.Mock).mockReturnValue([mockReorder, {}]); + (useLocalPaginationState as jest.Mock).mockReturnValue([mockPaginationState]); + (useProductReorderOptimistic as jest.Mock).mockReturnValue({ + createOptimisticResponse: mockCreateOptimisticResponse, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should call reorder with correct variables when move is called", () => { + // Arrange + const { result } = renderHook(() => useProductReorder()); + const productIds = ["1", "2", "3"]; + const shift = 1; + + // Act + act(() => { + result.current.move(productIds, shift); + }); + + // Assert + expect(mockReorder).toHaveBeenCalledWith({ + variables: { + collectionId: "collection-id-1", + moves: [ + { + productId: "1", + sortOrder: 1, + }, + { + productId: "2", + sortOrder: 1, + }, + { + productId: "3", + sortOrder: 1, + }, + ], + page: 1, + pageSize: 10, + }, + }); + }); + + it("should return data from the mutation", () => { + const { result } = renderHook(() => useProductReorder()); + + expect(result.current.data).toEqual({}); + }); +}); diff --git a/src/collections/components/CollectionProducts/useProductReorderOptimistic.test.ts b/src/collections/components/CollectionProducts/useProductReorderOptimistic.test.ts new file mode 100644 index 00000000000..80bef29d497 --- /dev/null +++ b/src/collections/components/CollectionProducts/useProductReorderOptimistic.test.ts @@ -0,0 +1,131 @@ +import { renderHook } from "@testing-library/react-hooks"; +import useRouter from "use-react-router"; + +import { useProductEdges } from "./useProductEdges"; +import { useProductReorderOptimistic } from "./useProductReorderOptimistic"; + +jest.mock("use-react-router"); +jest.mock("./useProductEdges"); + +describe("CollectionProducts/useProductReorderOptimistic", () => { + beforeEach(() => { + (useRouter as jest.Mock).mockReturnValue({ + match: { + params: { id: "collection-id-1" }, + }, + }); + (useProductEdges as jest.Mock).mockReturnValue({ + shift: jest.fn(), + isShiftExceedPage: jest.fn(), + }); + }); + + it("should create optimistic response correctly when product is not moved to the next page", () => { + // Arrange + const mockShift = jest + .fn() + .mockReturnValue([ + { node: { id: "product1" } }, + { node: { id: "product3" } }, + { node: { id: "product2" } }, + { node: { id: "product4" } }, + { node: { id: "product5" } }, + ]); + const mockIsShiftExceedPage = jest.fn().mockReturnValue({ exceededProductIds: [] }); + + (useProductEdges as jest.Mock).mockReturnValue({ + shift: mockShift, + isShiftExceedPage: mockIsShiftExceedPage, + }); + + // Act + const { result } = renderHook(() => useProductReorderOptimistic()); + + const response = result.current.createOptimisticResponse(["product3"], 1); + + // Assert + expect(response).toEqual({ + collectionReorderProducts: { + __typename: "CollectionReorderProducts", + collection: { + __typename: "Collection", + id: "collection-id-1", + products: { + __typename: "ProductCountableConnection", + edges: [ + { node: { id: "product1" } }, + { node: { id: "product3" } }, + { node: { id: "product2" } }, + { node: { id: "product4" } }, + { node: { id: "product5" } }, + ], + pageInfo: { + __typename: "PageInfo", + endCursor: null, + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + }, + }, + }, + errors: [], + }, + __typename: "Mutation", + }); + }); + + it("should create optimistic response correctly when product is moved to the next page", () => { + // Arrange + const mockShift = jest + .fn() + .mockReturnValue([ + { node: { id: "product3" } }, + { node: { id: "product1" } }, + { node: { id: "product2" } }, + { node: { id: "product4" } }, + { node: { id: "product5" } }, + ]); + + // Act + const mockIsShiftExceedPage = jest.fn().mockReturnValue({ exceededProductIds: ["product3"] }); + + (useProductEdges as jest.Mock).mockReturnValue({ + shift: mockShift, + isShiftExceedPage: mockIsShiftExceedPage, + }); + + const { result } = renderHook(() => useProductReorderOptimistic()); + + const response = result.current.createOptimisticResponse(["product3"], 3); + + // Assert + expect(response).toEqual({ + collectionReorderProducts: { + __typename: "CollectionReorderProducts", + collection: { + __typename: "Collection", + id: "collection-id-1", + products: { + __typename: "ProductCountableConnection", + edges: [ + { node: { id: "optimistic_product3" } }, + { node: { id: "product1" } }, + { node: { id: "product2" } }, + { node: { id: "product4" } }, + { node: { id: "product5" } }, + ], + pageInfo: { + __typename: "PageInfo", + endCursor: null, + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + }, + }, + }, + errors: [], + }, + __typename: "Mutation", + }); + }); +}); diff --git a/src/collections/components/ProductReorder/ReorderPopover.tsx b/src/collections/components/ProductReorder/ReorderPopover.tsx index c0a89e2d37e..2a68c80ac8a 100644 --- a/src/collections/components/ProductReorder/ReorderPopover.tsx +++ b/src/collections/components/ProductReorder/ReorderPopover.tsx @@ -1,16 +1,19 @@ import { Box, Button, Input, Popover, Text } from "@saleor/macaw-ui-next"; import React, { useState } from "react"; +import { FormattedMessage } from "react-intl"; +import { CollectionProducts, keepProductOrder } from "../CollectionProducts/keepProductOrder"; import { useProductReorder } from "../CollectionProducts/useProductReorder"; interface ReorderPopoverProps { listElements: string[]; - onReorder: () => void; + collectionProducts: CollectionProducts; } -export const ReorderPopover = ({ listElements, onReorder }: ReorderPopoverProps) => { +export const ReorderPopover = ({ listElements, collectionProducts }: ReorderPopoverProps) => { const [position, setPosition] = useState(1); const { move } = useProductReorder(); + const orderElementsByPosition = keepProductOrder(listElements, collectionProducts); const handlePositionChange = (event: React.ChangeEvent) => { const value = parseInt(event.target.value); @@ -21,34 +24,32 @@ export const ReorderPopover = ({ listElements, onReorder }: ReorderPopoverProps) }; const handleMoveUp = () => { - move(listElements, position); - onReorder(); + move(orderElementsByPosition(position), position); }; const handleMoveDown = () => { - move(listElements, -position); - onReorder(); + move(orderElementsByPosition(-position), -position); }; return ( - Reorder selected products + - Move products by their positions. + - Move by positions + diff --git a/src/collections/views/CollectionDetails.tsx b/src/collections/views/CollectionDetails.tsx index 52173270b2c..4250fc60c41 100644 --- a/src/collections/views/CollectionDetails.tsx +++ b/src/collections/views/CollectionDetails.tsx @@ -40,6 +40,7 @@ import { FormattedMessage, useIntl } from "react-intl"; import { getMutationErrors, getMutationState, maybe } from "../../misc"; import CollectionDetailsPage from "../components/CollectionDetailsPage/CollectionDetailsPage"; import { CollectionUpdateData } from "../components/CollectionDetailsPage/form"; +import { keepProductOrder } from "../components/CollectionProducts/keepProductOrder"; import { ReorderPopover } from "../components/ProductReorder/ReorderPopover"; import { collectionListUrl, @@ -304,7 +305,7 @@ export const CollectionDetails: React.FC = ({ id, params saveButtonBarState={formTransitionState} toolbar={ <> - + + + + + {products ? ( + - - - - - - {products ? ( - + openModal("unassign", { + ids: listElements, + }) + } + updateListSettings={updateListSettings} + numberOfRows={numberOfRows} + /> + ) : ( + + )} + + + + unassignProduct({ + variables: { + ...paginationState, + collectionId: id, + productIds: params.ids ?? [], + }, + }) + } + open={params.action === "unassign"} + title={intl.formatMessage({ + id: "5OtU+V", + defaultMessage: "Unassign products from collection", + description: "dialog title", + })} + > + {params.ids?.length ?? 0}, + }} /> - ) : ( - - )} - + + ); }; diff --git a/src/collections/components/CollectionProducts/Pagination.tsx b/src/collections/components/CollectionProducts/Pagination.tsx index 0b931c55b7b..3ce72c45074 100644 --- a/src/collections/components/CollectionProducts/Pagination.tsx +++ b/src/collections/components/CollectionProducts/Pagination.tsx @@ -1,31 +1,71 @@ import { usePaginatorContext } from "@dashboard/hooks/usePaginator"; -import { Box, Button, ChevronLeftIcon, ChevronRightIcon } from "@saleor/macaw-ui-next"; +import { + Box, + Button, + ChevronLeftIcon, + ChevronRightIcon, + Select, + Text, +} from "@saleor/macaw-ui-next"; import React from "react"; +import { FormattedMessage } from "react-intl"; -export const Pagination = () => { +const ROW_NUMBER_OPTIONS = [ + { label: "10", value: "10" }, + { label: "20", value: "20" }, + { label: "50", value: "50" }, + { label: "100", value: "100" }, +]; + +interface PaginationProps { + onUpdateListSettings: (key: "rowNumber", value: number) => void; + numberOfRows: number; +} + +export const Pagination = ({ onUpdateListSettings, numberOfRows }: PaginationProps) => { const { hasNextPage, hasPreviousPage, loadNextPage, loadPreviousPage } = usePaginatorContext(); + const currentRowNumber = String(numberOfRows); + const currentRowNumberOption = ROW_NUMBER_OPTIONS.find( + option => option.value === currentRowNumber, + ); + + const handleRowNumberChange = ({ value }: { value: string; label: string }) => { + onUpdateListSettings("rowNumber", parseInt(value)); + }; return ( - - - - - - - - - - - - - ); -}; diff --git a/src/collections/mutations.ts b/src/collections/mutations.ts index 9399e7b2e62..09ee4cc4c42 100644 --- a/src/collections/mutations.ts +++ b/src/collections/mutations.ts @@ -96,15 +96,7 @@ export const unassignCollectionProduct = gql` ) { edges { node { - id - name - productType { - id - name - } - thumbnail { - url - } + ...CollectionProduct } } pageInfo { diff --git a/src/collections/queries.ts b/src/collections/queries.ts index d7a2dc5dc2a..d5bc13604aa 100644 --- a/src/collections/queries.ts +++ b/src/collections/queries.ts @@ -38,9 +38,17 @@ export const collectionList = gql` `; export const collectionDetails = gql` - query CollectionDetails($id: ID!, $first: Int, $after: String, $last: Int, $before: String) { + query CollectionDetails($id: ID) { collection(id: $id) { ...CollectionDetails + } + } +`; + +export const collectionProducts = gql` + query CollectionProducts($id: ID!, $first: Int, $after: String, $last: Int, $before: String) { + collection(id: $id) { + id products( first: $first after: $after diff --git a/src/collections/utils.ts b/src/collections/utils.ts index 95129d8c467..9b4af6ce15b 100644 --- a/src/collections/utils.ts +++ b/src/collections/utils.ts @@ -26,7 +26,7 @@ export const createChannelsChangeHandler = export const getAssignedProductIdsToCollection = ( collection: CollectionDetailsQuery["collection"], - queryData: SearchProductsQuery["search"], + queryData?: SearchProductsQuery["search"], ) => { if (!queryData || !collection) { return {}; diff --git a/src/collections/views/CollectionDetails.tsx b/src/collections/views/CollectionDetails.tsx index 0237799e94b..bef48e65cf4 100644 --- a/src/collections/views/CollectionDetails.tsx +++ b/src/collections/views/CollectionDetails.tsx @@ -2,52 +2,40 @@ import { createCollectionChannels, createCollectionChannelsData } from "@dashboard/channels/utils"; import ActionDialog from "@dashboard/components/ActionDialog"; import useAppChannel from "@dashboard/components/AppLayout/AppChannelContext"; -import { Container } from "@dashboard/components/AssignContainerDialog"; -import AssignProductDialog from "@dashboard/components/AssignProductDialog"; import ChannelsAvailabilityDialog from "@dashboard/components/ChannelsAvailabilityDialog"; import NotFoundPage from "@dashboard/components/NotFoundPage"; import { WindowTitle } from "@dashboard/components/WindowTitle"; -import { DEFAULT_INITIAL_SEARCH_DATA, PAGINATE_BY } from "@dashboard/config"; import { CollectionInput, CollectionUpdateMutation, - useCollectionAssignProductMutation, useCollectionChannelListingUpdateMutation, useCollectionDetailsQuery, useCollectionUpdateMutation, useRemoveCollectionMutation, - useUnassignCollectionProductMutation, useUpdateMetadataMutation, useUpdatePrivateMetadataMutation, } from "@dashboard/graphql"; -import useBulkActions from "@dashboard/hooks/useBulkActions"; import useChannels from "@dashboard/hooks/useChannels"; -import useLocalPaginator, { useLocalPaginationState } from "@dashboard/hooks/useLocalPaginator"; import useLocalStorage from "@dashboard/hooks/useLocalStorage"; import useNavigator from "@dashboard/hooks/useNavigator"; import useNotifier from "@dashboard/hooks/useNotifier"; -import { PaginatorContext } from "@dashboard/hooks/usePaginator"; import { commonMessages, errorMessages } from "@dashboard/intl"; -import useProductSearch from "@dashboard/searches/useProductSearch"; import { arrayDiff } from "@dashboard/utils/arrays"; import createDialogActionHandlers from "@dashboard/utils/handlers/dialogActionHandlers"; import createMetadataUpdateHandler from "@dashboard/utils/handlers/metadataUpdateHandler"; import { getParsedDataForJsonStringField } from "@dashboard/utils/richText/misc"; -import { Button } from "@saleor/macaw-ui-next"; import React from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { getMutationErrors, getMutationState, maybe } from "../../misc"; import CollectionDetailsPage from "../components/CollectionDetailsPage/CollectionDetailsPage"; import { CollectionUpdateData } from "../components/CollectionDetailsPage/form"; -import { ReorderPopover } from "../components/ProductReorder/ReorderPopover"; import { collectionListUrl, collectionUrl, CollectionUrlDialog, CollectionUrlQueryParams, } from "../urls"; -import { getAssignedProductIdsToCollection, getProductsFromSearchResults } from "../utils"; import { COLLECTION_DETAILS_FORM_ID } from "./consts"; interface CollectionDetailsProps { @@ -58,11 +46,7 @@ interface CollectionDetailsProps { export const CollectionDetails: React.FC = ({ id, params }) => { const navigate = useNavigator(); const notify = useNotifier(); - const { isSelected, listElements, reset, toggle, toggleAll } = useBulkActions(params.ids); const intl = useIntl(); - const { search, loadMore, result } = useProductSearch({ - variables: DEFAULT_INITIAL_SEARCH_DATA, - }); const [openModal, closeModal] = createDialogActionHandlers< CollectionUrlDialog, CollectionUrlQueryParams @@ -95,35 +79,7 @@ export const CollectionDetails: React.FC = ({ id, params const [updateCollection, updateCollectionOpts] = useCollectionUpdateMutation({ onCompleted: handleCollectionUpdate, }); - const [assignProduct, assignProductOpts] = useCollectionAssignProductMutation({ - onCompleted: data => { - if (data.collectionAddProducts.errors.length === 0) { - notify({ - status: "success", - text: intl.formatMessage({ - id: "56vUeQ", - defaultMessage: "Added product to collection", - }), - }); - navigate(collectionUrl(id), { replace: true }); - } - }, - }); - const [unassignProduct, unassignProductOpts] = useUnassignCollectionProductMutation({ - onCompleted: data => { - if (data.collectionRemoveProducts.errors.length === 0) { - notify({ - status: "success", - text: intl.formatMessage({ - id: "WW+Ruy", - defaultMessage: "Deleted product from collection", - }), - }); - reset(); - closeModal(); - } - }, - }); + const [removeCollection, removeCollectionOpts] = useRemoveCollectionMutation({ onCompleted: data => { if (data.collectionDelete.errors.length === 0) { @@ -138,13 +94,13 @@ export const CollectionDetails: React.FC = ({ id, params } }, }); - const [paginationState, setPaginationState] = useLocalPaginationState(PAGINATE_BY); - const paginate = useLocalPaginator(setPaginationState); + const [selectedChannel] = useLocalStorage("collectionListChannel", ""); const { data, loading } = useCollectionDetailsQuery({ displayLoader: true, - variables: { id, ...paginationState }, + variables: { id }, }); + const collection = data?.collection; const allChannels = createCollectionChannels(availableChannels)?.sort((channel, nextChannel) => channel.name.localeCompare(nextChannel.name), @@ -213,45 +169,19 @@ export const CollectionDetails: React.FC = ({ id, params variables => updateMetadata({ variables }), variables => updatePrivateMetadata({ variables }), ); - const handleAssignationChange = async (products: Container[]) => { - const productIds = products.map(product => product.id); - const toUnassignIds = Object.keys(assignedProductDict).filter( - s => assignedProductDict[s] && !productIds.includes(s), - ); - const baseVariables = { ...paginationState, collectionId: id }; - - if (productIds.length > 0) { - await assignProduct({ - variables: { ...baseVariables, productIds }, - }); - } - - if (toUnassignIds.length > 0) { - await unassignProduct({ - variables: { ...baseVariables, productIds: toUnassignIds }, - }); - } - await result.refetch(DEFAULT_INITIAL_SEARCH_DATA); - }; const formTransitionState = getMutationState( updateCollectionOpts.called, updateCollectionOpts.loading, updateCollectionOpts.data?.collectionUpdate.errors, ); - const { pageInfo, ...paginationValues } = paginate( - data?.collection?.products?.pageInfo, - paginationState, - ); if (collection === null) { return ; } - const assignedProductDict = getAssignedProductIdsToCollection(collection, result.data?.search); - return ( - + <> {!!allChannels?.length && ( = ({ id, params /> )} openModal("assign")} disabled={loading || updateChannelsOpts.loading} collection={data?.collection} channelsErrors={updateChannelsOpts?.data?.collectionChannelListingUpdate.errors || []} @@ -290,69 +219,13 @@ export const CollectionDetails: React.FC = ({ id, params }) } onSubmit={handleSubmit} - onProductUnassign={async (productId, event) => { - event.stopPropagation(); - await unassignProduct({ - variables: { - collectionId: id, - productIds: [productId], - ...paginationState, - }, - }); - await result.refetch(DEFAULT_INITIAL_SEARCH_DATA); - }} saveButtonBarState={formTransitionState} - toolbar={ - <> - - - - } - isChecked={isSelected} - selected={listElements.length} - toggle={toggle} - toggleAll={toggleAll} currentChannels={currentChannels} channelsCount={availableChannels.length} selectedChannelId={selectedChannel} openChannelsModal={handleChannelsModalOpen} onChannelsChange={setCurrentChannels} - paginationState={paginationState} - /> - = ({ id, params }} /> - - unassignProduct({ - variables: { - ...paginationState, - collectionId: id, - productIds: params.ids, - }, - }) - } - open={params.action === "unassign"} - title={intl.formatMessage({ - id: "5OtU+V", - defaultMessage: "Unassign products from collection", - description: "dialog title", - })} - > - params.ids.length), - displayQuantity: {maybe(() => params.ids.length)}, - }} - /> - + = ({ id, params defaultMessage="Are you sure you want to delete collection's image?" /> - + ); }; export default CollectionDetails; diff --git a/src/components/Metadata/Metadata.tsx b/src/components/Metadata/Metadata.tsx index d4a0301d964..677f3509848 100644 --- a/src/components/Metadata/Metadata.tsx +++ b/src/components/Metadata/Metadata.tsx @@ -72,7 +72,7 @@ export const Metadata: React.FC = memo( }; return ( - + {isLoading ? ( <> diff --git a/src/graphql/hooks.generated.ts b/src/graphql/hooks.generated.ts index 267d356e201..b52d4314f2a 100644 --- a/src/graphql/hooks.generated.ts +++ b/src/graphql/hooks.generated.ts @@ -5567,15 +5567,7 @@ export const UnassignCollectionProductDocument = gql` ) { edges { node { - id - name - productType { - id - name - } - thumbnail { - url - } + ...CollectionProduct } } pageInfo { @@ -5591,7 +5583,8 @@ export const UnassignCollectionProductDocument = gql` } } } - ${CollectionErrorFragmentDoc}`; + ${CollectionProductFragmentDoc} +${CollectionErrorFragmentDoc}`; export type UnassignCollectionProductMutationFn = Apollo.MutationFunction; /** @@ -5819,9 +5812,44 @@ export type CollectionListQueryHookResult = ReturnType; export type CollectionListQueryResult = Apollo.QueryResult; export const CollectionDetailsDocument = gql` - query CollectionDetails($id: ID!, $first: Int, $after: String, $last: Int, $before: String) { + query CollectionDetails($id: ID) { collection(id: $id) { ...CollectionDetails + } +} + ${CollectionDetailsFragmentDoc}`; + +/** + * __useCollectionDetailsQuery__ + * + * To run a query within a React component, call `useCollectionDetailsQuery` and pass it any options that fit your needs. + * When your component renders, `useCollectionDetailsQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useCollectionDetailsQuery({ + * variables: { + * id: // value for 'id' + * }, + * }); + */ +export function useCollectionDetailsQuery(baseOptions?: ApolloReactHooks.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return ApolloReactHooks.useQuery(CollectionDetailsDocument, options); + } +export function useCollectionDetailsLazyQuery(baseOptions?: ApolloReactHooks.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return ApolloReactHooks.useLazyQuery(CollectionDetailsDocument, options); + } +export type CollectionDetailsQueryHookResult = ReturnType; +export type CollectionDetailsLazyQueryHookResult = ReturnType; +export type CollectionDetailsQueryResult = Apollo.QueryResult; +export const CollectionProductsDocument = gql` + query CollectionProducts($id: ID!, $first: Int, $after: String, $last: Int, $before: String) { + collection(id: $id) { + id products( first: $first after: $after @@ -5843,20 +5871,19 @@ export const CollectionDetailsDocument = gql` } } } - ${CollectionDetailsFragmentDoc} -${CollectionProductFragmentDoc}`; + ${CollectionProductFragmentDoc}`; /** - * __useCollectionDetailsQuery__ + * __useCollectionProductsQuery__ * - * To run a query within a React component, call `useCollectionDetailsQuery` and pass it any options that fit your needs. - * When your component renders, `useCollectionDetailsQuery` returns an object from Apollo Client that contains loading, error, and data properties + * To run a query within a React component, call `useCollectionProductsQuery` and pass it any options that fit your needs. + * When your component renders, `useCollectionProductsQuery` returns an object from Apollo Client that contains loading, error, and data properties * you can use to render your UI. * * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; * * @example - * const { data, loading, error } = useCollectionDetailsQuery({ + * const { data, loading, error } = useCollectionProductsQuery({ * variables: { * id: // value for 'id' * first: // value for 'first' @@ -5866,17 +5893,17 @@ ${CollectionProductFragmentDoc}`; * }, * }); */ -export function useCollectionDetailsQuery(baseOptions: ApolloReactHooks.QueryHookOptions) { +export function useCollectionProductsQuery(baseOptions: ApolloReactHooks.QueryHookOptions) { const options = {...defaultOptions, ...baseOptions} - return ApolloReactHooks.useQuery(CollectionDetailsDocument, options); + return ApolloReactHooks.useQuery(CollectionProductsDocument, options); } -export function useCollectionDetailsLazyQuery(baseOptions?: ApolloReactHooks.LazyQueryHookOptions) { +export function useCollectionProductsLazyQuery(baseOptions?: ApolloReactHooks.LazyQueryHookOptions) { const options = {...defaultOptions, ...baseOptions} - return ApolloReactHooks.useLazyQuery(CollectionDetailsDocument, options); + return ApolloReactHooks.useLazyQuery(CollectionProductsDocument, options); } -export type CollectionDetailsQueryHookResult = ReturnType; -export type CollectionDetailsLazyQueryHookResult = ReturnType; -export type CollectionDetailsQueryResult = Apollo.QueryResult; +export type CollectionProductsQueryHookResult = ReturnType; +export type CollectionProductsLazyQueryHookResult = ReturnType; +export type CollectionProductsQueryResult = Apollo.QueryResult; export const AddressValidationRulesDocument = gql` query addressValidationRules($countryCode: CountryCode!) { addressValidationRules(countryCode: $countryCode) { diff --git a/src/graphql/types.generated.ts b/src/graphql/types.generated.ts index 5c0c0cb88bb..ac46d1e9507 100644 --- a/src/graphql/types.generated.ts +++ b/src/graphql/types.generated.ts @@ -9333,7 +9333,7 @@ export type UnassignCollectionProductMutationVariables = Exact<{ }>; -export type UnassignCollectionProductMutation = { __typename: 'Mutation', collectionRemoveProducts: { __typename: 'CollectionRemoveProducts', collection: { __typename: 'Collection', id: string, products: { __typename: 'ProductCountableConnection', edges: Array<{ __typename: 'ProductCountableEdge', node: { __typename: 'Product', id: string, name: string, productType: { __typename: 'ProductType', id: string, name: string }, thumbnail: { __typename: 'Image', url: string } | null } }>, pageInfo: { __typename: 'PageInfo', endCursor: string | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor: string | null } } | null } | null, errors: Array<{ __typename: 'CollectionError', code: CollectionErrorCode, field: string | null, message: string | null }> } | null }; +export type UnassignCollectionProductMutation = { __typename: 'Mutation', collectionRemoveProducts: { __typename: 'CollectionRemoveProducts', collection: { __typename: 'Collection', id: string, products: { __typename: 'ProductCountableConnection', edges: Array<{ __typename: 'ProductCountableEdge', node: { __typename: 'Product', id: string, name: string, productType: { __typename: 'ProductType', id: string, name: string }, thumbnail: { __typename: 'Image', url: string } | null, channelListings: Array<{ __typename: 'ProductChannelListing', id: string, isPublished: boolean, publishedAt: any | null, isAvailableForPurchase: boolean | null, availableForPurchaseAt: any | null, visibleInListings: boolean, channel: { __typename: 'Channel', id: string, name: string, currencyCode: string } }> | null } }>, pageInfo: { __typename: 'PageInfo', endCursor: string | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor: string | null } } | null } | null, errors: Array<{ __typename: 'CollectionError', code: CollectionErrorCode, field: string | null, message: string | null }> } | null }; export type CollectionBulkDeleteMutationVariables = Exact<{ ids: Array | Scalars['ID']; @@ -9376,6 +9376,13 @@ export type CollectionListQueryVariables = Exact<{ export type CollectionListQuery = { __typename: 'Query', collections: { __typename: 'CollectionCountableConnection', edges: Array<{ __typename: 'CollectionCountableEdge', node: { __typename: 'Collection', id: string, name: string, products: { __typename: 'ProductCountableConnection', totalCount: number | null } | null, channelListings: Array<{ __typename: 'CollectionChannelListing', isPublished: boolean, publishedAt: any | null, channel: { __typename: 'Channel', id: string, name: string } }> | null } }>, pageInfo: { __typename: 'PageInfo', endCursor: string | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor: string | null } } | null }; export type CollectionDetailsQueryVariables = Exact<{ + id?: InputMaybe; +}>; + + +export type CollectionDetailsQuery = { __typename: 'Query', collection: { __typename: 'Collection', slug: string, description: any | null, seoDescription: string | null, seoTitle: string | null, id: string, name: string, backgroundImage: { __typename: 'Image', alt: string | null, url: string } | null, channelListings: Array<{ __typename: 'CollectionChannelListing', isPublished: boolean, publishedAt: any | null, channel: { __typename: 'Channel', id: string, name: string } }> | null, metadata: Array<{ __typename: 'MetadataItem', key: string, value: string }>, privateMetadata: Array<{ __typename: 'MetadataItem', key: string, value: string }> } | null }; + +export type CollectionProductsQueryVariables = Exact<{ id: Scalars['ID']; first?: InputMaybe; after?: InputMaybe; @@ -9384,7 +9391,7 @@ export type CollectionDetailsQueryVariables = Exact<{ }>; -export type CollectionDetailsQuery = { __typename: 'Query', collection: { __typename: 'Collection', slug: string, description: any | null, seoDescription: string | null, seoTitle: string | null, id: string, name: string, products: { __typename: 'ProductCountableConnection', edges: Array<{ __typename: 'ProductCountableEdge', node: { __typename: 'Product', id: string, name: string, productType: { __typename: 'ProductType', id: string, name: string }, thumbnail: { __typename: 'Image', url: string } | null, channelListings: Array<{ __typename: 'ProductChannelListing', id: string, isPublished: boolean, publishedAt: any | null, isAvailableForPurchase: boolean | null, availableForPurchaseAt: any | null, visibleInListings: boolean, channel: { __typename: 'Channel', id: string, name: string, currencyCode: string } }> | null } }>, pageInfo: { __typename: 'PageInfo', endCursor: string | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor: string | null } } | null, backgroundImage: { __typename: 'Image', alt: string | null, url: string } | null, channelListings: Array<{ __typename: 'CollectionChannelListing', isPublished: boolean, publishedAt: any | null, channel: { __typename: 'Channel', id: string, name: string } }> | null, metadata: Array<{ __typename: 'MetadataItem', key: string, value: string }>, privateMetadata: Array<{ __typename: 'MetadataItem', key: string, value: string }> } | null }; +export type CollectionProductsQuery = { __typename: 'Query', collection: { __typename: 'Collection', id: string, products: { __typename: 'ProductCountableConnection', edges: Array<{ __typename: 'ProductCountableEdge', node: { __typename: 'Product', id: string, name: string, productType: { __typename: 'ProductType', id: string, name: string }, thumbnail: { __typename: 'Image', url: string } | null, channelListings: Array<{ __typename: 'ProductChannelListing', id: string, isPublished: boolean, publishedAt: any | null, isAvailableForPurchase: boolean | null, availableForPurchaseAt: any | null, visibleInListings: boolean, channel: { __typename: 'Channel', id: string, name: string, currencyCode: string } }> | null } }>, pageInfo: { __typename: 'PageInfo', endCursor: string | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor: string | null } } | null } | null }; export type AddressValidationRulesQueryVariables = Exact<{ countryCode: CountryCode; diff --git a/src/types.ts b/src/types.ts index 29652db8635..7980df13516 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,6 +26,7 @@ export enum ListViews { ATTRIBUTE_VALUE_LIST = "ATTRIBUTE_VALUE_LIST", CATEGORY_LIST = "CATEGORY_LIST", COLLECTION_LIST = "COLLECTION_LIST", + COLLECTION_PRODUCTS_LIST = "COLLECTION_PRODUCTS_LIST", CUSTOMER_LIST = "CUSTOMER_LIST", DRAFT_LIST = "DRAFT_LIST", NAVIGATION_LIST = "NAVIGATION_LIST", From 5a87eb7f62387b1c298a50e82a0b1682d9875bec Mon Sep 17 00:00:00 2001 From: andrzejewsky Date: Tue, 28 Jan 2025 16:43:36 +0100 Subject: [PATCH 10/22] Update tests --- .../useProductEdges.test.ts | 22 +++++++++++++++---- .../useProductReorder.test.ts | 2 ++ .../useProductReorderOptimistic.test.ts | 12 ++++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/collections/components/CollectionProducts/useProductEdges.test.ts b/src/collections/components/CollectionProducts/useProductEdges.test.ts index 91198af6272..ce1bbeb5f0b 100644 --- a/src/collections/components/CollectionProducts/useProductEdges.test.ts +++ b/src/collections/components/CollectionProducts/useProductEdges.test.ts @@ -43,9 +43,11 @@ describe("CollectionProducts/useProductEdges", () => { mockReadQuery.mockReturnValue(mockQueryResponse); }); - it("should return product edges when query is successful", () => { + it("should return product edges when query cache exists", () => { + // Arrange & Act const { result } = renderHook(() => useProductEdges({ paginationState: mockPaginationState })); + // Assert expect(mockReadQuery).toHaveBeenCalledWith({ query: "CollectionProductsDocument", variables: { @@ -57,40 +59,52 @@ describe("CollectionProducts/useProductEdges", () => { expect(result.current.edges).toEqual(mockProductEdges); }); - it("should return empty array when query returns null", () => { + it("should return empty array when query cache is empty", () => { + // Arrange mockReadQuery.mockReturnValue(null); + // Act const { result } = renderHook(() => useProductEdges({ paginationState: mockPaginationState })); + // Assert expect(result.current.edges).toEqual([]); }); - it("should return empty array when collection is null", () => { + it("should return empty array when collection is empty", () => { + // Arrange mockReadQuery.mockReturnValue({ collection: null }); + // Act const { result } = renderHook(() => useProductEdges({ paginationState: mockPaginationState })); + // Assert expect(result.current.edges).toEqual([]); }); - it("should return empty array when products is null", () => { + it("should return empty array when there are no products", () => { + // Arrange mockReadQuery.mockReturnValue({ collection: { products: null }, }); + // Act const { result } = renderHook(() => useProductEdges({ paginationState: mockPaginationState })); + // Assert expect(result.current.edges).toEqual([]); }); it("should pass pagination state to query", () => { + // Arrange const customPaginationState = { first: 20, after: "cursor-123", }; + // Act renderHook(() => useProductEdges({ paginationState: customPaginationState })); + // Assert expect(mockReadQuery).toHaveBeenCalledWith({ query: expect.anything(), variables: { diff --git a/src/collections/components/CollectionProducts/useProductReorder.test.ts b/src/collections/components/CollectionProducts/useProductReorder.test.ts index b2c89d9721c..02620f99797 100644 --- a/src/collections/components/CollectionProducts/useProductReorder.test.ts +++ b/src/collections/components/CollectionProducts/useProductReorder.test.ts @@ -88,10 +88,12 @@ describe("CollectionProducts/useProductReorder", () => { }); it("should return data from the mutation", () => { + // Arrange & Act const { result } = renderHook(() => useProductReorder({ paginationState: { first: 10, after: "1" } }), ); + // Assert expect(result.current.data).toEqual({}); }); }); diff --git a/src/collections/components/CollectionProducts/useProductReorderOptimistic.test.ts b/src/collections/components/CollectionProducts/useProductReorderOptimistic.test.ts index cc27a8713e2..8c3cabcfdbd 100644 --- a/src/collections/components/CollectionProducts/useProductReorderOptimistic.test.ts +++ b/src/collections/components/CollectionProducts/useProductReorderOptimistic.test.ts @@ -35,6 +35,7 @@ const mockEdges = [ (useProductEdges as jest.Mock).mockReturnValue({ edges: mockEdges }); describe("CollectionProducts/useProductReorderOptimistic", () => { + // Arrange const defaultPaginationState = { first: 10, after: null, @@ -49,6 +50,7 @@ describe("CollectionProducts/useProductReorderOptimistic", () => { }); it("should create proper optimistic response for reordered products", () => { + // Arrange & Act const { result } = renderHook(() => useProductReorderOptimistic({ paginationState: defaultPaginationState }), ); @@ -62,6 +64,7 @@ describe("CollectionProducts/useProductReorderOptimistic", () => { const activeNodeId = "product-1"; const response = result.current.createForDroppedItem(products, activeNodeId); + // Assert expect(response).toEqual({ collectionReorderProducts: { __typename: "CollectionReorderProducts", @@ -106,6 +109,7 @@ describe("CollectionProducts/useProductReorderOptimistic", () => { }); it("should handle empty product list", () => { + // Arrange & Act const { result } = renderHook(() => useProductReorderOptimistic({ paginationState: defaultPaginationState }), ); @@ -114,10 +118,12 @@ describe("CollectionProducts/useProductReorderOptimistic", () => { const activeNodeId = "product-1"; const response = result.current.createForDroppedItem(products, activeNodeId); + // Assert expect(response.collectionReorderProducts.collection.products.edges).toEqual([]); }); it("should handle product not found in edges", () => { + // Arrange & Act const { result } = renderHook(() => useProductReorderOptimistic({ paginationState: defaultPaginationState }), ); @@ -130,6 +136,7 @@ describe("CollectionProducts/useProductReorderOptimistic", () => { const activeNodeId = "product-1"; const response = result.current.createForDroppedItem(products, activeNodeId); + // Assert expect(response.collectionReorderProducts.collection.products.edges).toEqual([ { node: { @@ -141,6 +148,7 @@ describe("CollectionProducts/useProductReorderOptimistic", () => { }); it("should use correct collection ID from hook", () => { + // Arrange const customCollectionId = "custom-collection-123"; (useCollectionId as jest.Mock).mockReturnValue(customCollectionId); @@ -153,10 +161,12 @@ describe("CollectionProducts/useProductReorderOptimistic", () => { const activeNodeId = "product-1"; const response = result.current.createForDroppedItem(products, activeNodeId); + // Assert expect(response.collectionReorderProducts.collection.id).toBe(customCollectionId); }); it("should preserve edge properties when creating optimistic response", () => { + // Arrange const edgesWithExtraProps = [ { node: { @@ -168,6 +178,7 @@ describe("CollectionProducts/useProductReorderOptimistic", () => { }, ]; + // Act (useProductEdges as jest.Mock).mockReturnValue({ edges: edgesWithExtraProps }); const { result } = renderHook(() => @@ -178,6 +189,7 @@ describe("CollectionProducts/useProductReorderOptimistic", () => { const activeNodeId = "product-1"; const response = result.current.createForDroppedItem(products, activeNodeId); + // Assert expect(response.collectionReorderProducts.collection.products.edges[0]).toMatchObject({ node: { id: "moved_product-1", From 361cc0ec67efb1a2c9a7abc0c35f5c3e4c0f66ab Mon Sep 17 00:00:00 2001 From: andrzejewsky Date: Tue, 28 Jan 2025 16:46:41 +0100 Subject: [PATCH 11/22] Update tests --- .../components/CollectionProducts/useProductReorder.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/collections/components/CollectionProducts/useProductReorder.test.ts b/src/collections/components/CollectionProducts/useProductReorder.test.ts index 02620f99797..94a990f522e 100644 --- a/src/collections/components/CollectionProducts/useProductReorder.test.ts +++ b/src/collections/components/CollectionProducts/useProductReorder.test.ts @@ -31,14 +31,14 @@ jest.mock("./useCollectionId", () => ({ describe("CollectionProducts/useProductReorder", () => { const mockReorder = jest.fn(); - const mockCreateOptimisticResponse = jest.fn(); + const createForDroppedItem = jest.fn(); const mockPaginationState = { page: 1, pageSize: 10 }; beforeEach(() => { (useReorderProductsInCollectionMutation as jest.Mock).mockReturnValue([mockReorder, {}]); (useLocalPaginationState as jest.Mock).mockReturnValue([mockPaginationState]); (useProductReorderOptimistic as jest.Mock).mockReturnValue({ - createOptimisticResponse: mockCreateOptimisticResponse, + createForDroppedItem: createForDroppedItem, }); }); From b639bcc655d8c56daf1d5982176b8de1744c6885 Mon Sep 17 00:00:00 2001 From: andrzejewsky Date: Tue, 28 Jan 2025 16:50:22 +0100 Subject: [PATCH 12/22] Update tests --- .../components/CollectionProducts/useProductReorder.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/collections/components/CollectionProducts/useProductReorder.test.ts b/src/collections/components/CollectionProducts/useProductReorder.test.ts index 94a990f522e..e28b9cbd699 100644 --- a/src/collections/components/CollectionProducts/useProductReorder.test.ts +++ b/src/collections/components/CollectionProducts/useProductReorder.test.ts @@ -29,6 +29,8 @@ jest.mock("./useCollectionId", () => ({ useCollectionId: jest.fn(() => "collection-id-1"), })); +jest.mock("@dashboard/hooks/useNotifier", () => jest.fn()); + describe("CollectionProducts/useProductReorder", () => { const mockReorder = jest.fn(); const createForDroppedItem = jest.fn(); From 80ca9c356edfe1c44c2083371fd0f7e183cef43f Mon Sep 17 00:00:00 2001 From: andrzejewsky Date: Tue, 28 Jan 2025 16:57:50 +0100 Subject: [PATCH 13/22] Update translations --- locale/defaultMessages.json | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index cc49db88c99..9e638dd04c2 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -821,9 +821,6 @@ "context": "price or ordered products", "string": "Price" }, - "32uBJ8": { - "string": "Move products by their positions." - }, "34F7Jk": { "context": "filter range separator", "string": "and" @@ -2968,9 +2965,6 @@ "context": "dialog content", "string": "You are not able to modify this group members. Solve this problem to continue with request." }, - "H/r5m6": { - "string": "Move down" - }, "H/xj8R": { "context": "VariantDetailsChannelsAvailabilityCard item subtitle published", "string": "Published since {getPublishedAt}" @@ -5488,6 +5482,9 @@ "context": "card update success alert title", "string": "Successfully updated card balance" }, + "XAvER/": { + "string": "Product reordered" + }, "XB2Jj9": { "context": "create app button", "string": "Create App" @@ -7449,9 +7446,6 @@ "kFsTMN": { "string": "Delete customers" }, - "kGQJcD": { - "string": "Reorder" - }, "kIcyUo": { "context": "column header", "string": "Slug" @@ -7891,6 +7885,9 @@ "context": "label", "string": "External app" }, + "nABmvC": { + "string": "No. of rows" + }, "nBzIBG": { "string": "Delete permission group" }, @@ -9259,9 +9256,6 @@ "context": "order history message", "string": "Restocked {quantity} items" }, - "wPayk9": { - "string": "Reorder selected products" - }, "wQdR8M": { "string": "Add search engine title and description to make this category easier to find" }, @@ -9310,9 +9304,6 @@ "context": "button", "string": "Create Webhook" }, - "wmFdws": { - "string": "Move up" - }, "wmdHhD": { "context": "button", "string": "Create Warehouse" @@ -9777,9 +9768,6 @@ "context": "cta button label", "string": "Explore Updates" }, - "zpbMmC": { - "string": "Move by positions" - }, "zqarUF": { "context": "modal information under title", "string": "Select an address you want to use from the list below" From 20a4d679625f5a2933b08148f7a12e2bbb5ff929 Mon Sep 17 00:00:00 2001 From: andrzejewsky Date: Tue, 28 Jan 2025 17:10:52 +0100 Subject: [PATCH 14/22] Update tests --- .../CollectionProducts/useProductReorder.test.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/collections/components/CollectionProducts/useProductReorder.test.ts b/src/collections/components/CollectionProducts/useProductReorder.test.ts index e28b9cbd699..02d63032fab 100644 --- a/src/collections/components/CollectionProducts/useProductReorder.test.ts +++ b/src/collections/components/CollectionProducts/useProductReorder.test.ts @@ -1,5 +1,6 @@ import { useReorderProductsInCollectionMutation } from "@dashboard/graphql"; import { useLocalPaginationState } from "@dashboard/hooks/useLocalPaginator"; +import useNotifier from "@dashboard/hooks/useNotifier"; import { act, renderHook } from "@testing-library/react-hooks"; import { Product } from "./types"; @@ -33,7 +34,8 @@ jest.mock("@dashboard/hooks/useNotifier", () => jest.fn()); describe("CollectionProducts/useProductReorder", () => { const mockReorder = jest.fn(); - const createForDroppedItem = jest.fn(); + const mockNotifier = jest.fn(); + const createForDroppedItem = jest.fn(() => "optimistic-response"); const mockPaginationState = { page: 1, pageSize: 10 }; beforeEach(() => { @@ -42,6 +44,7 @@ describe("CollectionProducts/useProductReorder", () => { (useProductReorderOptimistic as jest.Mock).mockReturnValue({ createForDroppedItem: createForDroppedItem, }); + (useNotifier as jest.Mock).mockReturnValue(mockNotifier); }); afterEach(() => { @@ -72,20 +75,13 @@ describe("CollectionProducts/useProductReorder", () => { collectionId: "collection-id-1", first: 10, moves: [ - { - productId: "1", - sortOrder: 1, - }, { productId: "2", sortOrder: 1, }, - { - productId: "3", - sortOrder: 1, - }, ], }, + optimisticResponse: "optimistic-response", }); }); From cd4f055f5c08e6ec1cfee06d680f7fb605339580 Mon Sep 17 00:00:00 2001 From: andrzejewsky Date: Tue, 28 Jan 2025 17:38:08 +0100 Subject: [PATCH 15/22] Fix initial load --- .../components/CollectionProducts/CollectionProducts.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/collections/components/CollectionProducts/CollectionProducts.tsx b/src/collections/components/CollectionProducts/CollectionProducts.tsx index 0c896c9e76a..8ff3a8b6428 100644 --- a/src/collections/components/CollectionProducts/CollectionProducts.tsx +++ b/src/collections/components/CollectionProducts/CollectionProducts.tsx @@ -12,7 +12,7 @@ import ActionDialog from "@dashboard/components/ActionDialog/ActionDialog"; import { Container } from "@dashboard/components/AssignContainerDialog"; import AssignProductDialog from "@dashboard/components/AssignProductDialog/AssignProductDialog"; import { DashboardCard } from "@dashboard/components/Card"; -import { DEFAULT_INITIAL_SEARCH_DATA } from "@dashboard/config"; +import { DEFAULT_INITIAL_SEARCH_DATA, PAGINATE_BY } from "@dashboard/config"; import { CollectionDetailsQuery, useCollectionAssignProductMutation, @@ -60,7 +60,7 @@ const CollectionProducts: React.FC = ({ const intl = useIntl(); const id = useCollectionId(); const { settings, updateListSettings } = useListSettings(ListViews.COLLECTION_PRODUCTS_LIST); - const numberOfRows = settings.rowNumber; + const numberOfRows = settings ? settings.rowNumber : PAGINATE_BY; const [paginationState, setPaginationState] = useLocalPaginationState(numberOfRows); const notify = useNotifier(); From 8a6527e512ad76013ae6558ee96cd00278f953b8 Mon Sep 17 00:00:00 2001 From: andrzejewsky Date: Thu, 30 Jan 2025 10:50:21 +0100 Subject: [PATCH 16/22] Cr --- .../CollectionProducts/CollectionProducts.tsx | 2 +- .../CollectionProducts/ProductTableItem.tsx | 2 +- .../CollectionProducts/ProductTableSkeleton.tsx | 4 ++-- .../CollectionProducts/keepProductOrder.ts | 12 ++++++++++++ .../useProductReorderOptimistic.ts | 6 +++--- 5 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/collections/components/CollectionProducts/CollectionProducts.tsx b/src/collections/components/CollectionProducts/CollectionProducts.tsx index 8ff3a8b6428..62447e8493d 100644 --- a/src/collections/components/CollectionProducts/CollectionProducts.tsx +++ b/src/collections/components/CollectionProducts/CollectionProducts.tsx @@ -200,7 +200,7 @@ const CollectionProducts: React.FC = ({ numberOfRows={numberOfRows} /> ) : ( - + )} ( ); -export const ProductTableSkeleton = ({ numberOfRows }: { numberOfRows: number }) => ( +export const ProductTableSkeleton = () => ( @@ -58,7 +58,7 @@ export const ProductTableSkeleton = ({ numberOfRows }: { numberOfRows: number }) - {Array.from({ length: numberOfRows }, (_, index) => ( + {Array.from({ length: 10 }, (_, index) => ( ))} diff --git a/src/collections/components/CollectionProducts/keepProductOrder.ts b/src/collections/components/CollectionProducts/keepProductOrder.ts index 10373f6dda6..5c671f69dce 100644 --- a/src/collections/components/CollectionProducts/keepProductOrder.ts +++ b/src/collections/components/CollectionProducts/keepProductOrder.ts @@ -7,6 +7,18 @@ export type CollectionProducts = NonNullable< /* The API needs to take items in the same order as they came, and the reversed one when we move items down (negative position). This function is designed to keep that order. + + Example: + list from the API: [1, 2, 3, 4, 5, 6] + we change (in order): + 5 to position 4 + 2 to position 0 + + [1, 2, 3, 4, 5, 6] + + The array we get for update is: [{ id: 5, sortOrder: 1 }, { id: 2, sortOrder: 1 }] + But instead we should send: [{ id: 2, sortOrder: 1 }, { id: 5, sortOrder: 1 }] + 2 is first because it came like this from the API */ export const keepProductOrder = (listElements: string[], products: CollectionProducts) => (position: number) => { diff --git a/src/collections/components/CollectionProducts/useProductReorderOptimistic.ts b/src/collections/components/CollectionProducts/useProductReorderOptimistic.ts index 0e5ef112970..0bed5bf6b2c 100644 --- a/src/collections/components/CollectionProducts/useProductReorderOptimistic.ts +++ b/src/collections/components/CollectionProducts/useProductReorderOptimistic.ts @@ -1,10 +1,10 @@ import { PaginationState } from "@dashboard/hooks/useLocalPaginator"; -import { Product } from "./types"; +import { Edges, Product } from "./types"; import { useCollectionId } from "./useCollectionId"; import { useProductEdges } from "./useProductEdges"; -const createOptimisticResponseForEdges = (collectionId: string, edges: any) => ({ +const createOptimisticResponseForEdges = (collectionId: string, edges: Edges) => ({ collectionReorderProducts: { __typename: "CollectionReorderProducts" as const, collection: { @@ -53,7 +53,7 @@ export const useProductReorderOptimistic = ({ paginationState }: ProductReorderO }; }); - return createOptimisticResponseForEdges(collectionId, exceededEdges); + return createOptimisticResponseForEdges(collectionId, exceededEdges as Edges); }; return { From 0b5eddef47ef670334a4a36400d019e15321e063 Mon Sep 17 00:00:00 2001 From: andrzejewsky Date: Thu, 30 Jan 2025 13:30:51 +0100 Subject: [PATCH 17/22] Empty image --- .../CollectionProducts/EmptyImage.tsx | 10 ++++++ .../CollectionProducts/ProductTableItem.tsx | 33 +++++++++++++------ .../CollectionProducts/ProductsTable.tsx | 4 +-- 3 files changed, 35 insertions(+), 12 deletions(-) create mode 100644 src/collections/components/CollectionProducts/EmptyImage.tsx diff --git a/src/collections/components/CollectionProducts/EmptyImage.tsx b/src/collections/components/CollectionProducts/EmptyImage.tsx new file mode 100644 index 00000000000..9730e8147a2 --- /dev/null +++ b/src/collections/components/CollectionProducts/EmptyImage.tsx @@ -0,0 +1,10 @@ +import { Box, useTheme } from "@saleor/macaw-ui-next"; +import React from "react"; + +export const EmptyImage = () => { + const { theme } = useTheme(); + + const bgColor = theme === "defaultLight" ? "hsla(210, 15%, 87%, 1)" : "hsla(210, 32%, 25%, 1)"; + + return ; +}; diff --git a/src/collections/components/CollectionProducts/ProductTableItem.tsx b/src/collections/components/CollectionProducts/ProductTableItem.tsx index 9c5a93f319c..9681be15215 100644 --- a/src/collections/components/CollectionProducts/ProductTableItem.tsx +++ b/src/collections/components/CollectionProducts/ProductTableItem.tsx @@ -4,9 +4,18 @@ import Drag from "@dashboard/icons/Drag"; import { productUrl } from "@dashboard/products/urls"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { Box, Button, Checkbox, Skeleton, Text, TrashBinIcon } from "@saleor/macaw-ui-next"; +import { + Box, + Button, + Checkbox, + Skeleton, + Text, + TrashBinIcon, + useTheme, +} from "@saleor/macaw-ui-next"; import React from "react"; +import { EmptyImage } from "./EmptyImage"; import { Product } from "./types"; interface ItemProps { @@ -78,15 +87,19 @@ export const ProductTableItem = ({ gap={2} height="100%" > - - - + {product?.thumbnail ? ( + + + + ) : ( + + )} {product?.name} diff --git a/src/collections/components/CollectionProducts/ProductsTable.tsx b/src/collections/components/CollectionProducts/ProductsTable.tsx index 6a1947e740e..869783f8a05 100644 --- a/src/collections/components/CollectionProducts/ProductsTable.tsx +++ b/src/collections/components/CollectionProducts/ProductsTable.tsx @@ -147,12 +147,12 @@ export const ProductsTable = ({ {renderCollection(items, product => { - const isSelected = product ? isChecked(product.id) : false; - if (!product) { return null; } + const isSelected = isChecked(product.id); + return ( Date: Thu, 30 Jan 2025 14:38:45 +0100 Subject: [PATCH 18/22] Lint --- .../components/CollectionProducts/EmptyImage.tsx | 1 + .../components/CollectionProducts/ProductTableItem.tsx | 10 +--------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/src/collections/components/CollectionProducts/EmptyImage.tsx b/src/collections/components/CollectionProducts/EmptyImage.tsx index 9730e8147a2..a4789456340 100644 --- a/src/collections/components/CollectionProducts/EmptyImage.tsx +++ b/src/collections/components/CollectionProducts/EmptyImage.tsx @@ -4,6 +4,7 @@ import React from "react"; export const EmptyImage = () => { const { theme } = useTheme(); + // Using these colors to match other grid-based lists const bgColor = theme === "defaultLight" ? "hsla(210, 15%, 87%, 1)" : "hsla(210, 32%, 25%, 1)"; return ; diff --git a/src/collections/components/CollectionProducts/ProductTableItem.tsx b/src/collections/components/CollectionProducts/ProductTableItem.tsx index 9681be15215..d7fb337fb58 100644 --- a/src/collections/components/CollectionProducts/ProductTableItem.tsx +++ b/src/collections/components/CollectionProducts/ProductTableItem.tsx @@ -4,15 +4,7 @@ import Drag from "@dashboard/icons/Drag"; import { productUrl } from "@dashboard/products/urls"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { - Box, - Button, - Checkbox, - Skeleton, - Text, - TrashBinIcon, - useTheme, -} from "@saleor/macaw-ui-next"; +import { Box, Button, Checkbox, Skeleton, Text, TrashBinIcon } from "@saleor/macaw-ui-next"; import React from "react"; import { EmptyImage } from "./EmptyImage"; From c2715439db29e04594be09d7066ed80b77b83868 Mon Sep 17 00:00:00 2001 From: andrzejewsky Date: Thu, 30 Jan 2025 16:51:39 +0100 Subject: [PATCH 19/22] Display pagination when there are no elements --- .../components/CollectionProducts/CollectionProducts.tsx | 2 ++ .../components/CollectionProducts/ProductsTable.tsx | 4 ---- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/collections/components/CollectionProducts/CollectionProducts.tsx b/src/collections/components/CollectionProducts/CollectionProducts.tsx index 62447e8493d..cdd9ac40680 100644 --- a/src/collections/components/CollectionProducts/CollectionProducts.tsx +++ b/src/collections/components/CollectionProducts/CollectionProducts.tsx @@ -33,6 +33,7 @@ import React from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { ListViews } from "../../../types"; +import { Pagination } from "./Pagination"; import { ProductsTable } from "./ProductsTable"; import { ProductTableSkeleton } from "./ProductTableSkeleton"; import { useCollectionId } from "./useCollectionId"; @@ -202,6 +203,7 @@ const CollectionProducts: React.FC = ({ ) : ( )} + { const allChecked = areAllChecked(products, selected); const { items, sensors, isSaving, handleDragEnd } = useProductDrag({ products, paginationState }); @@ -167,7 +164,6 @@ export const ProductsTable = ({ - ); }; From b3dd025c769e6bc1b77da8d18f2f0027bb1392b5 Mon Sep 17 00:00:00 2001 From: andrzejewsky Date: Fri, 31 Jan 2025 10:47:48 +0100 Subject: [PATCH 20/22] Add mutation workaround --- .../CollectionProducts/CollectionProducts.tsx | 9 ++++++- .../CollectionProducts/useProductReorder.ts | 2 +- src/collections/mutations.ts | 27 ++++++++++++++++--- src/collections/queries.ts | 2 +- src/graphql/hooks.generated.ts | 22 +++++++++------ src/graphql/types.generated.ts | 3 ++- 6 files changed, 49 insertions(+), 16 deletions(-) diff --git a/src/collections/components/CollectionProducts/CollectionProducts.tsx b/src/collections/components/CollectionProducts/CollectionProducts.tsx index cdd9ac40680..1333309683d 100644 --- a/src/collections/components/CollectionProducts/CollectionProducts.tsx +++ b/src/collections/components/CollectionProducts/CollectionProducts.tsx @@ -17,6 +17,7 @@ import { CollectionDetailsQuery, useCollectionAssignProductMutation, useCollectionProductsQuery, + useReorderProductsInCollectionMutation, useUnassignCollectionProductMutation, } from "@dashboard/graphql"; import useBulkActions from "@dashboard/hooks/useBulkActions"; @@ -65,6 +66,8 @@ const CollectionProducts: React.FC = ({ const [paginationState, setPaginationState] = useLocalPaginationState(numberOfRows); const notify = useNotifier(); + const [reorder, reorderData] = useReorderProductsInCollectionMutation(); + const [assignProduct, assignProductOpts] = useCollectionAssignProductMutation({ onCompleted: data => { if (data.collectionAddProducts?.errors.length === 0) { @@ -134,7 +137,11 @@ const CollectionProducts: React.FC = ({ if (productIds.length > 0) { await assignProduct({ - variables: { ...baseVariables, productIds }, + variables: { + ...baseVariables, + productIds, + moves: productIds.map(id => ({ productId: id, sortOrder: 0 })), + }, }); } diff --git a/src/collections/components/CollectionProducts/useProductReorder.ts b/src/collections/components/CollectionProducts/useProductReorder.ts index 1376cb0653d..133c2f80c5d 100644 --- a/src/collections/components/CollectionProducts/useProductReorder.ts +++ b/src/collections/components/CollectionProducts/useProductReorder.ts @@ -26,7 +26,7 @@ export const useProductReorder = ({ paginationState }: ProductReorderProps) => { moves: [ { productId, - sortOrder: shift, + sortOrder: -shift, }, ], ...paginationState, diff --git a/src/collections/mutations.ts b/src/collections/mutations.ts index 09ee4cc4c42..ba828c98b0b 100644 --- a/src/collections/mutations.ts +++ b/src/collections/mutations.ts @@ -13,16 +13,35 @@ export const collectionUpdate = gql` } `; +/* + The mutation below has two simultaneous mutations: + - collectionAddProducts, for adding products to the collection + - collectionReorderProducts, for resetting its reorder position + + The collectionReorderProducts is used here as a workaround due to issues on the API. + It does the reorder by moving product by 0, which sets the initial reorder position. + + Additionally, collectionAddProducts gets only the errors as requested field, + while collectionReorderProducts takes the desired response (products and collection id) - this is + intentional as we are interested only in the response from reorder mutation, and + only that will invalidate the apollo cache (because of request collection id presence, required for cache key) +*/ export const assignCollectionProduct = gql` mutation CollectionAssignProduct( $collectionId: ID! $productIds: [ID!]! + $moves: [MoveProductInput!]! $first: Int $after: String $last: Int $before: String ) { collectionAddProducts(collectionId: $collectionId, products: $productIds) { + errors { + ...CollectionError + } + } + collectionReorderProducts(collectionId: $collectionId, moves: $moves) { collection { id products( @@ -30,7 +49,7 @@ export const assignCollectionProduct = gql` after: $after before: $before last: $last - sortBy: { field: COLLECTION, direction: DESC } + sortBy: { field: COLLECTION, direction: ASC } ) { edges { node { @@ -46,7 +65,7 @@ export const assignCollectionProduct = gql` } } errors { - ...CollectionError + message } } } @@ -92,7 +111,7 @@ export const unassignCollectionProduct = gql` after: $after before: $before last: $last - sortBy: { field: COLLECTION, direction: DESC } + sortBy: { field: COLLECTION, direction: ASC } ) { edges { node { @@ -151,7 +170,7 @@ export const reorderProductsInCollection = gql` after: $after before: $before last: $last - sortBy: { field: COLLECTION, direction: DESC } + sortBy: { field: COLLECTION, direction: ASC } ) { edges { node { diff --git a/src/collections/queries.ts b/src/collections/queries.ts index d5bc13604aa..90e3f427ade 100644 --- a/src/collections/queries.ts +++ b/src/collections/queries.ts @@ -54,7 +54,7 @@ export const collectionProducts = gql` after: $after before: $before last: $last - sortBy: { field: COLLECTION, direction: DESC } + sortBy: { field: COLLECTION, direction: ASC } ) { edges { node { diff --git a/src/graphql/hooks.generated.ts b/src/graphql/hooks.generated.ts index d6956df139b..238c0d38f21 100644 --- a/src/graphql/hooks.generated.ts +++ b/src/graphql/hooks.generated.ts @@ -5420,8 +5420,13 @@ export type CollectionUpdateMutationHookResult = ReturnType; export type CollectionUpdateMutationOptions = Apollo.BaseMutationOptions; export const CollectionAssignProductDocument = gql` - mutation CollectionAssignProduct($collectionId: ID!, $productIds: [ID!]!, $first: Int, $after: String, $last: Int, $before: String) { + mutation CollectionAssignProduct($collectionId: ID!, $productIds: [ID!]!, $moves: [MoveProductInput!]!, $first: Int, $after: String, $last: Int, $before: String) { collectionAddProducts(collectionId: $collectionId, products: $productIds) { + errors { + ...CollectionError + } + } + collectionReorderProducts(collectionId: $collectionId, moves: $moves) { collection { id products( @@ -5429,7 +5434,7 @@ export const CollectionAssignProductDocument = gql` after: $after before: $before last: $last - sortBy: {field: COLLECTION, direction: DESC} + sortBy: {field: COLLECTION, direction: ASC} ) { edges { node { @@ -5445,12 +5450,12 @@ export const CollectionAssignProductDocument = gql` } } errors { - ...CollectionError + message } } } - ${CollectionProductFragmentDoc} -${CollectionErrorFragmentDoc}`; + ${CollectionErrorFragmentDoc} +${CollectionProductFragmentDoc}`; export type CollectionAssignProductMutationFn = Apollo.MutationFunction; /** @@ -5468,6 +5473,7 @@ export type CollectionAssignProductMutationFn = Apollo.MutationFunction | Scalars['ID']; + moves: Array | MoveProductInput; first?: InputMaybe; after?: InputMaybe; last?: InputMaybe; @@ -9307,7 +9308,7 @@ export type CollectionAssignProductMutationVariables = Exact<{ }>; -export type CollectionAssignProductMutation = { __typename: 'Mutation', collectionAddProducts: { __typename: 'CollectionAddProducts', collection: { __typename: 'Collection', id: string, products: { __typename: 'ProductCountableConnection', edges: Array<{ __typename: 'ProductCountableEdge', node: { __typename: 'Product', id: string, name: string, productType: { __typename: 'ProductType', id: string, name: string }, thumbnail: { __typename: 'Image', url: string } | null, channelListings: Array<{ __typename: 'ProductChannelListing', id: string, isPublished: boolean, publishedAt: any | null, isAvailableForPurchase: boolean | null, availableForPurchaseAt: any | null, visibleInListings: boolean, channel: { __typename: 'Channel', id: string, name: string, currencyCode: string } }> | null } }>, pageInfo: { __typename: 'PageInfo', endCursor: string | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor: string | null } } | null } | null, errors: Array<{ __typename: 'CollectionError', code: CollectionErrorCode, field: string | null, message: string | null }> } | null }; +export type CollectionAssignProductMutation = { __typename: 'Mutation', collectionAddProducts: { __typename: 'CollectionAddProducts', errors: Array<{ __typename: 'CollectionError', code: CollectionErrorCode, field: string | null, message: string | null }> } | null, collectionReorderProducts: { __typename: 'CollectionReorderProducts', collection: { __typename: 'Collection', id: string, products: { __typename: 'ProductCountableConnection', edges: Array<{ __typename: 'ProductCountableEdge', node: { __typename: 'Product', id: string, name: string, productType: { __typename: 'ProductType', id: string, name: string }, thumbnail: { __typename: 'Image', url: string } | null, channelListings: Array<{ __typename: 'ProductChannelListing', id: string, isPublished: boolean, publishedAt: any | null, isAvailableForPurchase: boolean | null, availableForPurchaseAt: any | null, visibleInListings: boolean, channel: { __typename: 'Channel', id: string, name: string, currencyCode: string } }> | null } }>, pageInfo: { __typename: 'PageInfo', endCursor: string | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor: string | null } } | null } | null, errors: Array<{ __typename: 'CollectionError', message: string | null }> } | null }; export type CreateCollectionMutationVariables = Exact<{ input: CollectionCreateInput; From da4ac08f62125b0bdf4e2135d02e4e04c08970bd Mon Sep 17 00:00:00 2001 From: andrzejewsky Date: Fri, 31 Jan 2025 10:57:19 +0100 Subject: [PATCH 21/22] Test fix --- .../components/CollectionProducts/useProductReorder.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/collections/components/CollectionProducts/useProductReorder.test.ts b/src/collections/components/CollectionProducts/useProductReorder.test.ts index 02d63032fab..8d1ea9cc163 100644 --- a/src/collections/components/CollectionProducts/useProductReorder.test.ts +++ b/src/collections/components/CollectionProducts/useProductReorder.test.ts @@ -77,7 +77,7 @@ describe("CollectionProducts/useProductReorder", () => { moves: [ { productId: "2", - sortOrder: 1, + sortOrder: -1, }, ], }, From 77330620a02615e7e4903164bf840dd6a8e055bd Mon Sep 17 00:00:00 2001 From: andrzejewsky Date: Fri, 31 Jan 2025 11:01:29 +0100 Subject: [PATCH 22/22] Test fix --- .../components/CollectionProducts/CollectionProducts.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/collections/components/CollectionProducts/CollectionProducts.tsx b/src/collections/components/CollectionProducts/CollectionProducts.tsx index 1333309683d..12a716dcbff 100644 --- a/src/collections/components/CollectionProducts/CollectionProducts.tsx +++ b/src/collections/components/CollectionProducts/CollectionProducts.tsx @@ -17,7 +17,6 @@ import { CollectionDetailsQuery, useCollectionAssignProductMutation, useCollectionProductsQuery, - useReorderProductsInCollectionMutation, useUnassignCollectionProductMutation, } from "@dashboard/graphql"; import useBulkActions from "@dashboard/hooks/useBulkActions"; @@ -66,8 +65,6 @@ const CollectionProducts: React.FC = ({ const [paginationState, setPaginationState] = useLocalPaginationState(numberOfRows); const notify = useNotifier(); - const [reorder, reorderData] = useReorderProductsInCollectionMutation(); - const [assignProduct, assignProductOpts] = useCollectionAssignProductMutation({ onCompleted: data => { if (data.collectionAddProducts?.errors.length === 0) {