From 4bf40ed4103b322d78ad1521cd05c12d9d4723cd Mon Sep 17 00:00:00 2001 From: Dawid Tarasiuk Date: Mon, 10 May 2021 15:05:14 +0200 Subject: [PATCH 1/3] Fix line on product page (#1098) --- .../ProductDetailsForm/ProductDetailsForm.tsx | 1 + .../__snapshots__/Stories.test.ts.snap | 45 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/products/components/ProductDetailsForm/ProductDetailsForm.tsx b/src/products/components/ProductDetailsForm/ProductDetailsForm.tsx index 0b56cca56..5b6af5fe8 100644 --- a/src/products/components/ProductDetailsForm/ProductDetailsForm.tsx +++ b/src/products/components/ProductDetailsForm/ProductDetailsForm.tsx @@ -68,6 +68,7 @@ export const ProductDetailsForm: React.FC = ({ name="description" onChange={onDescriptionChange} /> +
diff --git a/src/storybook/__snapshots__/Stories.test.ts.snap b/src/storybook/__snapshots__/Stories.test.ts.snap index 97cd099b9..14a3d3dde 100644 --- a/src/storybook/__snapshots__/Stories.test.ts.snap +++ b/src/storybook/__snapshots__/Stories.test.ts.snap @@ -174153,6 +174153,9 @@ exports[`Storyshots Views / Products / Create product When loading 1`] = ` class="MuiFormHelperText-root-id MuiFormHelperText-contained-id MuiFormHelperText-disabled-id" /> +

@@ -174858,6 +174861,9 @@ exports[`Storyshots Views / Products / Create product default 1`] = ` class="MuiFormHelperText-root-id MuiFormHelperText-contained-id" />
+

@@ -175561,6 +175567,9 @@ exports[`Storyshots Views / Products / Create product form errors 1`] = ` class="MuiFormHelperText-root-id MuiFormHelperText-contained-id" />
+

@@ -180884,6 +180893,9 @@ exports[`Storyshots Views / Products / Product edit form errors 1`] = ` class="MuiFormHelperText-root-id MuiFormHelperText-contained-id" />
+

@@ -182619,6 +182631,9 @@ exports[`Storyshots Views / Products / Product edit limits reached 1`] = ` class="MuiFormHelperText-root-id MuiFormHelperText-contained-id" />
+

@@ -184390,6 +184405,9 @@ exports[`Storyshots Views / Products / Product edit no limits 1`] = ` class="MuiFormHelperText-root-id MuiFormHelperText-contained-id" />
+

@@ -186115,6 +186133,9 @@ exports[`Storyshots Views / Products / Product edit no product attributes 1`] = class="MuiFormHelperText-root-id MuiFormHelperText-contained-id" />
+

@@ -187588,6 +187609,9 @@ exports[`Storyshots Views / Products / Product edit no stock and no variants 1`] class="MuiFormHelperText-root-id MuiFormHelperText-contained-id" />
+

@@ -189227,6 +189251,9 @@ exports[`Storyshots Views / Products / Product edit no stock, no variants and no class="MuiFormHelperText-root-id MuiFormHelperText-contained-id" />
+

@@ -190786,6 +190813,9 @@ exports[`Storyshots Views / Products / Product edit no variants 1`] = ` class="MuiFormHelperText-root-id MuiFormHelperText-contained-id" />
+

@@ -192567,6 +192597,9 @@ exports[`Storyshots Views / Products / Product edit when data is fully loaded 1` class="MuiFormHelperText-root-id MuiFormHelperText-contained-id" />
+

@@ -194298,6 +194331,9 @@ exports[`Storyshots Views / Products / Product edit when loading data 1`] = ` class="MuiFormHelperText-root-id MuiFormHelperText-contained-id MuiFormHelperText-disabled-id" />
+

@@ -195293,6 +195329,9 @@ exports[`Storyshots Views / Products / Product edit when product has no images 1 class="MuiFormHelperText-root-id MuiFormHelperText-contained-id" />
+

@@ -196973,6 +197012,9 @@ exports[`Storyshots Views / Products / Product edit when product has no variants class="MuiFormHelperText-root-id MuiFormHelperText-contained-id" />
+

@@ -198754,6 +198796,9 @@ exports[`Storyshots Views / Products / Product edit with channels 1`] = ` class="MuiFormHelperText-root-id MuiFormHelperText-contained-id" />
+

From bb441ea11a932c3df94ed67ef47be622ddcd6da3 Mon Sep 17 00:00:00 2001 From: mmarkusik Date: Tue, 11 May 2021 14:26:17 +0200 Subject: [PATCH 2/3] Add product / page delete warning (#1095) * Add Delete button component * Add product / page type delete warning dialog * Replace old product types delete dialog with new one, add products total count query * Update schema, types and queries for pages, add use page count query and add warning delete dialog to page types * Move type delete warning dialog data to proper hooks, refactor * Remove unused components and stories * Add plural forms to messages for product / page type delete warning, refactor * Add type delete warning dialog stories * Move type delete hooks to proper directiories, fix imports * Fix imports * Remove countallproducts query and instead use useproductcountquery * Remove unnecessary types and imports --- schema.graphql | 1 + .../ChannelsAvailabilityCardWrapper.tsx | 2 +- src/components/DeleteButton/DeleteButton.tsx | 47 +++++++ src/components/DeleteButton/index.tsx | 2 + .../TypeDeleteWarningDialog.stories.tsx | 40 ++++++ .../TypeDeleteWarningDialog.tsx | 132 ++++++++++++++++++ .../TypeDeleteWarningDialogContent.tsx | 95 +++++++++++++ .../TypeDeleteWarningDialog/index.tsx | 2 + .../TypeDeleteWarningDialog/styles.ts | 23 +++ .../TypeDeleteWarningDialog/types.ts | 10 ++ .../OrderDiscountCommonModal/ModalTitle.tsx | 25 +++- .../PageTypeDeleteDialog.tsx | 61 -------- .../components/PageTypeDeleteDialog/index.ts | 2 - .../hooks/usePageTypeDelete/index.ts | 2 + .../hooks/usePageTypeDelete/messages.ts | 59 ++++++++ .../hooks/usePageTypeDelete/types.ts | 16 +++ .../usePageTypeDelete/usePageTypeDelete.tsx | 64 +++++++++ src/pageTypes/views/PageTypeDetails.tsx | 99 +++++++------ .../views/PageTypeList/PageTypeList.tsx | 40 +++--- .../views/PageTypeUpdate/PageTypeUpdate.tsx | 0 src/pages/queries.ts | 13 ++ src/pages/types/PageCount.ts | 23 +++ src/pages/urls.ts | 18 ++- .../ProductTypeDeleteDialog.tsx | 48 ------- .../ProductTypeDeleteDialog/index.ts | 2 - .../hooks/useProductTypeDelete/index.ts | 2 + .../hooks/useProductTypeDelete/messages.ts | 60 ++++++++ .../useProductTypeDelete.tsx | 68 +++++++++ .../views/ProductTypeList/ProductTypeList.tsx | 63 ++++----- .../views/ProductTypeUpdate/index.tsx | 121 ++++++++-------- src/products/queries.ts | 16 ++- .../{CountAllProducts.ts => ProductCount.ts} | 14 +- .../views/ProductList/ProductList.tsx | 4 +- src/storybook/config.js | 1 - .../productTypes/ProductTypeDeleteDialog.tsx | 19 --- src/types.ts | 2 + src/types/globalTypes.ts | 6 + 37 files changed, 900 insertions(+), 302 deletions(-) create mode 100644 src/components/DeleteButton/DeleteButton.tsx create mode 100644 src/components/DeleteButton/index.tsx create mode 100644 src/components/TypeDeleteWarningDialog/TypeDeleteWarningDialog.stories.tsx create mode 100644 src/components/TypeDeleteWarningDialog/TypeDeleteWarningDialog.tsx create mode 100644 src/components/TypeDeleteWarningDialog/TypeDeleteWarningDialogContent.tsx create mode 100644 src/components/TypeDeleteWarningDialog/index.tsx create mode 100644 src/components/TypeDeleteWarningDialog/styles.ts create mode 100644 src/components/TypeDeleteWarningDialog/types.ts delete mode 100644 src/pageTypes/components/PageTypeDeleteDialog/PageTypeDeleteDialog.tsx delete mode 100644 src/pageTypes/components/PageTypeDeleteDialog/index.ts create mode 100644 src/pageTypes/hooks/usePageTypeDelete/index.ts create mode 100644 src/pageTypes/hooks/usePageTypeDelete/messages.ts create mode 100644 src/pageTypes/hooks/usePageTypeDelete/types.ts create mode 100644 src/pageTypes/hooks/usePageTypeDelete/usePageTypeDelete.tsx delete mode 100644 src/pageTypes/views/PageTypeUpdate/PageTypeUpdate.tsx create mode 100644 src/pages/types/PageCount.ts delete mode 100644 src/productTypes/components/ProductTypeDeleteDialog/ProductTypeDeleteDialog.tsx delete mode 100644 src/productTypes/components/ProductTypeDeleteDialog/index.ts create mode 100644 src/productTypes/hooks/useProductTypeDelete/index.ts create mode 100644 src/productTypes/hooks/useProductTypeDelete/messages.ts create mode 100644 src/productTypes/hooks/useProductTypeDelete/useProductTypeDelete.tsx rename src/products/types/{CountAllProducts.ts => ProductCount.ts} (50%) delete mode 100644 src/storybook/stories/productTypes/ProductTypeDeleteDialog.tsx diff --git a/schema.graphql b/schema.graphql index 928c5a8c6..5943a4045 100644 --- a/schema.graphql +++ b/schema.graphql @@ -3579,6 +3579,7 @@ enum PageErrorCode { } input PageFilterInput { + pageTypes: [ID!] search: String metadata: [MetadataInput] } diff --git a/src/components/ChannelsAvailabilityCard/ChannelsAvailabilityCardWrapper.tsx b/src/components/ChannelsAvailabilityCard/ChannelsAvailabilityCardWrapper.tsx index 1436d598e..fdd84e410 100644 --- a/src/components/ChannelsAvailabilityCard/ChannelsAvailabilityCardWrapper.tsx +++ b/src/components/ChannelsAvailabilityCard/ChannelsAvailabilityCardWrapper.tsx @@ -53,7 +53,7 @@ export const ChannelsAvailabilityWrapper: React.FC + ); +}; + +export default DeleteButton; diff --git a/src/components/DeleteButton/index.tsx b/src/components/DeleteButton/index.tsx new file mode 100644 index 000000000..185d2bc63 --- /dev/null +++ b/src/components/DeleteButton/index.tsx @@ -0,0 +1,2 @@ +export * from "./DeleteButton"; +export { default } from "./DeleteButton"; diff --git a/src/components/TypeDeleteWarningDialog/TypeDeleteWarningDialog.stories.tsx b/src/components/TypeDeleteWarningDialog/TypeDeleteWarningDialog.stories.tsx new file mode 100644 index 000000000..129a9fa17 --- /dev/null +++ b/src/components/TypeDeleteWarningDialog/TypeDeleteWarningDialog.stories.tsx @@ -0,0 +1,40 @@ +import CentralPlacementDecorator from "@saleor/storybook/CentralPlacementDecorator"; +import CommonDecorator from "@saleor/storybook/Decorator"; +import { storiesOf } from "@storybook/react"; +import React from "react"; + +import * as messages from "../../pageTypes/hooks/usePageTypeDelete/messages"; +import TypeDeleteWarningDialog, { + TypeBaseData, + TypeDeleteWarningDialogProps +} from "./TypeDeleteWarningDialog"; + +const props: TypeDeleteWarningDialogProps = { + ...messages, + isOpen: true, + onClose: () => undefined, + onDelete: () => undefined, + typesData: [{ id: "id-1", name: "Interesting Pages" }], + isLoading: false, + assignedItemsCount: 4, + typesToDelete: ["id-1"], + viewAssignedItemsUrl: "some-url", + deleteButtonState: "default" +}; + +storiesOf("TypeDeleteWarningDialog.stories", module) + .addDecorator(CommonDecorator) + .addDecorator(CentralPlacementDecorator) + .add("loading", () => ) + .add("single type no assigned items", () => ( + + )) + .add("single type some assigned items", () => ( + + )) + .add("multiple type no assigned items", () => ( + + )) + .add("multiple types some assigned items", () => ( + + )); diff --git a/src/components/TypeDeleteWarningDialog/TypeDeleteWarningDialog.tsx b/src/components/TypeDeleteWarningDialog/TypeDeleteWarningDialog.tsx new file mode 100644 index 000000000..59b582e0c --- /dev/null +++ b/src/components/TypeDeleteWarningDialog/TypeDeleteWarningDialog.tsx @@ -0,0 +1,132 @@ +import { CardContent } from "@material-ui/core"; +import Card from "@material-ui/core/Card"; +import CircularProgress from "@material-ui/core/CircularProgress"; +import Modal from "@material-ui/core/Modal"; +import { ConfirmButtonTransitionState } from "@saleor/components/ConfirmButton"; +import ModalTitle from "@saleor/orders/components/OrderDiscountCommonModal/ModalTitle"; +import { getById } from "@saleor/orders/components/OrderReturnPage/utils"; +import React from "react"; +import { useIntl } from "react-intl"; + +import { useTypeDeleteWarningDialogStyles as useStyles } from "./styles"; +import ProductTypeDeleteWarningDialogContent from "./TypeDeleteWarningDialogContent"; +import { + CommonTypeDeleteWarningMessages, + TypeDeleteWarningMessages +} from "./types"; + +export interface TypeBaseData { + id: string; + name: string; +} + +export interface TypeDeleteMessages { + baseMessages: CommonTypeDeleteWarningMessages; + singleWithItemsMessages: TypeDeleteWarningMessages; + singleWithoutItemsMessages: TypeDeleteWarningMessages; + multipleWithItemsMessages: TypeDeleteWarningMessages; + multipleWithoutItemsMessages: TypeDeleteWarningMessages; +} + +export interface TypeDeleteWarningDialogProps + extends TypeDeleteMessages { + isOpen: boolean; + deleteButtonState: ConfirmButtonTransitionState; + onClose: () => void; + onDelete: () => void; + viewAssignedItemsUrl: string; + typesToDelete: string[]; + assignedItemsCount: number | undefined; + isLoading?: boolean; + typesData: T[]; + // temporary, until we add filters to pages list - SALEOR-3279 + showViewAssignedItemsButton?: boolean; +} + +function TypeDeleteWarningDialog({ + isLoading = false, + isOpen, + baseMessages, + singleWithItemsMessages, + singleWithoutItemsMessages, + multipleWithItemsMessages, + multipleWithoutItemsMessages, + onClose, + onDelete, + assignedItemsCount, + viewAssignedItemsUrl, + typesToDelete, + typesData, + showViewAssignedItemsButton = true +}: TypeDeleteWarningDialogProps) { + const intl = useIntl(); + const classes = useStyles({}); + + const showMultiple = typesToDelete.length > 1; + + const hasAssignedItems = !!assignedItemsCount; + + const selectMessages = () => { + if (showMultiple) { + const multipleMessages = hasAssignedItems + ? multipleWithItemsMessages + : multipleWithoutItemsMessages; + + return { + ...multipleMessages + }; + } + + const singleMessages = hasAssignedItems + ? singleWithItemsMessages + : singleWithoutItemsMessages; + + return { + ...singleMessages + }; + }; + + const { description, consentLabel } = selectMessages(); + + const singleItemSelectedId = typesToDelete[0]; + + const singleItemSelectedName = typesData.find(getById(singleItemSelectedId)) + ?.name; + + return ( + +
+ + + {isLoading ? ( + + + + ) : ( + + )} + +
+
+ ); +} + +export default TypeDeleteWarningDialog; diff --git a/src/components/TypeDeleteWarningDialog/TypeDeleteWarningDialogContent.tsx b/src/components/TypeDeleteWarningDialog/TypeDeleteWarningDialogContent.tsx new file mode 100644 index 000000000..6c2b19a6c --- /dev/null +++ b/src/components/TypeDeleteWarningDialog/TypeDeleteWarningDialogContent.tsx @@ -0,0 +1,95 @@ +import CardContent from "@material-ui/core/CardContent"; +import Typography from "@material-ui/core/Typography"; +import HorizontalSpacer from "@saleor/apps/components/HorizontalSpacer"; +import CardSpacer from "@saleor/components/CardSpacer"; +import ConfirmButton from "@saleor/components/ConfirmButton"; +import ControlledCheckbox from "@saleor/components/ControlledCheckbox"; +import DeleteButton from "@saleor/components/DeleteButton"; +import useNavigator from "@saleor/hooks/useNavigator"; +import React, { ChangeEvent, useState } from "react"; +import { MessageDescriptor, useIntl } from "react-intl"; + +import { useTypeDeleteWarningDialogStyles as useStyles } from "./styles"; + +interface TypeDeleteWarningDialogContentProps { + singleItemSelectedName?: string; + viewAssignedItemsButtonLabel: MessageDescriptor; + description: MessageDescriptor; + consentLabel: MessageDescriptor; + viewAssignedItemsUrl: string; + hasAssignedItems: boolean; + assignedItemsCount: number | undefined; + onDelete: () => void; + // temporary, until we add filters to pages list - SALEOR-3279 + showViewAssignedItemsButton?: boolean; +} + +const TypeDeleteWarningDialogContent: React.FC = ({ + description, + consentLabel, + viewAssignedItemsUrl, + viewAssignedItemsButtonLabel, + singleItemSelectedName, + hasAssignedItems, + assignedItemsCount, + onDelete, + showViewAssignedItemsButton +}) => { + const classes = useStyles({}); + const intl = useIntl(); + const navigate = useNavigator(); + + const [isConsentChecked, setIsConsentChecked] = useState(false); + + const handleConsentChange = ({ target }: ChangeEvent) => + setIsConsentChecked(target.value); + + const handleViewAssignedItems = () => navigate(viewAssignedItemsUrl); + + const isDisbled = hasAssignedItems ? !isConsentChecked : false; + + const shouldShowViewAssignedItemsButton = + showViewAssignedItemsButton && hasAssignedItems; + + return ( + + + {intl.formatMessage(description, { + typeName: singleItemSelectedName, + assignedItemsCount, + b: (...chunks) => {chunks} + })} + + + {consentLabel && ( + + {intl.formatMessage(consentLabel)} + + } + /> + )} + +
+ {shouldShowViewAssignedItemsButton && ( + <> + + {intl.formatMessage(viewAssignedItemsButtonLabel)} + + + + )} + +
+
+ ); +}; + +export default TypeDeleteWarningDialogContent; diff --git a/src/components/TypeDeleteWarningDialog/index.tsx b/src/components/TypeDeleteWarningDialog/index.tsx new file mode 100644 index 000000000..73ce09808 --- /dev/null +++ b/src/components/TypeDeleteWarningDialog/index.tsx @@ -0,0 +1,2 @@ +export * from "./TypeDeleteWarningDialog"; +export { default } from "./TypeDeleteWarningDialog"; diff --git a/src/components/TypeDeleteWarningDialog/styles.ts b/src/components/TypeDeleteWarningDialog/styles.ts new file mode 100644 index 000000000..0e5e83c14 --- /dev/null +++ b/src/components/TypeDeleteWarningDialog/styles.ts @@ -0,0 +1,23 @@ +import { makeStyles } from "@saleor/theme"; + +export const useTypeDeleteWarningDialogStyles = makeStyles( + theme => ({ + centerContainer: { + display: "flex", + alignItems: "center", + justifyContent: "center", + height: "100%" + }, + content: { + width: 600 + }, + consentLabel: { + color: theme.palette.primary.main + }, + buttonsSection: { + display: "flex", + justifyContent: "flex-end" + } + }), + { name: "ProductTypeDeleteWarningDialog" } +); diff --git a/src/components/TypeDeleteWarningDialog/types.ts b/src/components/TypeDeleteWarningDialog/types.ts new file mode 100644 index 000000000..d5c8cce8b --- /dev/null +++ b/src/components/TypeDeleteWarningDialog/types.ts @@ -0,0 +1,10 @@ +import { MessageDescriptor } from "react-intl"; + +export type CommonTypeDeleteWarningMessages = Record< + "title" | "viewAssignedItemsButtonLabel", + MessageDescriptor +>; + +export type TypeDeleteWarningMessages = Partial< + Record<"description" | "consentLabel", MessageDescriptor> +>; diff --git a/src/orders/components/OrderDiscountCommonModal/ModalTitle.tsx b/src/orders/components/OrderDiscountCommonModal/ModalTitle.tsx index 99da31e29..cec6f6f6d 100644 --- a/src/orders/components/OrderDiscountCommonModal/ModalTitle.tsx +++ b/src/orders/components/OrderDiscountCommonModal/ModalTitle.tsx @@ -1,5 +1,7 @@ +import Divider from "@material-ui/core/Divider"; import Typography from "@material-ui/core/Typography"; import CloseIcon from "@material-ui/icons/Close"; +import CardSpacer from "@saleor/components/CardSpacer"; import { makeStyles } from "@saleor/theme"; import React from "react"; @@ -19,16 +21,29 @@ const useStyles = makeStyles( interface ModalTitleProps { title: string; onClose: () => void; + withBorder?: boolean; } -const ModalTitle: React.FC = ({ title, onClose }) => { +const ModalTitle: React.FC = ({ + title, + onClose, + withBorder = false +}) => { const classes = useStyles({}); return ( -
- {title} - -
+ <> +
+ {title} + +
+ {withBorder && ( + <> + + + + )} + ); }; diff --git a/src/pageTypes/components/PageTypeDeleteDialog/PageTypeDeleteDialog.tsx b/src/pageTypes/components/PageTypeDeleteDialog/PageTypeDeleteDialog.tsx deleted file mode 100644 index 8cef40620..000000000 --- a/src/pageTypes/components/PageTypeDeleteDialog/PageTypeDeleteDialog.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import DialogContentText from "@material-ui/core/DialogContentText"; -import ActionDialog from "@saleor/components/ActionDialog"; -import { ConfirmButtonTransitionState } from "@saleor/components/ConfirmButton"; -import React from "react"; -import { FormattedMessage, useIntl } from "react-intl"; - -export interface PageTypeDeleteDialogProps { - confirmButtonState: ConfirmButtonTransitionState; - open: boolean; - name: string; - hasPages: boolean; - onClose: () => void; - onConfirm: () => void; -} - -const PageTypeDeleteDialog: React.FC = ({ - confirmButtonState, - open, - name, - hasPages, - onClose, - onConfirm -}) => { - const intl = useIntl(); - - return ( - - - {hasPages ? ( - {name} - }} - /> - ) : ( - {name} - }} - /> - )} - - - ); -}; -PageTypeDeleteDialog.displayName = "PageTypeDeleteDialog"; -export default PageTypeDeleteDialog; diff --git a/src/pageTypes/components/PageTypeDeleteDialog/index.ts b/src/pageTypes/components/PageTypeDeleteDialog/index.ts deleted file mode 100644 index 24fe8502b..000000000 --- a/src/pageTypes/components/PageTypeDeleteDialog/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from "./PageTypeDeleteDialog"; -export * from "./PageTypeDeleteDialog"; diff --git a/src/pageTypes/hooks/usePageTypeDelete/index.ts b/src/pageTypes/hooks/usePageTypeDelete/index.ts new file mode 100644 index 000000000..ecb78f329 --- /dev/null +++ b/src/pageTypes/hooks/usePageTypeDelete/index.ts @@ -0,0 +1,2 @@ +export * from "./usePageTypeDelete"; +export { default } from "./usePageTypeDelete"; diff --git a/src/pageTypes/hooks/usePageTypeDelete/messages.ts b/src/pageTypes/hooks/usePageTypeDelete/messages.ts new file mode 100644 index 000000000..46de4491d --- /dev/null +++ b/src/pageTypes/hooks/usePageTypeDelete/messages.ts @@ -0,0 +1,59 @@ +import { defineMessages } from "react-intl"; + +export const baseMessages = defineMessages({ + title: { + defaultMessage: + "Delete page {selectedTypesCount,plural,one{type} other{types}}", + description: "ProductTypeDeleteWarningDialog title" + }, + viewAssignedItemsButtonLabel: { + defaultMessage: "View pages", + description: + "ProductTypeDeleteWarningDialog single assigned items button label" + } +}); + +export const singleWithItemsMessages = defineMessages({ + description: { + defaultMessage: + "You are about to delete page type {typeName}. It is assigned to {assignedItemsCount} {assignedItemsCount,plural,one{page} other{pages}}. Deleting this page type will also delete those pages. Are you sure you want to do this?", + description: + "ProductTypeDeleteWarningDialog single assigned items description" + }, + consentLabel: { + defaultMessage: "Yes, I want to delete this page type and assigned pages", + description: "ProductTypeDeleteWarningDialog single consent label" + } +}); + +export const multipleWithItemsMessages = defineMessages({ + description: { + defaultMessage: + "You are about to delete multiple page types. Some of them are assigned to pages. Deleting those page types will also delete those pages", + description: + "ProductTypeDeleteWarningDialog with items multiple description" + }, + consentLabel: { + defaultMessage: + "Yes, I want to delete those pages types and assigned pages", + description: "ProductTypeDeleteWarningDialog multiple consent label" + } +}); + +export const singleWithoutItemsMessages = defineMessages({ + description: { + defaultMessage: + "Are you sure you want to delete {typeName}? If you remove it you won’t be able to assign it to created pages.", + description: + "ProductTypeDeleteWarningDialog single assigned items description" + } +}); + +export const multipleWithoutItemsMessages = defineMessages({ + description: { + defaultMessage: + "Are you sure you want to delete selected page types? If you remove them you won’t be able to assign them to created pages.", + description: + "ProductTypeDeleteWarningDialog single assigned items description" + } +}); diff --git a/src/pageTypes/hooks/usePageTypeDelete/types.ts b/src/pageTypes/hooks/usePageTypeDelete/types.ts new file mode 100644 index 000000000..a89455cd4 --- /dev/null +++ b/src/pageTypes/hooks/usePageTypeDelete/types.ts @@ -0,0 +1,16 @@ +import { TypeDeleteMessages } from "@saleor/components/TypeDeleteWarningDialog"; +import { Ids } from "@saleor/types"; + +export interface UseTypeDeleteData extends TypeDeleteMessages { + isOpen: boolean; + assignedItemsCount: number | undefined; + viewAssignedItemsUrl: string; + isLoading: boolean | undefined; + typesToDelete: Ids; +} + +export interface UseTypeDeleteProps { + params: T; + selectedTypes?: Ids; + singleId?: string; +} diff --git a/src/pageTypes/hooks/usePageTypeDelete/usePageTypeDelete.tsx b/src/pageTypes/hooks/usePageTypeDelete/usePageTypeDelete.tsx new file mode 100644 index 000000000..9849aa51b --- /dev/null +++ b/src/pageTypes/hooks/usePageTypeDelete/usePageTypeDelete.tsx @@ -0,0 +1,64 @@ +import { usePageCountQuery } from "@saleor/pages/queries"; +import { PageCountVariables } from "@saleor/pages/types/PageCount"; +import { pageListUrl } from "@saleor/pages/urls"; +import { + PageTypeListUrlQueryParams, + PageTypeUrlQueryParams +} from "@saleor/pageTypes/urls"; +import React from "react"; + +import * as messages from "./messages"; +import { UseTypeDeleteData, UseTypeDeleteProps } from "./types"; + +type UsePageTypeDeleteProps< + T = PageTypeListUrlQueryParams | PageTypeUrlQueryParams +> = UseTypeDeleteProps; + +function usePageTypeDelete({ + singleId, + params, + selectedTypes +}: UsePageTypeDeleteProps): UseTypeDeleteData { + const pageTypes = selectedTypes || [singleId]; + + const isDeleteDialogOpen = params.action === "remove"; + + const pagesAssignedToSelectedTypesQueryVars = React.useMemo< + PageCountVariables + >( + () => ({ + filter: { + pageTypes + } + }), + [pageTypes] + ); + + const shouldSkipPageListQuery = !pageTypes.length || !isDeleteDialogOpen; + + const { + data: pagesAssignedToSelectedTypesData, + loading: loadingPagesAssignedToSelectedTypes + } = usePageCountQuery({ + variables: pagesAssignedToSelectedTypesQueryVars, + skip: shouldSkipPageListQuery + }); + + const selectedPagesAssignedToDeleteUrl = pageListUrl({ + pageTypes + }); + + const assignedItemsCount = + pagesAssignedToSelectedTypesData?.pages?.totalCount; + + return { + ...messages, + isOpen: isDeleteDialogOpen, + assignedItemsCount, + viewAssignedItemsUrl: selectedPagesAssignedToDeleteUrl, + isLoading: loadingPagesAssignedToSelectedTypes, + typesToDelete: pageTypes + }; +} + +export default usePageTypeDelete; diff --git a/src/pageTypes/views/PageTypeDetails.tsx b/src/pageTypes/views/PageTypeDetails.tsx index 7d2c0c32d..1574dc353 100644 --- a/src/pageTypes/views/PageTypeDetails.tsx +++ b/src/pageTypes/views/PageTypeDetails.tsx @@ -4,6 +4,7 @@ import AssignAttributeDialog from "@saleor/components/AssignAttributeDialog"; import AttributeUnassignDialog from "@saleor/components/AttributeUnassignDialog"; import BulkAttributeUnassignDialog from "@saleor/components/BulkAttributeUnassignDialog"; import NotFoundPage from "@saleor/components/NotFoundPage"; +import TypeDeleteWarningDialog from "@saleor/components/TypeDeleteWarningDialog"; import { WindowTitle } from "@saleor/components/WindowTitle"; import { DEFAULT_INITIAL_SEARCH_DATA } from "@saleor/config"; import useBulkActions from "@saleor/hooks/useBulkActions"; @@ -11,7 +12,6 @@ import useNavigator from "@saleor/hooks/useNavigator"; import useNotifier from "@saleor/hooks/useNotifier"; import { commonMessages } from "@saleor/intl"; import { getStringOrPlaceholder } from "@saleor/misc"; -import PageTypeDeleteDialog from "@saleor/pageTypes/components/PageTypeDeleteDialog"; import { useAssignPageAttributeMutation, usePageTypeAttributeReorderMutation, @@ -33,6 +33,7 @@ import PageTypeDetailsPage, { PageTypeForm } from "../components/PageTypeDetailsPage"; import useAvailablePageAttributeSearch from "../hooks/useAvailablePageAttributeSearch"; +import usePageTypeDelete from "../hooks/usePageTypeDelete"; import { usePageTypeDetailsQuery } from "../queries"; import { pageTypeListUrl, pageTypeUrl, PageTypeUrlQueryParams } from "../urls"; @@ -184,6 +185,11 @@ export const PageTypeDetails: React.FC = ({ const loading = updatePageTypeOpts.loading || dataLoading; + const pageTypeDeleteData = usePageTypeDelete({ + singleId: id, + params + }); + return ( <> @@ -245,50 +251,55 @@ export const PageTypeDetails: React.FC = ({ ) }} /> - navigate(pageTypeUrl(id))} - onConfirm={handlePageTypeDelete} - /> + {!dataLoading && ( - edge.node - )} - confirmButtonState={assignAttributeOpts.status} - errors={ - assignAttributeOpts.data?.pageAttributeAssign.errors - ? assignAttributeOpts.data.pageAttributeAssign.errors.map(err => - getPageErrorMessage(err, intl) - ) - : [] - } - loading={result.loading} - onClose={closeModal} - onSubmit={handleAssignAttribute} - onFetch={search} - onFetchMore={loadMore} - onOpen={result.refetch} - hasMore={ - !!result.data?.pageType.availableAttributes.pageInfo.hasNextPage - } - open={params.action === "assign-attribute"} - selected={params.ids || []} - onToggle={attributeId => { - const ids = params.ids || []; - navigate( - pageTypeUrl(id, { - ...params, - ids: ids.includes(attributeId) - ? params.ids.filter(selectedId => selectedId !== attributeId) - : [...ids, attributeId] - }) - ); - }} - /> + <> + + edge.node + )} + confirmButtonState={assignAttributeOpts.status} + errors={ + assignAttributeOpts.data?.pageAttributeAssign.errors + ? assignAttributeOpts.data.pageAttributeAssign.errors.map(err => + getPageErrorMessage(err, intl) + ) + : [] + } + loading={result.loading} + onClose={closeModal} + onSubmit={handleAssignAttribute} + onFetch={search} + onFetchMore={loadMore} + onOpen={result.refetch} + hasMore={ + !!result.data?.pageType.availableAttributes.pageInfo.hasNextPage + } + open={params.action === "assign-attribute"} + selected={params.ids || []} + onToggle={attributeId => { + const ids = params.ids || []; + navigate( + pageTypeUrl(id, { + ...params, + ids: ids.includes(attributeId) + ? params.ids.filter( + selectedId => selectedId !== attributeId + ) + : [...ids, attributeId] + }) + ); + }} + /> + )} = ({ params }) => { const navigate = useNavigator(); const paginate = usePaginator(); const notify = useNotifier(); - const { isSelected, listElements, reset, toggle, toggleAll } = useBulkActions( - params.ids - ); + const { + isSelected, + listElements: selectedPageTypes, + reset, + toggle, + toggleAll + } = useBulkActions(params.ids); const intl = useIntl(); const { settings } = useListSettings(ListViews.PAGES_LIST); @@ -155,10 +160,12 @@ export const PageTypeList: React.FC = ({ params }) => { } }); - const selectedPageTypesHasPages = data?.pageTypes.edges.some( - pageType => - pageType.node.hasPages && params.ids?.some(id => id === pageType.node.id) - ); + const pageTypeDeleteData = usePageTypeDelete({ + selectedTypes: selectedPageTypes, + params + }); + + const pageTypesData = data?.pageTypes?.edges.map(edge => edge.node) || []; return ( <> @@ -181,7 +188,7 @@ export const PageTypeList: React.FC = ({ params }) => { onRowClick={id => () => navigate(pageTypeUrl(id))} onSort={handleSort} isChecked={isSelected} - selected={listElements.length} + selected={selectedPageTypes.length} sort={getSortParams(params)} toggle={toggle} toggleAll={toggleAll} @@ -190,7 +197,7 @@ export const PageTypeList: React.FC = ({ params }) => { color="primary" onClick={() => openModal("remove", { - ids: listElements + ids: selectedPageTypes }) } > @@ -198,13 +205,14 @@ export const PageTypeList: React.FC = ({ params }) => { } /> - ( pageDetails ); + +const pageCountQuery = gql` + query PageCount($filter: PageFilterInput) { + pages(filter: $filter) { + totalCount + } + } +`; + +export const usePageCountQuery = makeQuery( + pageCountQuery +); diff --git a/src/pages/types/PageCount.ts b/src/pages/types/PageCount.ts new file mode 100644 index 000000000..19bb4d4e3 --- /dev/null +++ b/src/pages/types/PageCount.ts @@ -0,0 +1,23 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +import { PageFilterInput } from "./../../types/globalTypes"; + +// ==================================================== +// GraphQL query operation: PageCount +// ==================================================== + +export interface PageCount_pages { + __typename: "PageCountableConnection"; + totalCount: number | null; +} + +export interface PageCount { + pages: PageCount_pages | null; +} + +export interface PageCountVariables { + filter?: PageFilterInput | null; +} diff --git a/src/pages/urls.ts b/src/pages/urls.ts index 748153f58..5474e5a57 100644 --- a/src/pages/urls.ts +++ b/src/pages/urls.ts @@ -1,7 +1,14 @@ import { stringify as stringifyQs } from "qs"; import urlJoin from "url-join"; -import { BulkAction, Dialog, Pagination, SingleAction, Sort } from "../types"; +import { + BulkAction, + Dialog, + FiltersWithMultipleValues, + Pagination, + SingleAction, + Sort +} from "../types"; export const pagesSection = "/pages/"; @@ -12,8 +19,17 @@ export enum PageListUrlSortField { slug = "slug", visible = "visible" } + +export enum PageListUrlFiltersWithMultipleValues { + pageTypes = "pageTypes" +} + +export type PageListUrlFilters = FiltersWithMultipleValues< + PageListUrlFiltersWithMultipleValues +>; export type PageListUrlSort = Sort; export type PageListUrlQueryParams = BulkAction & + PageListUrlFilters & Dialog & PageListUrlSort & Pagination; diff --git a/src/productTypes/components/ProductTypeDeleteDialog/ProductTypeDeleteDialog.tsx b/src/productTypes/components/ProductTypeDeleteDialog/ProductTypeDeleteDialog.tsx deleted file mode 100644 index 482f3ba98..000000000 --- a/src/productTypes/components/ProductTypeDeleteDialog/ProductTypeDeleteDialog.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import DialogContentText from "@material-ui/core/DialogContentText"; -import ActionDialog from "@saleor/components/ActionDialog"; -import { ConfirmButtonTransitionState } from "@saleor/components/ConfirmButton"; -import React from "react"; -import { FormattedMessage, useIntl } from "react-intl"; - -export interface ProductTypeDeleteDialogProps { - confirmButtonState: ConfirmButtonTransitionState; - open: boolean; - name: string; - onClose: () => void; - onConfirm: () => void; -} - -const ProductTypeDeleteDialog: React.FC = ({ - confirmButtonState, - open, - name, - onClose, - onConfirm -}) => { - const intl = useIntl(); - - return ( - - - {name} - }} - /> - - - ); -}; -ProductTypeDeleteDialog.displayName = "ProductTypeDeleteDialog"; -export default ProductTypeDeleteDialog; diff --git a/src/productTypes/components/ProductTypeDeleteDialog/index.ts b/src/productTypes/components/ProductTypeDeleteDialog/index.ts deleted file mode 100644 index 70a362a77..000000000 --- a/src/productTypes/components/ProductTypeDeleteDialog/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from "./ProductTypeDeleteDialog"; -export * from "./ProductTypeDeleteDialog"; diff --git a/src/productTypes/hooks/useProductTypeDelete/index.ts b/src/productTypes/hooks/useProductTypeDelete/index.ts new file mode 100644 index 000000000..407501ce8 --- /dev/null +++ b/src/productTypes/hooks/useProductTypeDelete/index.ts @@ -0,0 +1,2 @@ +export * from "./useProductTypeDelete"; +export { default } from "./useProductTypeDelete"; diff --git a/src/productTypes/hooks/useProductTypeDelete/messages.ts b/src/productTypes/hooks/useProductTypeDelete/messages.ts new file mode 100644 index 000000000..adde90214 --- /dev/null +++ b/src/productTypes/hooks/useProductTypeDelete/messages.ts @@ -0,0 +1,60 @@ +import { defineMessages } from "react-intl"; + +export const baseMessages = defineMessages({ + title: { + defaultMessage: + "Delete product {selectedTypesCount,plural,one{type} other{types}}", + description: "ProductTypeDeleteWarningDialog title" + }, + viewAssignedItemsButtonLabel: { + defaultMessage: "View products", + description: + "ProductTypeDeleteWarningDialog single assigned items button label" + } +}); + +export const singleWithItemsMessages = defineMessages({ + description: { + defaultMessage: + "You are about to delete product type {typeName}. It is assigned to {assignedItemsCount} {assignedItemsCount,plural,one{product} other{products}}. Deleting this product type will also delete those products. Are you sure you want to do this?", + description: + "ProductTypeDeleteWarningDialog single assigned items description" + }, + consentLabel: { + defaultMessage: + "Yes, I want to delete this product type and assigned products", + description: "ProductTypeDeleteWarningDialog single consent label" + } +}); + +export const multipleWithItemsMessages = defineMessages({ + description: { + defaultMessage: + "You are about to delete multiple product types. Some of them are assigned to products. Deleting those product types will also delete those products", + description: + "ProductTypeDeleteWarningDialog with items multiple description" + }, + consentLabel: { + defaultMessage: + "Yes, I want to delete those products types and assigned products", + description: "ProductTypeDeleteWarningDialog multiple consent label" + } +}); + +export const singleWithoutItemsMessages = defineMessages({ + description: { + defaultMessage: + "Are you sure you want to delete {typeName}? If you remove it you won’t be able to assign it to created products.", + description: + "ProductTypeDeleteWarningDialog single assigned items description" + } +}); + +export const multipleWithoutItemsMessages = defineMessages({ + description: { + defaultMessage: + "Are you sure you want to delete selected product types? If you remove them you won’t be able to assign them to created products.", + description: + "ProductTypeDeleteWarningDialog single assigned items description" + } +}); diff --git a/src/productTypes/hooks/useProductTypeDelete/useProductTypeDelete.tsx b/src/productTypes/hooks/useProductTypeDelete/useProductTypeDelete.tsx new file mode 100644 index 000000000..b8b43dc38 --- /dev/null +++ b/src/productTypes/hooks/useProductTypeDelete/useProductTypeDelete.tsx @@ -0,0 +1,68 @@ +import { + UseTypeDeleteData, + UseTypeDeleteProps +} from "@saleor/pageTypes/hooks/usePageTypeDelete/types"; +import { useProductCountQuery } from "@saleor/products/queries"; +import { ProductCountVariables } from "@saleor/products/types/ProductCount"; +import { productListUrl } from "@saleor/products/urls"; +import { + ProductTypeListUrlQueryParams, + ProductTypeUrlQueryParams +} from "@saleor/productTypes/urls"; +import React from "react"; + +import * as messages from "./messages"; + +type UseProductTypeDeleteProps< + T = ProductTypeListUrlQueryParams | ProductTypeUrlQueryParams +> = UseTypeDeleteProps; + +function useProductTypeDelete({ + params, + singleId, + selectedTypes +}: UseProductTypeDeleteProps): UseTypeDeleteData { + const productTypes = selectedTypes || [singleId]; + + const isDeleteDialogOpen = params.action === "remove"; + + const productsAssignedToSelectedTypesQueryVars = React.useMemo< + ProductCountVariables + >( + () => ({ + filter: { + productTypes + } + }), + [productTypes] + ); + + const shouldSkipProductListQuery = + !productTypes.length || !isDeleteDialogOpen; + + const { + data: productsAssignedToSelectedTypesData, + loading: loadingProductsAssignedToSelectedTypes + } = useProductCountQuery({ + variables: productsAssignedToSelectedTypesQueryVars, + skip: shouldSkipProductListQuery + }); + + const selectedProductsAssignedToDeleteUrl = productListUrl({ + productTypes + }); + + const assignedItemsCount = + productsAssignedToSelectedTypesData?.products?.totalCount; + + return { + ...messages, + isOpen: isDeleteDialogOpen, + assignedItemsCount, + viewAssignedItemsUrl: selectedProductsAssignedToDeleteUrl, + isLoading: loadingProductsAssignedToSelectedTypes, + typesToDelete: productTypes + }; +} + +export default useProductTypeDelete; diff --git a/src/productTypes/views/ProductTypeList/ProductTypeList.tsx b/src/productTypes/views/ProductTypeList/ProductTypeList.tsx index e308af9ea..7e4654255 100644 --- a/src/productTypes/views/ProductTypeList/ProductTypeList.tsx +++ b/src/productTypes/views/ProductTypeList/ProductTypeList.tsx @@ -1,7 +1,5 @@ -import DialogContentText from "@material-ui/core/DialogContentText"; import IconButton from "@material-ui/core/IconButton"; import DeleteIcon from "@material-ui/icons/Delete"; -import ActionDialog from "@saleor/components/ActionDialog"; import DeleteFilterTabDialog from "@saleor/components/DeleteFilterTabDialog"; import SaveFilterTabDialog, { SaveFilterTabDialogFormData @@ -14,14 +12,16 @@ import usePaginator, { createPaginationState } from "@saleor/hooks/usePaginator"; import { commonMessages } from "@saleor/intl"; +import useProductTypeDelete from "@saleor/productTypes/hooks/useProductTypeDelete"; import { ListViews } from "@saleor/types"; import createDialogActionHandlers from "@saleor/utils/handlers/dialogActionHandlers"; import createFilterHandlers from "@saleor/utils/handlers/filterHandlers"; import createSortHandler from "@saleor/utils/handlers/sortHandler"; import { getSortParams } from "@saleor/utils/sort"; import React from "react"; -import { FormattedMessage, useIntl } from "react-intl"; +import { useIntl } from "react-intl"; +import TypeDeleteWarningDialog from "../../../components/TypeDeleteWarningDialog/TypeDeleteWarningDialog"; import { configurationMenuUrl } from "../../../configuration"; import { maybe } from "../../../misc"; import ProductTypeListPage from "../../components/ProductTypeListPage"; @@ -55,9 +55,14 @@ export const ProductTypeList: React.FC = ({ params }) => { const navigate = useNavigator(); const notify = useNotifier(); const paginate = usePaginator(); - const { isSelected, listElements, reset, toggle, toggleAll } = useBulkActions( - params.ids - ); + const { + isSelected, + listElements: selectedProductTypes, + reset, + toggle, + toggleAll + } = useBulkActions(params.ids); + const { settings } = useListSettings(ListViews.PRODUCT_LIST); const intl = useIntl(); @@ -148,6 +153,14 @@ export const ProductTypeList: React.FC = ({ params }) => { const handleSort = createSortHandler(navigate, productTypeListUrl, params); + const productTypeDeleteData = useProductTypeDelete({ + selectedTypes: selectedProductTypes, + params + }); + + const productTypesData = + data?.productTypes?.edges.map(edge => edge.node) || []; + return ( = ({ params }) => { onTabSave={() => openModal("save-search")} tabs={tabs.map(tab => tab.name)} disabled={loading} - productTypes={maybe(() => - data.productTypes.edges.map(edge => edge.node) - )} + productTypes={productTypesData} pageInfo={pageInfo} onAdd={() => navigate(productTypeAddUrl)} onBack={() => navigate(configurationMenuUrl)} @@ -185,7 +196,7 @@ export const ProductTypeList: React.FC = ({ params }) => { onRowClick={id => () => navigate(productTypeUrl(id))} onSort={handleSort} isChecked={isSelected} - selected={listElements.length} + selected={selectedProductTypes.length} sort={getSortParams(params)} toggle={toggle} toggleAll={toggleAll} @@ -194,7 +205,7 @@ export const ProductTypeList: React.FC = ({ params }) => { color="primary" onClick={() => openModal("remove", { - ids: listElements + ids: selectedProductTypes }) } > @@ -202,30 +213,14 @@ export const ProductTypeList: React.FC = ({ params }) => { } /> - - - params.ids.length), - displayQuantity: ( - {maybe(() => params.ids.length)} - ) - }} - /> - - + onDelete={onProductTypeBulkDelete} + deleteButtonState={productTypeBulkDeleteOpts.status} + /> = ({ return result.data.productTypeUpdate.errors; }; + const productTypeDeleteData = useProductTypeDelete({ + singleId: id, + params + }); + return ( {({ data, loading: dataLoading }) => { @@ -335,62 +341,67 @@ export const ProductTypeUpdate: React.FC = ({ ) }} /> - {!dataLoading && - Object.keys(ProductAttributeType).map(key => ( - - result.data.productType.availableAttributes.edges.map( - edge => edge.node - ) - )} - confirmButtonState={assignAttribute.opts.status} - errors={maybe( - () => - assignAttribute.opts.data.productAttributeAssign.errors.map( - err => err.message - ), - [] - )} - loading={result.loading} + {!dataLoading && ( + <> + {Object.keys(ProductAttributeType).map(key => ( + + result.data.productType.availableAttributes.edges.map( + edge => edge.node + ) + )} + confirmButtonState={assignAttribute.opts.status} + errors={maybe( + () => + assignAttribute.opts.data.productAttributeAssign.errors.map( + err => err.message + ), + [] + )} + loading={result.loading} + onClose={closeModal} + onSubmit={handleAssignAttribute} + onFetch={search} + onFetchMore={loadMore} + onOpen={result.refetch} + hasMore={maybe( + () => + result.data.productType.availableAttributes + .pageInfo.hasNextPage, + false + )} + open={ + params.action === "assign-attribute" && + params.type === ProductAttributeType[key] + } + selected={maybe(() => params.ids, [])} + onToggle={attributeId => { + const ids = maybe(() => params.ids, []); + navigate( + productTypeUrl(id, { + ...params, + ids: ids.includes(attributeId) + ? params.ids.filter( + selectedId => selectedId !== attributeId + ) + : [...ids, attributeId] + }) + ); + }} + key={key} + /> + ))} + - result.data.productType.availableAttributes.pageInfo - .hasNextPage, - false - )} - open={ - params.action === "assign-attribute" && - params.type === ProductAttributeType[key] - } - selected={maybe(() => params.ids, [])} - onToggle={attributeId => { - const ids = maybe(() => params.ids, []); - navigate( - productTypeUrl(id, { - ...params, - ids: ids.includes(attributeId) - ? params.ids.filter( - selectedId => selectedId !== attributeId - ) - : [...ids, attributeId] - }) - ); - }} - key={key} + onDelete={handleProductTypeDelete} + deleteButtonState={deleteProductType.opts.status} /> - ))} - data.productType.name, "...")} - open={params.action === "remove"} - onClose={() => navigate(productTypeUrl(id))} - onConfirm={handleProductTypeDelete} - /> + + )} + ( productListQuery ); -const countAllProductsQuery = gql` - query CountAllProducts { - products { +const productCountQuery = gql` + query ProductCount($filter: ProductFilterInput) { + products(filter: $filter) { totalCount } } `; -export const useCountAllProducts = makeQuery( - countAllProductsQuery -); + +export const useProductCountQuery = makeQuery< + ProductCount, + ProductCountVariables +>(productCountQuery); const productDetailsQuery = gql` ${productFragmentDetails} diff --git a/src/products/types/CountAllProducts.ts b/src/products/types/ProductCount.ts similarity index 50% rename from src/products/types/CountAllProducts.ts rename to src/products/types/ProductCount.ts index 71d7a60ce..ccef6bf33 100644 --- a/src/products/types/CountAllProducts.ts +++ b/src/products/types/ProductCount.ts @@ -3,15 +3,21 @@ // @generated // This file was automatically generated and should not be edited. +import { ProductFilterInput } from "./../../types/globalTypes"; + // ==================================================== -// GraphQL query operation: CountAllProducts +// GraphQL query operation: ProductCount // ==================================================== -export interface CountAllProducts_products { +export interface ProductCount_products { __typename: "ProductCountableConnection"; totalCount: number | null; } -export interface CountAllProducts { - products: CountAllProducts_products | null; +export interface ProductCount { + products: ProductCount_products | null; +} + +export interface ProductCountVariables { + filter?: ProductFilterInput | null; } diff --git a/src/products/views/ProductList/ProductList.tsx b/src/products/views/ProductList/ProductList.tsx index bf83ac5a7..a66a0c943 100644 --- a/src/products/views/ProductList/ProductList.tsx +++ b/src/products/views/ProductList/ProductList.tsx @@ -32,11 +32,11 @@ import { } from "@saleor/products/components/ProductListPage/utils"; import { useAvailableInGridAttributesQuery, - useCountAllProducts, useInitialProductFilterAttributesQuery, useInitialProductFilterCategoriesQuery, useInitialProductFilterCollectionsQuery, useInitialProductFilterProductTypesQuery, + useProductCountQuery, useProductListQuery } from "@saleor/products/queries"; import { ProductListVariables } from "@saleor/products/types/ProductList"; @@ -186,7 +186,7 @@ export const ProductList: React.FC = ({ params }) => { : 0 : parseInt(params.activeTab, 0); - const countAllProducts = useCountAllProducts({ + const countAllProducts = useProductCountQuery({ skip: params.action !== "export" }); diff --git a/src/storybook/config.js b/src/storybook/config.js index 42ada3b25..1575d9e22 100644 --- a/src/storybook/config.js +++ b/src/storybook/config.js @@ -130,7 +130,6 @@ function loadStories() { // Product types require("./stories/productTypes/ProductTypeCreatePage"); - require("./stories/productTypes/ProductTypeDeleteDialog"); require("./stories/productTypes/ProductTypeDetailsPage"); require("./stories/productTypes/ProductTypeListPage"); diff --git a/src/storybook/stories/productTypes/ProductTypeDeleteDialog.tsx b/src/storybook/stories/productTypes/ProductTypeDeleteDialog.tsx deleted file mode 100644 index 6ffe808fe..000000000 --- a/src/storybook/stories/productTypes/ProductTypeDeleteDialog.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { storiesOf } from "@storybook/react"; -import React from "react"; - -import ProductTypeDeleteDialog, { - ProductTypeDeleteDialogProps -} from "../../../productTypes/components/ProductTypeDeleteDialog"; -import Decorator from "../../Decorator"; - -const props: ProductTypeDeleteDialogProps = { - confirmButtonState: "default", - name: "Shoes", - onClose: () => undefined, - onConfirm: () => undefined, - open: true -}; - -storiesOf("Product types / ProductTypeDeleteDialog", module) - .addDecorator(Decorator) - .add("default", () => ); diff --git a/src/types.ts b/src/types.ts index 6d2ad5939..069a800a9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -203,3 +203,5 @@ export interface AutocompleteFilterOpts choices: MultiAutocompleteChoiceType[]; displayValues: MultiAutocompleteChoiceType[]; } + +export type Ids = string[]; diff --git a/src/types/globalTypes.ts b/src/types/globalTypes.ts index 325ec55a2..ddef892e6 100644 --- a/src/types/globalTypes.ts +++ b/src/types/globalTypes.ts @@ -1528,6 +1528,12 @@ export interface PageCreateInput { pageType: string; } +export interface PageFilterInput { + pageTypes?: string[] | null; + search?: string | null; + metadata?: (MetadataInput | null)[] | null; +} + export interface PageInput { slug?: string | null; title?: string | null; From 19b8d30565ba4fe3e771b054edd0018329d6e241 Mon Sep 17 00:00:00 2001 From: mmarkusik Date: Tue, 11 May 2021 15:58:09 +0200 Subject: [PATCH 3/3] Feature - channels per plugin (#1093) * Update schema * Update queries, mutations, and types * Add render with dividers util function * Add plugin details channels card component * Update plugin details to use channels * Update stories * Update plugin configuration type across the app, fix some other types, temporarily comment some things out in plugins list so types match" * Update schema * Update types * Update plugins list to show channels and global statuses, add plugin channel status, update status label component * Add render with dividers util function * Comment out some stuff for types to match - temporary * Add useChannelsSearchWithLoadMore util to imitate loading more from backend for channels list with load more * Change filters logic to be able to display multiple fields in a field section and add it to plugins view * Add scroll option to plugin availability popup on plugin list * Fix plugin list page story * Temporarily comment some stuff out, fix some types * Add filters errors WIP * Fix filters not updating list * Add error handling to plugins list filters and filters in general * Rename some components according to review * Move useChannelsSearch and useChannelsSearchWithLoadMore to hooks, change some imports accordingly * Fix imports * Move render collection with dividers to a component, fix usages * Replace channels with load more and search query to base channels query * Change render with dividers function to take in a component instead of render function * Update tests * Extract messages * Remove unnecessary imports * Fix filters - autocomplete messing items order sometimes & some fields not working * Update plugin update mutation variables - change channelId to channel * fix failing tests * Add test ids * fix failing tests * fix failing tests * Rename misc.tsx to ts * Remove usage of render collection with diviers, change it to CollectionWithDividers component * Remove unnecessary imports * Update messages ids * Update snapshots Co-authored-by: Karolina Rakoczy --- cypress/apiRequests/Order.js | 11 +- .../elements/orders/draft-order-selectors.js | 5 +- cypress/elements/shared/addressForm.js | 13 + cypress/fixtures/addresses.json | 36 +- .../integration/homePage/homePageAnalitics.js | 10 +- cypress/integration/orders/draftOrders.js | 4 +- cypress/integration/orders/orders.js | 8 +- cypress/steps/draftOrderSteps.js | 75 +- cypress/steps/shared/addressForm.js | 26 + cypress/utils/ordersUtils.js | 28 +- cypress/utils/products/productsListUtils.js | 1 + locale/defaultMessages.json | 211 +- schema.graphql | 24 +- .../ChannelsAvailabilityDialog/utils.ts | 11 - src/components/AddressEdit/AddressEdit.tsx | 1 + src/components/Alert/InlineAlert.tsx | 25 + .../CollectionWithDividers.tsx | 46 + .../CollectionWithDividers/index.tsx | 2 + src/components/Filter/Filter.tsx | 48 +- .../Filter/FilterAutocompleteField.tsx | 39 +- src/components/Filter/FilterContent.tsx | 433 -- .../Filter/FilterContent/FilterContent.tsx | 158 + .../FilterContent/FilterContentBody.tsx | 298 + .../FilterContentBodyNameField.tsx | 61 + .../FilterContent/FilterContentHeader.tsx | 55 + .../Filter/FilterContent/FilterErrorsList.tsx | 80 + src/components/Filter/FilterContent/index.tsx | 2 + src/components/Filter/FilterContent/utils.ts | 27 + src/components/Filter/types.ts | 12 +- src/components/Filter/useFilter.ts | 22 +- src/components/Filter/utils.ts | 68 + src/components/FilterBar/FilterBar.tsx | 7 +- src/components/StatusLabel/StatusLabel.tsx | 96 +- src/components/StatusLabel/messages.ts | 16 + src/fragments/plugins.ts | 40 +- .../types/ConfigurationItemFragment.ts | 19 + .../types/PluginConfiguarionFragment.ts | 33 + .../types/PluginConfigurationFragment.ts | 33 + src/fragments/types/PluginFragment.ts | 51 +- src/fragments/types/PluginsDetailsFragment.ts | 45 +- src/hooks/useChannelsSearch.ts | 21 + src/hooks/useChannelsSearchWithLoadMore.ts | 66 + .../OrderAddressEditDialog.tsx | 1 + .../OrderCustomer/OrderCustomer.tsx | 9 +- .../hooks/usePageTypeDelete/messages.ts | 25 +- .../PluginAuthorization.tsx | 4 +- .../PluginDetailsChannelsCard.tsx | 22 + .../PluginDetailsChannelsCardContent.tsx | 83 + .../PluginDetailsChannelsCard/index.tsx | 2 + .../PluginDetailsChannelsCard/messages.ts | 8 + .../PluginSecretFieldDialog.tsx | 4 +- .../PluginSettings/PluginSettings.tsx | 4 +- .../PluginsDetailsPage/PluginsDetailsPage.tsx | 128 +- .../components/PluginsDetailsPage/types.ts | 3 + .../PluginsList/PluginAvailabilityStatus.tsx | 75 + .../ChannelConfigPluginPopupBody.tsx | 76 + .../GlobalConfigPluginPopupBody.tsx | 42 + .../PluginAvailabilityStatusPopup.tsx | 52 + .../ScrollableContent.tsx | 29 + .../PluginAvailabilityStatusPopup/index.tsx | 2 + .../PluginChannelAvailabilityCell.tsx | 46 + .../PluginChannelConfigurationCell.tsx | 27 + .../PluginsList/PluginListTableHead.tsx | 52 + .../components/PluginsList/PluginsList.tsx | 133 +- .../components/PluginsList/messages.ts | 56 + src/plugins/components/PluginsList/utils.ts | 9 + .../PluginsListPage/PluginsListPage.tsx | 2 + .../components/PluginsListPage/filters.ts | 105 +- .../components/PluginsListPage/messages.ts | 12 + src/plugins/fixtures.ts | 156 +- src/plugins/mutations.ts | 4 +- src/plugins/queries.ts | 4 +- src/plugins/types/Plugin.ts | 45 +- src/plugins/types/PluginUpdate.ts | 46 +- src/plugins/types/Plugins.ts | 51 +- src/plugins/urls.ts | 10 +- src/plugins/utils.ts | 5 +- src/plugins/views/PluginDetails.test.ts | 7 +- src/plugins/views/PluginList/PluginList.tsx | 6 +- .../__snapshots__/filters.test.ts.snap | 3 +- src/plugins/views/PluginList/filters.test.ts | 46 +- src/plugins/views/PluginList/filters.ts | 79 +- src/plugins/views/PluginsDetails.tsx | 211 +- src/plugins/views/utils.ts | 11 + .../hooks/useProductTypeDelete/messages.ts | 18 +- .../ChannelsSection.tsx | 2 +- .../__snapshots__/Stories.test.ts.snap | 6280 ++++++++++++----- src/storybook/stories/components/Filter.tsx | 85 +- .../stories/plugins/PluginDetailsPage.tsx | 6 +- .../stories/plugins/PluginsListPage.tsx | 20 + src/types/globalTypes.ts | 13 +- src/utils/filters/fields.ts | 4 +- 92 files changed, 7496 insertions(+), 2894 deletions(-) create mode 100644 cypress/elements/shared/addressForm.js create mode 100644 cypress/steps/shared/addressForm.js delete mode 100644 src/channels/components/ChannelsAvailabilityDialog/utils.ts create mode 100644 src/components/Alert/InlineAlert.tsx create mode 100644 src/components/CollectionWithDividers/CollectionWithDividers.tsx create mode 100644 src/components/CollectionWithDividers/index.tsx delete mode 100644 src/components/Filter/FilterContent.tsx create mode 100644 src/components/Filter/FilterContent/FilterContent.tsx create mode 100644 src/components/Filter/FilterContent/FilterContentBody.tsx create mode 100644 src/components/Filter/FilterContent/FilterContentBodyNameField.tsx create mode 100644 src/components/Filter/FilterContent/FilterContentHeader.tsx create mode 100644 src/components/Filter/FilterContent/FilterErrorsList.tsx create mode 100644 src/components/Filter/FilterContent/index.tsx create mode 100644 src/components/Filter/FilterContent/utils.ts create mode 100644 src/components/Filter/utils.ts create mode 100644 src/components/StatusLabel/messages.ts create mode 100644 src/fragments/types/ConfigurationItemFragment.ts create mode 100644 src/fragments/types/PluginConfiguarionFragment.ts create mode 100644 src/fragments/types/PluginConfigurationFragment.ts create mode 100644 src/hooks/useChannelsSearch.ts create mode 100644 src/hooks/useChannelsSearchWithLoadMore.ts create mode 100644 src/plugins/components/PluginDetailsChannelsCard/PluginDetailsChannelsCard.tsx create mode 100644 src/plugins/components/PluginDetailsChannelsCard/PluginDetailsChannelsCardContent.tsx create mode 100644 src/plugins/components/PluginDetailsChannelsCard/index.tsx create mode 100644 src/plugins/components/PluginDetailsChannelsCard/messages.ts create mode 100644 src/plugins/components/PluginsDetailsPage/types.ts create mode 100644 src/plugins/components/PluginsList/PluginAvailabilityStatus.tsx create mode 100644 src/plugins/components/PluginsList/PluginAvailabilityStatusPopup/ChannelConfigPluginPopupBody.tsx create mode 100644 src/plugins/components/PluginsList/PluginAvailabilityStatusPopup/GlobalConfigPluginPopupBody.tsx create mode 100644 src/plugins/components/PluginsList/PluginAvailabilityStatusPopup/PluginAvailabilityStatusPopup.tsx create mode 100644 src/plugins/components/PluginsList/PluginAvailabilityStatusPopup/ScrollableContent.tsx create mode 100644 src/plugins/components/PluginsList/PluginAvailabilityStatusPopup/index.tsx create mode 100644 src/plugins/components/PluginsList/PluginChannelAvailabilityCell.tsx create mode 100644 src/plugins/components/PluginsList/PluginChannelConfigurationCell.tsx create mode 100644 src/plugins/components/PluginsList/PluginListTableHead.tsx create mode 100644 src/plugins/components/PluginsList/messages.ts create mode 100644 src/plugins/components/PluginsList/utils.ts create mode 100644 src/plugins/components/PluginsListPage/messages.ts create mode 100644 src/plugins/views/utils.ts diff --git a/cypress/apiRequests/Order.js b/cypress/apiRequests/Order.js index e10dd1710..212255abf 100644 --- a/cypress/apiRequests/Order.js +++ b/cypress/apiRequests/Order.js @@ -1,3 +1,5 @@ +import { getDefaultAddress } from "./utils/Utils"; + export function markOrderAsPaid(orderId) { const mutation = `mutation{ orderMarkAsPaid(id:"${orderId}"){ @@ -23,12 +25,19 @@ export function addProductToOrder(orderId, variantId, quantity = 1) { return cy.sendRequestWithQuery(mutation); } -export function createDraftOrder(customerId, shippingMethodId, channelId) { +export function createDraftOrder( + customerId, + shippingMethodId, + channelId, + address +) { const mutation = `mutation{ draftOrderCreate(input:{ user:"${customerId}" shippingMethod:"${shippingMethodId}" channel: "${channelId}" + ${getDefaultAddress(address, "shippingAddress")} + ${getDefaultAddress(address, "billingAddress")} }){ orderErrors{ message diff --git a/cypress/elements/orders/draft-order-selectors.js b/cypress/elements/orders/draft-order-selectors.js index cfa1bc6c6..4b2fb1a21 100644 --- a/cypress/elements/orders/draft-order-selectors.js +++ b/cypress/elements/orders/draft-order-selectors.js @@ -6,5 +6,8 @@ export const DRAFT_ORDER_SELECTORS = { selectCustomerOption: "[data-test-type='option']", addShippingCarrierLink: "[data-test-id='add-shipping-carrier']", finalizeButton: "[data-test='button-bar-confirm']", - pageHeader: "[data-test-id='page-header']" + pageHeader: "[data-test-id='page-header']", + editShippingAddress: '[data-test-id="edit-shipping-address"]', + editBillingAddress: '[data-test-id="edit-billing-address"]', + customerEmail: '[data-test-id="customer-email"]' }; diff --git a/cypress/elements/shared/addressForm.js b/cypress/elements/shared/addressForm.js new file mode 100644 index 000000000..5a8d7aa12 --- /dev/null +++ b/cypress/elements/shared/addressForm.js @@ -0,0 +1,13 @@ +export const ADDRESS_SELECTORS = { + firstName: '[name="firstName"]', + lastName: '[name="lastName"]', + companyName: '[name="companyName"]', + phone: '[name="phone"]', + streetAddress1: '[name="streetAddress1"]', + streetAddress2: '[name="streetAddress2"]', + city: '[name="city"]', + postalCode: '[name="postalCode"]', + country: '[data-test-id="address-edit-country-select-field"]', + countryArea: '[name="countryArea"]', + saveButton: '[data-test-id="order-address-edit-dialog-confirm-button"]' +}; diff --git a/cypress/fixtures/addresses.json b/cypress/fixtures/addresses.json index 820b814fe..12107df59 100644 --- a/cypress/fixtures/addresses.json +++ b/cypress/fixtures/addresses.json @@ -1,26 +1,16 @@ { - "plAddress": { - "companyName": "Test3", - "streetAddress1": "Smolna", - "streetAddress2": "13/1", - "city": "Wrocław", - "postalCode": "53-346", - "country": "PL", - "countryArea": "Dolny Śląsk", - "phone": "123456787", - "currency": "PLN", - "countryFullName": "Poland" - }, - "usAddress": { - "companyName": "Test3", - "streetAddress1": "Amber Prairie", - "streetAddress2": "483", - "city": "KIRKSHIRE", - "postalCode": "52216", - "country": "US", - "countryArea": "IA", - "currency": "USD", - "phone": "+12025550180", - "countryFullName": "United States of America" + "plAddress": { + "firstName": "test", + "lastName": "test", + "companyName": "Test3", + "streetAddress1": "Smolna", + "streetAddress2": "13/1", + "city": "Wrocław", + "postalCode": "53-346", + "country": "PL", + "countryArea": "Dolny Śląsk", + "phone": "123456787", + "currency": "PLN", + "countryFullName": "Poland" } } \ No newline at end of file diff --git a/cypress/integration/homePage/homePageAnalitics.js b/cypress/integration/homePage/homePageAnalitics.js index feb6f7d92..f0762ed84 100644 --- a/cypress/integration/homePage/homePageAnalitics.js +++ b/cypress/integration/homePage/homePageAnalitics.js @@ -109,12 +109,12 @@ describe("Homepage analytics", () => { homePageUtils .getOrdersReadyToFulfill(defaultChannel.slug) .as("ordersReadyToFulfill"); - createReadyToFulfillOrder( customerId, shippingMethod.id, defaultChannel.id, - createdVariants + createdVariants, + addresses.plAddress ); cy.get("@ordersReadyToFulfill").then(ordersReadyToFulfillBefore => { const allOrdersReadyToFulfill = ordersReadyToFulfillBefore + 1; @@ -195,7 +195,8 @@ describe("Homepage analytics", () => { customerId, shippingMethod.id, defaultChannel.id, - createdVariants + createdVariants, + addresses.plAddress ); cy.get("@salesAmount").then(salesAmount => { @@ -227,7 +228,8 @@ describe("Homepage analytics", () => { customerId, shippingMethod.id, defaultChannel.id, - createdVariants + createdVariants, + addresses.plAddress ); cy.get("@todaysOrders").then(ordersBefore => { diff --git a/cypress/integration/orders/draftOrders.js b/cypress/integration/orders/draftOrders.js index 43a9c2dcd..98e30938b 100644 --- a/cypress/integration/orders/draftOrders.js +++ b/cypress/integration/orders/draftOrders.js @@ -23,6 +23,7 @@ describe("Draft orders", () => { let defaultChannel; let warehouse; + let address; before(() => { cy.clearSessionData().loginUserViaRequest(); @@ -38,6 +39,7 @@ describe("Draft orders", () => { cy.fixture("addresses"); }) .then(addresses => { + address = addresses.plAddress; createCustomer( `${randomName}@example.com`, randomName, @@ -81,7 +83,7 @@ describe("Draft orders", () => { .get(ORDERS_SELECTORS.createOrder) .click(); selectChannelInPicker(defaultChannel.name); - finalizeDraftOrder(randomName).then(draftOrderNumber => { + finalizeDraftOrder(randomName, address).then(draftOrderNumber => { cy.visit(urlList.orders); cy.contains(ORDERS_SELECTORS.orderRow, draftOrderNumber).should( $order => { diff --git a/cypress/integration/orders/orders.js b/cypress/integration/orders/orders.js index 7422fdc40..184575e8f 100644 --- a/cypress/integration/orders/orders.js +++ b/cypress/integration/orders/orders.js @@ -26,6 +26,7 @@ describe("Orders", () => { let warehouse; let shippingMethod; let variantsList; + let address; before(() => { cy.clearSessionData().loginUserViaRequest(); @@ -33,8 +34,6 @@ describe("Orders", () => { deleteShippingStartsWith(startsWith); productsUtils.deleteProductsStartsWith(startsWith); - let address; - getDefaultChannel() .then(channel => { defaultChannel = channel; @@ -91,7 +90,7 @@ describe("Orders", () => { .get(ORDERS_SELECTORS.createOrder) .click(); selectChannelInPicker(defaultChannel.name); - finalizeDraftOrder(randomName).then(draftOrderNumber => { + finalizeDraftOrder(randomName, address).then(draftOrderNumber => { cy.visit(urlList.orders); cy.contains(ORDERS_SELECTORS.orderRow, draftOrderNumber).click(); cy.contains(ORDERS_SELECTORS.salesChannel, defaultChannel.name).should( @@ -104,7 +103,8 @@ describe("Orders", () => { customerId: customer.id, channelId: defaultChannel.id, shippingMethodId: shippingMethod.id, - variantsList + variantsList, + address }).then(order => { cy.visit(urlList.orders); cy.contains(ORDERS_SELECTORS.orderRow, order.number).click(); diff --git a/cypress/steps/draftOrderSteps.js b/cypress/steps/draftOrderSteps.js index a8b98909c..e5753351b 100644 --- a/cypress/steps/draftOrderSteps.js +++ b/cypress/steps/draftOrderSteps.js @@ -1,42 +1,47 @@ import { ASSIGN_PRODUCTS_SELECTORS } from "../elements/catalog/products/assign-products-selectors"; import { DRAFT_ORDER_SELECTORS } from "../elements/orders/draft-order-selectors"; import { SELECT_SHIPPING_METHOD_FORM } from "../elements/shipping/select-shipping-method-form"; +import { fillUpAddressForm } from "./shared/addressForm"; -export function finalizeDraftOrder(name) { - return cy - .get(DRAFT_ORDER_SELECTORS.addProducts) +export function finalizeDraftOrder(name, address) { + cy.get(DRAFT_ORDER_SELECTORS.addProducts) .click() .get(ASSIGN_PRODUCTS_SELECTORS.searchInput) - .type(name) - .then(() => { - cy.contains(ASSIGN_PRODUCTS_SELECTORS.tableRow, name) - .find(ASSIGN_PRODUCTS_SELECTORS.checkbox) - .click() - .get(ASSIGN_PRODUCTS_SELECTORS.submitButton) - .click() - .get(DRAFT_ORDER_SELECTORS.editCustomerButton) - .click() - .get(DRAFT_ORDER_SELECTORS.selectCustomer) - .type(name); - cy.contains(DRAFT_ORDER_SELECTORS.selectCustomerOption, name) - .click() - .get(DRAFT_ORDER_SELECTORS.addShippingCarrierLink) - .click() - .get(SELECT_SHIPPING_METHOD_FORM.selectShippingMethod) - .click() - .get(SELECT_SHIPPING_METHOD_FORM.shippingMethodOption) - .first() - .click(); - cy.addAliasToGraphRequest("OrderShippingMethodUpdate") - .get(SELECT_SHIPPING_METHOD_FORM.submitButton) - .click(); - cy.wait("@OrderShippingMethodUpdate"); - cy.getTextFromElement(DRAFT_ORDER_SELECTORS.pageHeader).as( - "draftOrderNumber" - ); - cy.addAliasToGraphRequest("OrderDraftFinalize"); - cy.get(DRAFT_ORDER_SELECTORS.finalizeButton).click(); - cy.wait("@OrderDraftFinalize"); - return cy.get("@draftOrderNumber"); - }); + .type(name); + cy.contains(ASSIGN_PRODUCTS_SELECTORS.tableRow, name) + .find(ASSIGN_PRODUCTS_SELECTORS.checkbox) + .click() + .get(ASSIGN_PRODUCTS_SELECTORS.submitButton) + .click() + .get(DRAFT_ORDER_SELECTORS.editCustomerButton) + .click() + .get(DRAFT_ORDER_SELECTORS.selectCustomer) + .type(name); + cy.contains(DRAFT_ORDER_SELECTORS.selectCustomerOption, name) + .click() + .get(DRAFT_ORDER_SELECTORS.customerEmail) + .should("be.visible") + .get(DRAFT_ORDER_SELECTORS.editShippingAddress) + .click(); + fillUpAddressForm(address); + cy.get(DRAFT_ORDER_SELECTORS.editBillingAddress).click(); + fillUpAddressForm(address); + cy.get(DRAFT_ORDER_SELECTORS.addShippingCarrierLink) + .click() + .get(SELECT_SHIPPING_METHOD_FORM.selectShippingMethod) + .click() + .get(SELECT_SHIPPING_METHOD_FORM.shippingMethodOption) + .first() + .click(); + cy.addAliasToGraphRequest("OrderShippingMethodUpdate") + .get(SELECT_SHIPPING_METHOD_FORM.submitButton) + .click(); + cy.wait("@OrderShippingMethodUpdate"); + cy.getTextFromElement(DRAFT_ORDER_SELECTORS.pageHeader).as( + "draftOrderNumber" + ); + cy.addAliasToGraphRequest("OrderDraftFinalize"); + cy.get(DRAFT_ORDER_SELECTORS.finalizeButton).click(); + cy.wait("@OrderDraftFinalize"); + return cy.get("@draftOrderNumber"); } diff --git a/cypress/steps/shared/addressForm.js b/cypress/steps/shared/addressForm.js new file mode 100644 index 000000000..b43dc4f5d --- /dev/null +++ b/cypress/steps/shared/addressForm.js @@ -0,0 +1,26 @@ +import { ADDRESS_SELECTORS } from "../../elements/shared/addressForm"; +import { fillAutocompleteSelect } from "./autocompleteSelect"; + +export function fillUpAddressForm(address) { + cy.get(ADDRESS_SELECTORS.firstName) + .type(address.firstName) + .get(ADDRESS_SELECTORS.lastName) + .type(address.lastName) + .get(ADDRESS_SELECTORS.companyName) + .type(address.companyName) + .get(ADDRESS_SELECTORS.phone) + .type(address.phone) + .get(ADDRESS_SELECTORS.streetAddress1) + .type(address.streetAddress1) + .get(ADDRESS_SELECTORS.streetAddress2) + .type(address.streetAddress2) + .get(ADDRESS_SELECTORS.city) + .type(address.city) + .get(ADDRESS_SELECTORS.postalCode) + .type(address.postalCode); + fillAutocompleteSelect(ADDRESS_SELECTORS.country, address.countryFullName); + cy.get(ADDRESS_SELECTORS.countryArea) + .type(address.countryArea) + .get(ADDRESS_SELECTORS.saveButton) + .click(); +} diff --git a/cypress/utils/ordersUtils.js b/cypress/utils/ordersUtils.js index 4cd7676b9..af426f3ca 100644 --- a/cypress/utils/ordersUtils.js +++ b/cypress/utils/ordersUtils.js @@ -47,11 +47,11 @@ export function createReadyToFulfillOrder( customerId, shippingMethodId, channelId, - variantsList + variantsList, + address ) { let order; - return orderRequest - .createDraftOrder(customerId, shippingMethodId, channelId) + return createDraftOrder(customerId, shippingMethodId, channelId, address) .then(orderResp => { order = orderResp; assignVariantsToOrder(order, variantsList); @@ -64,11 +64,11 @@ export function createOrder({ customerId, shippingMethodId, channelId, - variantsList + variantsList, + address }) { let order; - return orderRequest - .createDraftOrder(customerId, shippingMethodId, channelId) + return createDraftOrder(customerId, shippingMethodId, channelId, address) .then(orderResp => { order = orderResp; assignVariantsToOrder(order, variantsList); @@ -82,12 +82,16 @@ function assignVariantsToOrder(order, variantsList) { orderRequest.addProductToOrder(order.id, variantElement.id); }); } -export function addPayment(checkoutId) { - return checkoutRequest.addPayment( - checkoutId, - "mirumee.payments.dummy", - "not-charged" - ); + +export function createDraftOrder( + customerId, + shippingMethodId, + channelId, + address +) { + return orderRequest + .createDraftOrder(customerId, shippingMethodId, channelId, address) + .its("body.data.draftOrderCreate.order"); } export function createAndCompleteCheckoutWithoutShipping({ channelSlug, diff --git a/cypress/utils/products/productsListUtils.js b/cypress/utils/products/productsListUtils.js index fb9b699a0..a5ed573e6 100644 --- a/cypress/utils/products/productsListUtils.js +++ b/cypress/utils/products/productsListUtils.js @@ -16,6 +16,7 @@ export function getDisplayedColumnArray(columnName) { export function expectProductsSortedBy(columnName, inAscOrder = true) { let sortedProductsArray; let productsArray; + cy.get(PRODUCTS_LIST.emptyProductRow).should("not.exist"); getDisplayedColumnArray(columnName) .then(productsArrayResp => { productsArray = productsArrayResp; diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index 3447035fe..c1665ca1a 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -3,10 +3,58 @@ "context": "dialog header", "string": "Cancel Order" }, + "PageTypeDeleteWarningDialog multiple assigned items description": { + "context": "PageTypeDeleteWarningDialog multiple assigned items description", + "string": "Are you sure you want to delete selected page types? If you remove them you won’t be able to assign them to created pages." + }, + "PageTypeDeleteWarningDialog multiple consent label": { + "context": "PageTypeDeleteWarningDialog multiple consent label", + "string": "Yes, I want to delete those pages types and assigned pages" + }, + "PageTypeDeleteWarningDialog single assigned items description": { + "context": "PageTypeDeleteWarningDialog single assigned items description", + "string": "You are about to delete page type {typeName}. It is assigned to {assignedItemsCount} {assignedItemsCount,plural,one{page} other{pages}}. Deleting this page type will also delete those pages. Are you sure you want to do this?" + }, + "PageTypeDeleteWarningDialog single consent label": { + "context": "PageTypeDeleteWarningDialog single consent label", + "string": "Yes, I want to delete this page type and assigned pages" + }, + "PageTypeDeleteWarningDialog single no assigned items description": { + "context": "PageTypeDeleteWarningDialog single no assigned items description", + "string": "Are you sure you want to delete {typeName}? If you remove it you won’t be able to assign it to created pages." + }, + "PageTypeDeleteWarningDialog with items multiple description": { + "context": "PageTypeDeleteWarningDialog with items multiple description", + "string": "You are about to delete multiple page types. Some of them are assigned to pages. Deleting those page types will also delete those pages" + }, "Previous discount label id": { "context": "Previous discount label", "string": "Previous discount value" }, + "ProductTypeDeleteWarningDialog multiple assigned items description": { + "context": "ProductTypeDeleteWarningDialog multiple assigned items description", + "string": "Are you sure you want to delete selected product types? If you remove them you won’t be able to assign them to created products." + }, + "ProductTypeDeleteWarningDialog multiple consent label": { + "context": "ProductTypeDeleteWarningDialog multiple consent label", + "string": "Yes, I want to delete those products types and assigned products" + }, + "ProductTypeDeleteWarningDialog single assigned items description": { + "context": "ProductTypeDeleteWarningDialog single assigned items description", + "string": "You are about to delete product type {typeName}. It is assigned to {assignedItemsCount} {assignedItemsCount,plural,one{product} other{products}}. Deleting this product type will also delete those products. Are you sure you want to do this?" + }, + "ProductTypeDeleteWarningDialog single consent label": { + "context": "ProductTypeDeleteWarningDialog single consent label", + "string": "Yes, I want to delete this product type and assigned products" + }, + "ProductTypeDeleteWarningDialog single no assigned items description": { + "context": "ProductTypeDeleteWarningDialog single no assigned items description", + "string": "Are you sure you want to delete {typeName}? If you remove it you won’t be able to assign it to created products." + }, + "ProductTypeDeleteWarningDialog with items multiple description": { + "context": "ProductTypeDeleteWarningDialog with items multiple description", + "string": "You are about to delete multiple product types. Some of them are assigned to products. Deleting those product types will also delete those products" + }, "amount title": { "context": "amount title", "string": "Refunded amount" @@ -138,6 +186,10 @@ "context": "Fixed amount subtitle", "string": "Fixed amount" }, + "globalConfigPluginMessages title": { + "context": "global config plugin status popup title", + "string": "Global Plugin" + }, "homeActivityCardHeader": { "context": "header", "string": "Activity" @@ -199,6 +251,14 @@ "context": "header", "string": "Hello there, {userName}" }, + "is filter range between value": { + "context": "is filter range or value", + "string": "between" + }, + "is filter range equal to value": { + "context": "is filter range or value", + "string": "equal to" + }, "menuCreateDialogHeader": { "context": "dialog header", "string": "Create Menu" @@ -322,6 +382,10 @@ "pageTypeInputLabel": { "string": "Select content type" }, + "pluginChannelConfigurationCellMessages per channel": { + "context": "PluginChannelConfigurationCell channel title", + "string": "Per channel" + }, "product available for purchase": { "context": "product availability", "string": "Available for purchase" @@ -2027,23 +2091,19 @@ "context": "search", "string": "No results" }, - "src_dot_components_dot_Filter_dot_2683154806": { - "context": "is filter range or value", - "string": "equal to" - }, - "src_dot_components_dot_Filter_dot_2779594451": { - "context": "filter range separator", - "string": "and" - }, "src_dot_components_dot_Filter_dot_4190318230": { "context": "search results", "string": "Show more" }, - "src_dot_components_dot_Filter_dot_773313536": { - "context": "is filter range or value", - "string": "between" - }, "src_dot_components_dot_Filter_dot_996289613": { + "context": "button", + "string": "Filters" + }, + "src_dot_components_dot_Filter_dot_FilterContent_dot_2779594451": { + "context": "filter range separator", + "string": "and" + }, + "src_dot_components_dot_Filter_dot_FilterContent_dot_996289613": { "string": "Filters" }, "src_dot_components_dot_ImageUpload_dot_1731007575": { @@ -2319,6 +2379,18 @@ "src_dot_components_dot_SingleSelectField_dot_4205644805": { "string": "No results found" }, + "src_dot_components_dot_StatusLabel_dot_active": { + "context": "status label active", + "string": "Active" + }, + "src_dot_components_dot_StatusLabel_dot_deactivated": { + "context": "status label deactivated", + "string": "Deactivated" + }, + "src_dot_components_dot_StatusLabel_dot_inactive": { + "context": "status label inactive", + "string": "Inactive" + }, "src_dot_components_dot_TableHead_dot_868570480": { "string": "Selected {number} items" }, @@ -4532,18 +4604,6 @@ "context": "header", "string": "Create Page Type" }, - "src_dot_pageTypes_dot_components_dot_PageTypeDeleteDialog_dot_2364900868": { - "context": "delete page type with its pages", - "string": "Page Type you want to delete is used by some pages. Deleting this page type will also delete those pages. Are you sure you want to delete {name}? After doing so you won’t be able to revert changes." - }, - "src_dot_pageTypes_dot_components_dot_PageTypeDeleteDialog_dot_3120835055": { - "context": "dialog header", - "string": "Delete Page Type" - }, - "src_dot_pageTypes_dot_components_dot_PageTypeDeleteDialog_dot_3734861990": { - "context": "delete page type", - "string": "Are you sure you want to delete {name}? After doing so you won’t be able to revert changes." - }, "src_dot_pageTypes_dot_components_dot_PageTypeDetailsPage_dot_1105469372": { "string": "These are general information about this Content Type." }, @@ -4579,6 +4639,14 @@ "src_dot_pageTypes_dot_components_dot_PageTypeList_dot_2965257236": { "string": "No page types found" }, + "src_dot_pageTypes_dot_hooks_dot_usePageTypeDelete_dot_title": { + "context": "PageTypeDeleteWarningDialog title", + "string": "Delete page {selectedTypesCount,plural,one{type} other{types}}" + }, + "src_dot_pageTypes_dot_hooks_dot_usePageTypeDelete_dot_viewAssignedItemsButtonLabel": { + "context": "PageTypeDeleteWarningDialog single assigned items button label", + "string": "View pages" + }, "src_dot_pageTypes_dot_views_dot_2634056946": { "context": "dialog header", "string": "Unassign Attribute from Page Type" @@ -4887,6 +4955,10 @@ "context": "section header", "string": "Authorization" }, + "src_dot_plugins_dot_components_dot_PluginDetailsChannelsCard_dot_noChannelsSubtitle": { + "context": "PluginDetailsChannelsCard no channels subtitle", + "string": "Plugin’s settings are set common across all channels" + }, "src_dot_plugins_dot_components_dot_PluginInfo_dot_1049131348": { "context": "plugin name", "string": "Plugin Name" @@ -4914,21 +4986,10 @@ "context": "section header", "string": "Plugin Settings" }, - "src_dot_plugins_dot_components_dot_PluginsDetailsPage_dot_1970881031": { - "context": "section header", - "string": "Plugin Settings" - }, "src_dot_plugins_dot_components_dot_PluginsDetailsPage_dot_3352026836": { "context": "header", "string": "{pluginName} Details" }, - "src_dot_plugins_dot_components_dot_PluginsDetailsPage_dot_3425535100": { - "context": "section header", - "string": "Plugin Information and Status" - }, - "src_dot_plugins_dot_components_dot_PluginsDetailsPage_dot_3799756739": { - "string": "These are general information about your store. They define what is the URL of your store and what is shown in browsers taskbar." - }, "src_dot_plugins_dot_components_dot_PluginsListPage_dot_3233248823": { "string": "Search Plugins..." }, @@ -4937,28 +4998,56 @@ "string": "All Plugins" }, "src_dot_plugins_dot_components_dot_PluginsListPage_dot_active": { - "context": "plugin", - "string": "Active" + "context": "plugin filters error messages active", + "string": "Active is not selected" }, - "src_dot_plugins_dot_components_dot_PluginsListPage_dot_deactivated": { - "context": "plugin", - "string": "Inactive" + "src_dot_plugins_dot_components_dot_PluginsListPage_dot_channelStatusSectionSubtitle": { + "context": "status section subtitle", + "string": "Channel status" }, - "src_dot_plugins_dot_components_dot_PluginsList_dot_3247064221": { - "context": "plugin status", - "string": "Active" + "src_dot_plugins_dot_components_dot_PluginsListPage_dot_channelStatusSectionTitle": { + "context": "status section title", + "string": "Status in channel" }, - "src_dot_plugins_dot_components_dot_PluginsList_dot_4120604650": { - "context": "user action bar", - "string": "Action" + "src_dot_plugins_dot_components_dot_PluginsListPage_dot_channels": { + "context": "plugin filters error messages channels", + "string": "No channels selected" }, - "src_dot_plugins_dot_components_dot_PluginsList_dot_636461959": { - "context": "plugin name", - "string": "Name" + "src_dot_plugins_dot_components_dot_PluginsListPage_dot_configTypeSectionTitle": { + "context": "config type section title", + "string": "Configuration Type" }, "src_dot_plugins_dot_components_dot_PluginsList_dot_666641390": { "string": "No plugins found" }, + "src_dot_plugins_dot_components_dot_PluginsList_dot_channelLabel": { + "context": "table header channel col label", + "string": "Channel" + }, + "src_dot_plugins_dot_components_dot_PluginsList_dot_channelTitle": { + "context": "plugin channel availability status title", + "string": "Active in {activeChannelsCount}" + }, + "src_dot_plugins_dot_components_dot_PluginsList_dot_confLabel": { + "context": "table header configuration col label", + "string": "Configuration" + }, + "src_dot_plugins_dot_components_dot_PluginsList_dot_description": { + "context": "global config plugin status popup description", + "string": "Global plugins are set across all channels in your ecommerce. Only status is shown for those types of plugins" + }, + "src_dot_plugins_dot_components_dot_PluginsList_dot_globalLabel": { + "context": "PluginChannelConfigurationCell global title", + "string": "Global" + }, + "src_dot_plugins_dot_components_dot_PluginsList_dot_nameLabel": { + "context": "table header name col label", + "string": "Name" + }, + "src_dot_plugins_dot_components_dot_PluginsList_dot_title": { + "context": "channel config plugin status popup title", + "string": "Assigned to {activeChannelsCount} of {allChannelsCount} channels" + }, "src_dot_plugins_dot_views_dot_1096544448": { "context": "header", "string": "Authorization Field Delete" @@ -4992,14 +5081,6 @@ "context": "section header", "string": "Variant Attributes" }, - "src_dot_productTypes_dot_components_dot_ProductTypeDeleteDialog_dot_2297471173": { - "context": "delete product type", - "string": "Are you sure you want to delete {name}?" - }, - "src_dot_productTypes_dot_components_dot_ProductTypeDeleteDialog_dot_924066985": { - "context": "dialog header", - "string": "Delete Product Type" - }, "src_dot_productTypes_dot_components_dot_ProductTypeDetailsPage_dot_1217376589": { "context": "switch button", "string": "Product type uses Variant Attributes" @@ -5079,17 +5160,17 @@ "src_dot_productTypes_dot_components_dot_ProductTypeShipping_dot_746695941": { "string": "Weight" }, + "src_dot_productTypes_dot_hooks_dot_useProductTypeDelete_dot_title": { + "context": "ProductTypeDeleteWarningDialog title", + "string": "Delete product {selectedTypesCount,plural,one{type} other{types}}" + }, + "src_dot_productTypes_dot_hooks_dot_useProductTypeDelete_dot_viewAssignedItemsButtonLabel": { + "context": "ProductTypeDeleteWarningDialog single assigned items button label", + "string": "View products" + }, "src_dot_productTypes_dot_views_dot_3822478981": { "string": "Successfully created product type" }, - "src_dot_productTypes_dot_views_dot_ProductTypeList_dot_4080551769": { - "context": "dialog header", - "string": "Delete Product Types" - }, - "src_dot_productTypes_dot_views_dot_ProductTypeList_dot_4269954457": { - "context": "dialog content", - "string": "{counter,plural,one{Are you sure you want to delete this product type?} other{Are you sure you want to delete {displayQuantity} product types?}}" - }, "src_dot_productTypes_dot_views_dot_ProductTypeUpdate_dot_3512959355": { "string": "Product type deleted" }, diff --git a/schema.graphql b/schema.graphql index 5943a4045..9fafed726 100644 --- a/schema.graphql +++ b/schema.graphql @@ -2820,7 +2820,7 @@ type Mutation { giftCardCreate(input: GiftCardCreateInput!): GiftCardCreate giftCardDeactivate(id: ID!): GiftCardDeactivate giftCardUpdate(id: ID!, input: GiftCardUpdateInput!): GiftCardUpdate - pluginUpdate(id: ID!, input: PluginUpdateInput!): PluginUpdate + pluginUpdate(channel: ID, id: ID!, input: PluginUpdateInput!): PluginUpdate saleCreate(input: SaleInput!): SaleCreate saleDelete(id: ID!): SaleDelete saleBulkDelete(ids: [ID]!): SaleBulkDelete @@ -3813,6 +3813,7 @@ enum PaymentErrorCode { SHIPPING_METHOD_NOT_SET PAYMENT_ERROR NOT_SUPPORTED_GATEWAY + CHANNEL_INACTIVE } input PaymentFilterInput { @@ -3951,14 +3952,25 @@ input PermissionGroupUpdateInput { removeUsers: [ID!] } -type Plugin implements Node { +type Plugin { id: ID! name: String! description: String! + globalConfiguration: PluginConfiguration + channelConfigurations: [PluginConfiguration!]! +} + +type PluginConfiguration { active: Boolean! + channel: Channel configuration: [ConfigurationItem] } +enum PluginConfigurationType { + PER_CHANNEL + GLOBAL +} + type PluginCountableConnection { pageInfo: PageInfo! edges: [PluginCountableEdge!]! @@ -3986,8 +3998,9 @@ enum PluginErrorCode { } input PluginFilterInput { - active: Boolean + statusInChannels: PluginStatusInChannelsInput search: String + type: PluginConfigurationType } enum PluginSortField { @@ -4000,6 +4013,11 @@ input PluginSortingInput { field: PluginSortField! } +input PluginStatusInChannelsInput { + active: Boolean! + channels: [ID!]! +} + type PluginUpdate { plugin: Plugin pluginsErrors: [PluginError!]! @deprecated(reason: "Use errors field instead. This field will be removed in Saleor 4.0.") diff --git a/src/channels/components/ChannelsAvailabilityDialog/utils.ts b/src/channels/components/ChannelsAvailabilityDialog/utils.ts deleted file mode 100644 index d3c5b7d2a..000000000 --- a/src/channels/components/ChannelsAvailabilityDialog/utils.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { filter } from "fuzzaldrin"; -import React from "react"; - -export const useChannelsSearch = function( - channels: T[] -) { - const [query, onQueryChange] = React.useState(""); - const filteredChannels = filter(channels, query, { key: "name" }); - - return { query, onQueryChange, filteredChannels }; -}; diff --git a/src/components/AddressEdit/AddressEdit.tsx b/src/components/AddressEdit/AddressEdit.tsx index 60636993a..1ea464d1d 100644 --- a/src/components/AddressEdit/AddressEdit.tsx +++ b/src/components/AddressEdit/AddressEdit.tsx @@ -202,6 +202,7 @@ const AddressEdit: React.FC = props => {
({ + container: { + backgroundColor: theme.palette.error.main, + padding: theme.spacing(1.5, 2) + } + }), + { name: "InlineAlert" } +); + +interface AlertCardProps { + children?: React.ReactNode | React.ReactNode[]; +} + +const AlertCard: React.FC = ({ children }) => { + const classes = useStyles({}); + + return {children}; +}; + +export default AlertCard; diff --git a/src/components/CollectionWithDividers/CollectionWithDividers.tsx b/src/components/CollectionWithDividers/CollectionWithDividers.tsx new file mode 100644 index 000000000..3f1fbb9f3 --- /dev/null +++ b/src/components/CollectionWithDividers/CollectionWithDividers.tsx @@ -0,0 +1,46 @@ +import Divider from "@material-ui/core/Divider"; +import initial from "lodash-es/initial"; +import React from "react"; + +interface CollectionWithDividersProps { + DividerComponent?: React.FunctionComponent; + renderEmpty?: (collection: T[]) => any; + collection: T[]; + renderItem: ( + item: T | undefined, + index: number | undefined, + collection: T[] + ) => any; +} + +function CollectionWithDividers({ + collection, + renderItem, + DividerComponent, + renderEmpty +}: CollectionWithDividersProps) { + const hasNoItemsAndPlaceholder = !renderEmpty && !collection.length; + + if (hasNoItemsAndPlaceholder) { + return null; + } + + if (!collection.length) { + return !!renderEmpty ? renderEmpty(collection) : null; + } + + const SelectedDividerComponent = DividerComponent || Divider; + + return initial( + collection.reduce( + (result, item, index) => [ + ...result, + renderItem(item, index, collection), + + ], + [] + ) + ); +} + +export default CollectionWithDividers; diff --git a/src/components/CollectionWithDividers/index.tsx b/src/components/CollectionWithDividers/index.tsx new file mode 100644 index 000000000..f0b5ac483 --- /dev/null +++ b/src/components/CollectionWithDividers/index.tsx @@ -0,0 +1,2 @@ +export * from "./CollectionWithDividers"; +export { default } from "./CollectionWithDividers"; diff --git a/src/components/Filter/Filter.tsx b/src/components/Filter/Filter.tsx index 655a6cd22..78abbef0b 100644 --- a/src/components/Filter/Filter.tsx +++ b/src/components/Filter/Filter.tsx @@ -6,15 +6,17 @@ import { fade } from "@material-ui/core/styles/colorManipulator"; import Typography from "@material-ui/core/Typography"; import { makeStyles } from "@saleor/theme"; import classNames from "classnames"; -import React from "react"; +import React, { useState } from "react"; import { FormattedMessage } from "react-intl"; import { FilterContent } from "."; -import { IFilter, IFilterElement } from "./types"; +import { FilterErrorMessages, IFilter, IFilterElement } from "./types"; import useFilter from "./useFilter"; +import { extractInvalidFilters } from "./utils"; export interface FilterProps { currencySymbol?: string; + errorMessages?: FilterErrorMessages; menu: IFilter; onFilterAdd: (filter: Array>) => void; } @@ -87,15 +89,30 @@ const useStyles = makeStyles( { name: "Filter" } ); const Filter: React.FC = props => { - const { currencySymbol, menu, onFilterAdd } = props; + const { currencySymbol, menu, onFilterAdd, errorMessages } = props; const classes = useStyles(props); const anchor = React.useRef(); - const [isFilterMenuOpened, setFilterMenuOpened] = React.useState(false); + const [isFilterMenuOpened, setFilterMenuOpened] = useState(false); + const [filterErrors, setFilterErrors] = useState([]); const [data, dispatch, reset] = useFilter(menu); const isFilterActive = menu.some(filterElement => filterElement.active); + const handleSubmit = () => { + const invalidFilters = extractInvalidFilters(data, menu); + + if (!!invalidFilters.length) { + const parsedFilterErrors = invalidFilters.map(({ name }) => name); + setFilterErrors(parsedFilterErrors); + return; + } + + setFilterErrors([]); + onFilterAdd(data); + setFilterMenuOpened(false); + }; + return ( { @@ -120,10 +137,17 @@ const Filter: React.FC = props => { <> - {menu.reduce( - (acc, filterElement) => acc + (filterElement.active ? 1 : 0), - 0 - )} + {menu.reduce((acc, filterElement) => { + const dataFilterElement = data.find( + ({ name }) => name === filterElement.name + ); + + if (!dataFilterElement) { + return acc; + } + + return acc + (dataFilterElement.active ? 1 : 0); + }, 0)} )} @@ -157,14 +181,14 @@ const Filter: React.FC = props => { }} > { - onFilterAdd(data); - setFilterMenuOpened(false); - }} + onSubmit={handleSubmit} /> )} diff --git a/src/components/Filter/FilterAutocompleteField.tsx b/src/components/Filter/FilterAutocompleteField.tsx index 2868bc76b..d756c6653 100644 --- a/src/components/Filter/FilterAutocompleteField.tsx +++ b/src/components/Filter/FilterAutocompleteField.tsx @@ -13,12 +13,16 @@ import { MultiAutocompleteChoiceType } from "../MultiAutocompleteSelectField"; import { FilterBaseFieldProps } from "./types"; interface FilterAutocompleteFieldProps extends FilterBaseFieldProps { - displayValues: Record; - setDisplayValues: ( - values: Record - ) => void; + displayValues: FilterAutocompleteDisplayValues; + setDisplayValues: (values: FilterAutocompleteDisplayValues) => void; + initialDisplayValues: FilterAutocompleteDisplayValues; } +export type FilterAutocompleteDisplayValues = Record< + string, + MultiAutocompleteChoiceType[] +>; + const useStyles = makeStyles( theme => ({ hr: { @@ -29,7 +33,8 @@ const useStyles = makeStyles( padding: "12px 0 9px 12px" }, inputContainer: { - marginBottom: theme.spacing(1) + marginBottom: theme.spacing(1), + paddingTop: theme.spacing(1) }, noResults: { marginTop: theme.spacing(1) @@ -51,11 +56,13 @@ const FilterAutocompleteField: React.FC = ({ filterField, setDisplayValues, onFilterPropertyChange, + initialDisplayValues, ...rest }) => { const classes = useStyles({}); const fieldDisplayValues = displayValues[filterField.name]; + const initialFieldDisplayValues = initialDisplayValues[filterField.name]; const availableOptions = filterField.options.filter(option => fieldDisplayValues.every( displayValue => displayValue.value !== option.value @@ -63,11 +70,6 @@ const FilterAutocompleteField: React.FC = ({ ); const displayNoResults = availableOptions.length === 0 && fieldDisplayValues.length === 0; - const displayHr = !( - (fieldDisplayValues.length === 0 && availableOptions.length > 0) || - (availableOptions.length === 0 && fieldDisplayValues.length > 0) || - displayNoResults - ); const handleChange = (option: MultiAutocompleteChoiceType) => { onFilterPropertyChange({ @@ -90,6 +92,19 @@ const FilterAutocompleteField: React.FC = ({ }); }; + const isValueChecked = (displayValue: MultiAutocompleteChoiceType) => + filterField.value.includes(displayValue.value); + + const filteredValuesChecked = initialFieldDisplayValues.filter( + isValueChecked + ); + + const filteredValuesUnchecked = fieldDisplayValues.filter( + displayValue => !isValueChecked(displayValue) + ); + + const displayHr = !!filteredValuesChecked.length; + return (
= ({ }} onChange={event => filterField.onSearchChange(event.target.value)} /> - {fieldDisplayValues.map(displayValue => ( + {filteredValuesChecked.map(displayValue => (
= ({ )} - {availableOptions.map(option => ( + {filteredValuesUnchecked.map(option => (
{ - filters: IFilter; - onFilterPropertyChange: React.Dispatch>; - onClear: () => void; - onSubmit: () => void; - currencySymbol?: string; -} - -const useStyles = makeStyles( - theme => ({ - actionBar: { - alignItems: "center", - display: "flex", - justifyContent: "space-between", - padding: theme.spacing(1, 3) - }, - andLabel: { - margin: theme.spacing(0, 2) - }, - arrow: { - marginRight: theme.spacing(2) - }, - clear: { - marginRight: theme.spacing(1) - }, - filterFieldBar: { - "&:not(:last-of-type)": { - borderBottom: `1px solid ${theme.palette.divider}` - }, - padding: theme.spacing(1, 2.5) - }, - filterSettings: { - background: fade(theme.palette.primary.main, 0.2), - padding: theme.spacing(2, 3) - }, - input: { - padding: "12px 0 9px 12px" - }, - inputRange: { - alignItems: "center", - display: "flex" - }, - label: { - fontWeight: 600 - }, - option: { - left: -theme.spacing(0.5), - position: "relative" - }, - optionRadio: { - left: -theme.spacing(0.25) - } - }), - { name: "FilterContent" } -); - -function getIsFilterMultipleChoices( - intl: IntlShape -): SingleAutocompleteChoiceType[] { - return [ - { - label: intl.formatMessage({ - defaultMessage: "equal to", - description: "is filter range or value" - }), - value: FilterType.SINGULAR - }, - { - label: intl.formatMessage({ - defaultMessage: "between", - description: "is filter range or value" - }), - value: FilterType.MULTIPLE - } - ]; -} - -const filterFieldTestingContext = "filter-field"; - -const FilterContent: React.FC = ({ - currencySymbol, - filters, - onClear, - onFilterPropertyChange, - onSubmit -}) => { - const intl = useIntl(); - const classes = useStyles({}); - const [ - autocompleteDisplayValues, - setAutocompleteDisplayValues - ] = useStateFromProps>( - filters.reduce((acc, filterField) => { - if (filterField.type === FieldType.autocomplete) { - acc[filterField.name] = filterField.displayValues; - } - - return acc; - }, {}) - ); - - return ( - -
{ - event.preventDefault(); - onSubmit(); - }} - > -
- - - -
- - -
-
-
- {filters - .filter(filter => !!filter) - .sort((a, b) => (a.name > b.name ? 1 : -1)) - .map(filterField => ( - -
- - } - label={filterField.label} - onChange={() => - onFilterPropertyChange({ - payload: { - name: filterField.name, - update: { - active: !filterField.active - } - }, - type: "set-property" - }) - } - /> -
- {filterField.active && ( -
- {filterField.type === FieldType.text && ( - - onFilterPropertyChange({ - payload: { - name: filterField.name, - update: { - value: [event.target.value, filterField.value[1]] - } - }, - type: "set-property" - }) - } - /> - )} - {[FieldType.date, FieldType.price, FieldType.number].includes( - filterField.type - ) && ( - <> - - onFilterPropertyChange({ - payload: { - name: filterField.name, - update: { - multiple: - event.target.value === FilterType.MULTIPLE - } - }, - type: "set-property" - }) - } - /> - -
-
- -
- {filterField.multiple ? ( - <> - - onFilterPropertyChange({ - payload: { - name: filterField.name, - update: { - value: [ - event.target.value, - filterField.value[1] - ] - } - }, - type: "set-property" - }) - } - /> - - - - - onFilterPropertyChange({ - payload: { - name: filterField.name, - update: { - value: [ - filterField.value[0], - event.target.value - ] - } - }, - type: "set-property" - }) - } - /> - - ) : ( - - onFilterPropertyChange({ - payload: { - name: filterField.name, - update: { - value: [ - event.target.value, - filterField.value[1] - ] - } - }, - type: "set-property" - }) - } - /> - )} -
- - )} - {filterField.type === FieldType.options && ( - - )} - {filterField.type === FieldType.boolean && - filterField.options.map(option => ( -
- - } - label={option.label} - name={filterField.name} - onChange={() => - onFilterPropertyChange({ - payload: { - name: filterField.name, - update: { - value: [option.value] - } - }, - type: "set-property" - }) - } - /> -
- ))} - {filterField.type === FieldType.autocomplete && - filterField.multiple && ( - - )} -
- )} -
- ))} -
-
- ); -}; -FilterContent.displayName = "FilterContent"; -export default FilterContent; diff --git a/src/components/Filter/FilterContent/FilterContent.tsx b/src/components/Filter/FilterContent/FilterContent.tsx new file mode 100644 index 000000000..3d587e465 --- /dev/null +++ b/src/components/Filter/FilterContent/FilterContent.tsx @@ -0,0 +1,158 @@ +import { Typography } from "@material-ui/core"; +import Paper from "@material-ui/core/Paper"; +import CollectionWithDividers from "@saleor/components/CollectionWithDividers"; +import Hr from "@saleor/components/Hr"; +import useStateFromProps from "@saleor/hooks/useStateFromProps"; +import React from "react"; + +import { FilterAutocompleteDisplayValues } from "../FilterAutocompleteField"; +import { FilterReducerAction } from "../reducer"; +import { + FieldType, + FilterErrorMessages, + FilterErrors, + IFilter, + IFilterElement +} from "../types"; +import FilterContentBody, { FilterContentBodyProps } from "./FilterContentBody"; +import FilterContentBodyNameField from "./FilterContentBodyNameField"; +import FilterContentHeader from "./FilterContentHeader"; +import FilterErrorsList from "./FilterErrorsList"; + +export interface FilterContentProps { + filters: IFilter; + onFilterPropertyChange: React.Dispatch>; + onClear: () => void; + onSubmit: () => void; + currencySymbol?: string; + dataStructure: IFilter; + errors?: FilterErrors; + errorMessages?: FilterErrorMessages; +} + +const FilterContent: React.FC = ({ + currencySymbol, + errors, + errorMessages, + filters, + onClear, + onFilterPropertyChange, + onSubmit, + dataStructure +}) => { + const getAutocompleteValuesWithNewValues = ( + autocompleteDisplayValues: FilterAutocompleteDisplayValues, + filterField: IFilterElement + ) => { + if (filterField.type === FieldType.autocomplete) { + return { + ...autocompleteDisplayValues, + [filterField.name]: filterField.options + }; + } + + return autocompleteDisplayValues; + }; + + const initialAutocompleteDisplayValues = filters.reduce( + (acc, filterField) => { + if (filterField.multipleFields) { + return filterField.multipleFields.reduce( + getAutocompleteValuesWithNewValues, + {} + ); + } + + return getAutocompleteValuesWithNewValues(acc, filterField); + }, + {} + ); + + const [ + autocompleteDisplayValues, + setAutocompleteDisplayValues + ] = useStateFromProps( + initialAutocompleteDisplayValues + ); + + const commonFilterBodyProps: Omit< + FilterContentBodyProps, + "filter" | "onFilterPropertyChange" + > = { + currencySymbol, + autocompleteDisplayValues, + setAutocompleteDisplayValues, + initialAutocompleteDisplayValues + }; + + const handleMultipleFieldPropertyChange = function( + action: FilterReducerAction + ) { + const { update } = action.payload; + + onFilterPropertyChange({ + ...action, + payload: { ...action.payload, update: { ...update, active: true } } + }); + }; + + const getFilterFromCurrentData = function( + filter: IFilterElement + ) { + return filters.find(({ name }) => filter.name === name); + }; + + return ( + +
{ + event.preventDefault(); + onSubmit(); + }} + > + +
+ {dataStructure + .sort((a, b) => (a.name > b.name ? 1 : -1)) + .map(filter => ( + + + + {filter.multipleFields ? ( + ( + + {filterField.label} + + )} + /> + ) : ( + + )} + + ))} + +
+ ); +}; +FilterContent.displayName = "FilterContent"; +export default FilterContent; diff --git a/src/components/Filter/FilterContent/FilterContentBody.tsx b/src/components/Filter/FilterContent/FilterContentBody.tsx new file mode 100644 index 000000000..651e86414 --- /dev/null +++ b/src/components/Filter/FilterContent/FilterContentBody.tsx @@ -0,0 +1,298 @@ +import { + fade, + FormControlLabel, + makeStyles, + Radio, + TextField +} from "@material-ui/core"; +import FormSpacer from "@saleor/components/FormSpacer"; +import { MultiAutocompleteChoiceType } from "@saleor/components/MultiAutocompleteSelectField"; +import SingleSelectField from "@saleor/components/SingleSelectField"; +import classNames from "classnames"; +import React from "react"; +import { FormattedMessage, useIntl } from "react-intl"; + +import Arrow from "../Arrow"; +import FilterAutocompleteField, { + FilterAutocompleteDisplayValues +} from "../FilterAutocompleteField"; +import FilterOptionField from "../FilterOptionField"; +import { FilterReducerAction } from "../reducer"; +import { FieldType, FilterType, IFilterElement } from "../types"; +import { getIsFilterMultipleChoices } from "./utils"; + +const useStyles = makeStyles( + theme => ({ + andLabel: { + margin: theme.spacing(0, 2) + }, + arrow: { + marginRight: theme.spacing(2) + }, + filterSettings: { + background: fade(theme.palette.primary.main, 0.1), + padding: theme.spacing(2, 3) + }, + input: { + padding: "12px 0 9px 12px" + }, + inputRange: { + alignItems: "center", + display: "flex" + }, + + option: { + left: -theme.spacing(0.5), + position: "relative" + }, + optionRadio: { + left: -theme.spacing(0.25) + } + }), + { name: "FilterContentBody" } +); + +const filterTestingContext = "filter-field"; + +export interface FilterContentBodyProps { + children?: React.ReactNode; + filter: IFilterElement; + currencySymbol?: string; + initialAutocompleteDisplayValues: FilterAutocompleteDisplayValues; + onFilterPropertyChange: React.Dispatch>; + autocompleteDisplayValues: FilterAutocompleteDisplayValues; + setAutocompleteDisplayValues: React.Dispatch< + React.SetStateAction> + >; +} + +const FilterContentBody: React.FC = ({ + filter, + children, + currencySymbol, + onFilterPropertyChange, + autocompleteDisplayValues, + setAutocompleteDisplayValues, + initialAutocompleteDisplayValues +}) => { + const intl = useIntl(); + const classes = useStyles({}); + + if (!filter?.active) { + return null; + } + + return ( +
+ {children} + {filter.type === FieldType.text && ( + + onFilterPropertyChange({ + payload: { + name: filter.name, + update: { + value: [event.target.value, filter.value[1]] + } + }, + type: "set-property" + }) + } + /> + )} + {[FieldType.date, FieldType.price, FieldType.number].includes( + filter.type + ) && ( + <> + + onFilterPropertyChange({ + payload: { + name: filter.name, + update: { + multiple: event.target.value === FilterType.MULTIPLE + } + }, + type: "set-property" + }) + } + /> + +
+
+ +
+ {filter.multiple ? ( + <> + + onFilterPropertyChange({ + payload: { + name: filter.name, + update: { + value: [event.target.value, filter.value[1]] + } + }, + type: "set-property" + }) + } + /> + + + + + onFilterPropertyChange({ + payload: { + name: filter.name, + update: { + value: [filter.value[0], event.target.value] + } + }, + type: "set-property" + }) + } + /> + + ) : ( + + onFilterPropertyChange({ + payload: { + name: filter.name, + update: { + value: [event.target.value, filter.value[1]] + } + }, + type: "set-property" + }) + } + /> + )} +
+ + )} + {filter.type === FieldType.options && ( + + )} + {filter.type === FieldType.boolean && + filter.options.map(option => ( +
+ + } + label={option.label} + name={filter.name} + onChange={() => + onFilterPropertyChange({ + payload: { + name: filter.name, + update: { + value: [option.value] + } + }, + type: "set-property" + }) + } + /> +
+ ))} + {filter.type === FieldType.autocomplete && filter.multiple && ( + + )} +
+ ); +}; + +export default FilterContentBody; diff --git a/src/components/Filter/FilterContent/FilterContentBodyNameField.tsx b/src/components/Filter/FilterContent/FilterContentBodyNameField.tsx new file mode 100644 index 000000000..6bd6849de --- /dev/null +++ b/src/components/Filter/FilterContent/FilterContentBodyNameField.tsx @@ -0,0 +1,61 @@ +import { Checkbox, FormControlLabel, makeStyles } from "@material-ui/core"; +import React from "react"; + +import { FilterReducerAction } from "../reducer"; +import { IFilterElement } from "../types"; + +const useStyles = makeStyles( + theme => ({ + container: { + "&:not(:last-of-type)": { + borderBottom: `1px solid ${theme.palette.divider}` + }, + padding: theme.spacing(1, 2.5) + } + }), + { name: "FilterContentBodyNameField" } +); + +export interface FilterContentBodyNameFieldProps { + filter: IFilterElement; + onFilterPropertyChange: React.Dispatch>; +} + +const FilterContentBodyNameField: React.FC = ({ + filter, + onFilterPropertyChange +}) => { + const classes = useStyles({}); + + if (!filter) { + return null; + } + + return ( +
+ + } + label={filter.label} + onChange={() => + onFilterPropertyChange({ + payload: { + name: filter.name, + update: { + active: !filter.active + } + }, + type: "set-property" + }) + } + /> +
+ ); +}; + +export default FilterContentBodyNameField; diff --git a/src/components/Filter/FilterContent/FilterContentHeader.tsx b/src/components/Filter/FilterContent/FilterContentHeader.tsx new file mode 100644 index 000000000..7b704ea95 --- /dev/null +++ b/src/components/Filter/FilterContent/FilterContentHeader.tsx @@ -0,0 +1,55 @@ +import { Button, makeStyles, Typography } from "@material-ui/core"; +import { buttonMessages } from "@saleor/intl"; +import React from "react"; +import { FormattedMessage } from "react-intl"; + +const useStyles = makeStyles( + theme => ({ + container: { + alignItems: "center", + display: "flex", + justifyContent: "space-between", + padding: theme.spacing(1, 3) + }, + clear: { + marginRight: theme.spacing(1) + }, + label: { + fontWeight: 600 + } + }), + { name: "FilterContentHeader" } +); + +interface FilterContentHeaderProps { + onClear: () => void; +} + +const FilterContentHeader: React.FC = ({ + onClear +}) => { + const classes = useStyles({}); + + return ( +
+ + + +
+ + +
+
+ ); +}; + +export default FilterContentHeader; diff --git a/src/components/Filter/FilterContent/FilterErrorsList.tsx b/src/components/Filter/FilterContent/FilterErrorsList.tsx new file mode 100644 index 000000000..1d8e57525 --- /dev/null +++ b/src/components/Filter/FilterContent/FilterErrorsList.tsx @@ -0,0 +1,80 @@ +import { fade, makeStyles } from "@material-ui/core/styles"; +import Typography from "@material-ui/core/Typography"; +import InlineAlert from "@saleor/components/Alert/InlineAlert"; +import { useStyles as useDotStyles } from "@saleor/components/StatusLabel"; +import classNames from "classnames"; +import React from "react"; +import { useIntl } from "react-intl"; + +import { FilterErrorMessages, FilterErrors, IFilterElement } from "../types"; + +const useStyles = makeStyles( + theme => ({ + container: { + backgroundColor: fade(theme.palette.primary.main, 0.1), + padding: theme.spacing(3, 3, 0, 3) + }, + listItemTitle: { + color: theme.palette.primary.contrastText + }, + dot: { + backgroundColor: theme.palette.primary.contrastText, + marginRight: theme.spacing(1) + }, + itemContainer: { + display: "flex", + alignItems: "center" + } + }), + { name: "FilterErrorsList" } +); + +interface FilterErrorsListProps { + filter: IFilterElement; + errors?: FilterErrors; + errorMessages?: FilterErrorMessages; +} + +const FilterErrorsList: React.FC = ({ + filter: { name, multipleFields }, + errors = [], + errorMessages +}) => { + const classes = useStyles({}); + const dotClasses = useDotStyles({}); + const intl = useIntl(); + + const hasError = (fieldName: string) => + !!errors.find(errorName => errorName === fieldName); + + const hasErrorsToShow = () => { + if (!!multipleFields?.length) { + return multipleFields.some(multipleField => hasError(multipleField.name)); + } + + return hasError(name); + }; + + if (!errors.length || !hasErrorsToShow()) { + return null; + } + + return ( +
+ {!!errors.length && ( + + {errors.map(fieldName => ( +
+
+ + {intl.formatMessage(errorMessages?.[fieldName])} + +
+ ))} + + )} +
+ ); +}; + +export default FilterErrorsList; diff --git a/src/components/Filter/FilterContent/index.tsx b/src/components/Filter/FilterContent/index.tsx new file mode 100644 index 000000000..21dd6c64b --- /dev/null +++ b/src/components/Filter/FilterContent/index.tsx @@ -0,0 +1,2 @@ +export * from "./FilterContent"; +export { default } from "./FilterContent"; diff --git a/src/components/Filter/FilterContent/utils.ts b/src/components/Filter/FilterContent/utils.ts new file mode 100644 index 000000000..961e30a81 --- /dev/null +++ b/src/components/Filter/FilterContent/utils.ts @@ -0,0 +1,27 @@ +import { SingleAutocompleteChoiceType } from "@saleor/components/SingleAutocompleteSelectField"; +import { IntlShape } from "react-intl"; + +import { FilterType } from "../types"; + +export function getIsFilterMultipleChoices( + intl: IntlShape +): SingleAutocompleteChoiceType[] { + return [ + { + label: intl.formatMessage({ + defaultMessage: "equal to", + description: "is filter range or value", + id: "is filter range equal to value" + }), + value: FilterType.SINGULAR + }, + { + label: intl.formatMessage({ + defaultMessage: "between", + description: "is filter range or value", + id: "is filter range between value" + }), + value: FilterType.MULTIPLE + } + ]; +} diff --git a/src/components/Filter/types.ts b/src/components/Filter/types.ts index f872a87f1..f157283c6 100644 --- a/src/components/Filter/types.ts +++ b/src/components/Filter/types.ts @@ -1,4 +1,5 @@ import { FetchMoreProps, SearchPageProps } from "@saleor/types"; +import { MessageDescriptor } from "react-intl"; import { MultiAutocompleteChoiceType } from "../MultiAutocompleteSelectField"; import { FilterReducerAction } from "./reducer"; @@ -28,7 +29,9 @@ export interface IFilterElement group?: T; label: string; name: T; - type: FieldType; + type?: FieldType; + required?: boolean; + multipleFields?: IFilterElement[]; } export interface FilterBaseFieldProps { @@ -36,6 +39,13 @@ export interface FilterBaseFieldProps { onFilterPropertyChange: React.Dispatch>; } +export type FilterErrors = string[]; + +export type FilterErrorMessages = Record< + T, + MessageDescriptor +>; + export type IFilter = Array>; export enum FilterType { diff --git a/src/components/Filter/useFilter.ts b/src/components/Filter/useFilter.ts index 0816cab13..925605bc6 100644 --- a/src/components/Filter/useFilter.ts +++ b/src/components/Filter/useFilter.ts @@ -9,15 +9,31 @@ export type UseFilter = [ () => void ]; +function getParsedInitialFilter( + initialFilter: IFilter +): IFilter { + return initialFilter.reduce((resultFilter, filterField) => { + if (filterField.multipleFields) { + return resultFilter + .concat(filterField.multipleFields) + .concat([filterField]); + } + + return resultFilter.concat(filterField); + }, []); +} + function useFilter(initialFilter: IFilter): UseFilter { + const parsedInitialFilter = getParsedInitialFilter(initialFilter); + const [data, dispatchFilterAction] = useReducer< React.Reducer, FilterReducerAction> - >(reduceFilter, initialFilter); + >(reduceFilter, parsedInitialFilter); const reset = () => dispatchFilterAction({ payload: { - new: initialFilter.map(each => ({ + new: parsedInitialFilter.map(each => ({ ...each, active: false, value: [] @@ -29,7 +45,7 @@ function useFilter(initialFilter: IFilter): UseFilter { const refresh = () => dispatchFilterAction({ payload: { - new: initialFilter + new: parsedInitialFilter }, type: "merge" }); diff --git a/src/components/Filter/utils.ts b/src/components/Filter/utils.ts new file mode 100644 index 000000000..371fb3b7d --- /dev/null +++ b/src/components/Filter/utils.ts @@ -0,0 +1,68 @@ +import compact from "lodash-es/compact"; + +import { FieldType, IFilterElement } from "./types"; + +export const getByName = (nameToCompare: string) => (obj: { name: string }) => + obj.name === nameToCompare; + +export const isAutocompleteFilterFieldValid = function({ + value +}: IFilterElement) { + return !!compact(value).length; +}; + +export const isFilterFieldValid = function( + filter: IFilterElement +) { + const { type } = filter; + + switch (type) { + case FieldType.boolean: + case FieldType.autocomplete: + return isAutocompleteFilterFieldValid(filter); + + default: + return true; + } +}; + +export const isFilterValid = function( + resultFilters: Array>, + filter: IFilterElement +) { + const { required, active } = filter; + + if (!required || !active) { + return resultFilters; + } + + return isFilterFieldValid(filter) + ? resultFilters + : [...resultFilters, filter]; +}; + +export const extractInvalidFilters = function( + filtersData: Array>, + filtersDataStructure: Array> +) { + return filtersDataStructure.reduce( + (resultFilters, { name, multipleFields }) => { + const filter = filtersData.find(getByName(name)); + + const shouldExtractChildrenFields = + filter.active && !!multipleFields?.length; + + if (shouldExtractChildrenFields) { + return multipleFields + .map(field => { + const dataField = filtersData.find(getByName(field.name)); + return { ...dataField, active: true }; + }) + .reduce(isFilterValid, resultFilters); + } + + return isFilterValid(resultFilters, filter); + }, + [] + ); +}; diff --git a/src/components/FilterBar/FilterBar.tsx b/src/components/FilterBar/FilterBar.tsx index 1d85f1ac1..8405825c9 100644 --- a/src/components/FilterBar/FilterBar.tsx +++ b/src/components/FilterBar/FilterBar.tsx @@ -5,7 +5,7 @@ import { FormattedMessage, useIntl } from "react-intl"; import { FilterProps } from "../../types"; import Filter from "../Filter"; -import { IFilter } from "../Filter/types"; +import { FilterErrorMessages, IFilter } from "../Filter/types"; import { SearchBarProps } from "../SearchBar"; import SearchInput from "../SearchBar/SearchInput"; import FilterTabs, { FilterTab } from "../TableFilter"; @@ -13,6 +13,7 @@ import FilterTabs, { FilterTab } from "../TableFilter"; export interface FilterBarProps extends FilterProps, SearchBarProps { + errorMessages?: FilterErrorMessages; filterStructure: IFilter; } @@ -49,7 +50,8 @@ const FilterBar: React.FC = props => { onFilterChange, onTabChange, onTabDelete, - onTabSave + onTabSave, + errorMessages } = props; const classes = useStyles(props); @@ -84,6 +86,7 @@ const FilterBar: React.FC = props => {
{ const dot = { borderRadius: "100%", - content: "''", - display: "block", height: 8, - left: -theme.spacing(2), - position: "absolute" as "absolute", - top: "calc(50% - 5px)", width: 8 }; return { + dot, + container: { + display: "flex", + flexDirection: "row", + alignItems: "center" + }, + containerVertical: { + alignItems: "flex-start" + }, + textContainer: { + marginLeft: theme.spacing(1), + display: "flex", + flexDirection: "column" + }, + dotVertical: { + marginTop: theme.spacing(1) + }, alertDot: { - "&:before": { backgroundColor: yellow[500], ...dot } + backgroundColor: yellow[500], + ...dot }, errorDot: { - "&:before": { backgroundColor: theme.palette.error.main, ...dot } + backgroundColor: theme.palette.error.main, + ...dot }, neutralDot: { - "&:before": { backgroundColor: grey[300], ...dot } + backgroundColor: grey[300], + ...dot }, - root: { - display: "inline-block", - marginLeft: theme.spacing(1) + 8, - position: "relative" + successDot: { + backgroundColor: theme.palette.primary.main, + ...dot }, span: { display: "inline" - }, - successDot: { - "&:before": { backgroundColor: theme.palette.primary.main, ...dot } } }; }, @@ -45,41 +57,43 @@ const useStyles = makeStyles( ); interface StatusLabelProps { - className?: string; label: string | React.ReactNode; status: "success" | "alert" | "neutral" | "error" | string; - typographyProps?: TypographyProps; + subtitle?: string; + className?: string; } -const StatusLabel: React.FC = props => { - const { className, label, status, typographyProps } = props; - - const classes = useStyles(props); +const StatusLabel: React.FC = ({ + className, + label, + status, + subtitle +}) => { + const classes = useStyles({}); return (
- {typographyProps ? ( - - {label} - - ) : ( - label - )} +
+
+ {label} + {subtitle &&
); }; -StatusLabel.displayName = "StatusLabel"; + export default StatusLabel; diff --git a/src/components/StatusLabel/messages.ts b/src/components/StatusLabel/messages.ts new file mode 100644 index 000000000..8b9e19047 --- /dev/null +++ b/src/components/StatusLabel/messages.ts @@ -0,0 +1,16 @@ +import { defineMessages } from "react-intl"; + +export const statusLabelMessages = defineMessages({ + active: { + defaultMessage: "Active", + description: "status label active" + }, + inactive: { + defaultMessage: "Inactive", + description: "status label inactive" + }, + deactivated: { + defaultMessage: "Deactivated", + description: "status label deactivated" + } +}); diff --git a/src/fragments/plugins.ts b/src/fragments/plugins.ts index 3a128af56..d88d0e413 100644 --- a/src/fragments/plugins.ts +++ b/src/fragments/plugins.ts @@ -1,11 +1,42 @@ import gql from "graphql-tag"; +export const configurationItemFragment = gql` + fragment ConfigurationItemFragment on ConfigurationItem { + name + value + type + helpText + label + } +`; + +export const pluginConfigurationFragment = gql` + ${configurationItemFragment} + fragment PluginConfigurationFragment on PluginConfiguration { + active + channel { + id + name + slug + } + configuration { + ...ConfigurationItemFragment + } + } +`; + export const pluginsFragment = gql` + ${pluginConfigurationFragment} fragment PluginFragment on Plugin { id name description - active + globalConfiguration { + ...PluginConfigurationFragment + } + channelConfigurations { + ...PluginConfigurationFragment + } } `; @@ -13,12 +44,5 @@ export const pluginsDetailsFragment = gql` ${pluginsFragment} fragment PluginsDetailsFragment on Plugin { ...PluginFragment - configuration { - name - type - value - helpText - label - } } `; diff --git a/src/fragments/types/ConfigurationItemFragment.ts b/src/fragments/types/ConfigurationItemFragment.ts new file mode 100644 index 000000000..81de8d0c1 --- /dev/null +++ b/src/fragments/types/ConfigurationItemFragment.ts @@ -0,0 +1,19 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +import { ConfigurationTypeFieldEnum } from "./../../types/globalTypes"; + +// ==================================================== +// GraphQL fragment: ConfigurationItemFragment +// ==================================================== + +export interface ConfigurationItemFragment { + __typename: "ConfigurationItem"; + name: string; + value: string | null; + type: ConfigurationTypeFieldEnum | null; + helpText: string | null; + label: string | null; +} diff --git a/src/fragments/types/PluginConfiguarionFragment.ts b/src/fragments/types/PluginConfiguarionFragment.ts new file mode 100644 index 000000000..1a16e94dd --- /dev/null +++ b/src/fragments/types/PluginConfiguarionFragment.ts @@ -0,0 +1,33 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +import { ConfigurationTypeFieldEnum } from "./../../types/globalTypes"; + +// ==================================================== +// GraphQL fragment: PluginConfiguarionFragment +// ==================================================== + +export interface PluginConfiguarionFragment_channel { + __typename: "Channel"; + id: string; + name: string; + slug: string; +} + +export interface PluginConfiguarionFragment_configuration { + __typename: "ConfigurationItem"; + name: string; + value: string | null; + type: ConfigurationTypeFieldEnum | null; + helpText: string | null; + label: string | null; +} + +export interface PluginConfiguarionFragment { + __typename: "PluginConfiguration"; + active: boolean; + channel: PluginConfiguarionFragment_channel | null; + configuration: (PluginConfiguarionFragment_configuration | null)[] | null; +} diff --git a/src/fragments/types/PluginConfigurationFragment.ts b/src/fragments/types/PluginConfigurationFragment.ts new file mode 100644 index 000000000..6f75ac9a2 --- /dev/null +++ b/src/fragments/types/PluginConfigurationFragment.ts @@ -0,0 +1,33 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +import { ConfigurationTypeFieldEnum } from "./../../types/globalTypes"; + +// ==================================================== +// GraphQL fragment: PluginConfigurationFragment +// ==================================================== + +export interface PluginConfigurationFragment_channel { + __typename: "Channel"; + id: string; + name: string; + slug: string; +} + +export interface PluginConfigurationFragment_configuration { + __typename: "ConfigurationItem"; + name: string; + value: string | null; + type: ConfigurationTypeFieldEnum | null; + helpText: string | null; + label: string | null; +} + +export interface PluginConfigurationFragment { + __typename: "PluginConfiguration"; + active: boolean; + channel: PluginConfigurationFragment_channel | null; + configuration: (PluginConfigurationFragment_configuration | null)[] | null; +} diff --git a/src/fragments/types/PluginFragment.ts b/src/fragments/types/PluginFragment.ts index c4a323dae..d546aed49 100644 --- a/src/fragments/types/PluginFragment.ts +++ b/src/fragments/types/PluginFragment.ts @@ -3,14 +3,63 @@ // @generated // This file was automatically generated and should not be edited. +import { ConfigurationTypeFieldEnum } from "./../../types/globalTypes"; + // ==================================================== // GraphQL fragment: PluginFragment // ==================================================== +export interface PluginFragment_globalConfiguration_channel { + __typename: "Channel"; + id: string; + name: string; + slug: string; +} + +export interface PluginFragment_globalConfiguration_configuration { + __typename: "ConfigurationItem"; + name: string; + value: string | null; + type: ConfigurationTypeFieldEnum | null; + helpText: string | null; + label: string | null; +} + +export interface PluginFragment_globalConfiguration { + __typename: "PluginConfiguration"; + active: boolean; + channel: PluginFragment_globalConfiguration_channel | null; + configuration: (PluginFragment_globalConfiguration_configuration | null)[] | null; +} + +export interface PluginFragment_channelConfigurations_channel { + __typename: "Channel"; + id: string; + name: string; + slug: string; +} + +export interface PluginFragment_channelConfigurations_configuration { + __typename: "ConfigurationItem"; + name: string; + value: string | null; + type: ConfigurationTypeFieldEnum | null; + helpText: string | null; + label: string | null; +} + +export interface PluginFragment_channelConfigurations { + __typename: "PluginConfiguration"; + active: boolean; + channel: PluginFragment_channelConfigurations_channel | null; + configuration: (PluginFragment_channelConfigurations_configuration | null)[] | null; +} + export interface PluginFragment { __typename: "Plugin"; id: string; name: string; description: string; - active: boolean; + globalConfiguration: PluginFragment_globalConfiguration | null; + channelConfigurations: PluginFragment_channelConfigurations[]; } diff --git a/src/fragments/types/PluginsDetailsFragment.ts b/src/fragments/types/PluginsDetailsFragment.ts index 6aeb21c29..2e4b15f59 100644 --- a/src/fragments/types/PluginsDetailsFragment.ts +++ b/src/fragments/types/PluginsDetailsFragment.ts @@ -9,20 +9,57 @@ import { ConfigurationTypeFieldEnum } from "./../../types/globalTypes"; // GraphQL fragment: PluginsDetailsFragment // ==================================================== -export interface PluginsDetailsFragment_configuration { +export interface PluginsDetailsFragment_globalConfiguration_channel { + __typename: "Channel"; + id: string; + name: string; + slug: string; +} + +export interface PluginsDetailsFragment_globalConfiguration_configuration { __typename: "ConfigurationItem"; name: string; - type: ConfigurationTypeFieldEnum | null; value: string | null; + type: ConfigurationTypeFieldEnum | null; helpText: string | null; label: string | null; } +export interface PluginsDetailsFragment_globalConfiguration { + __typename: "PluginConfiguration"; + active: boolean; + channel: PluginsDetailsFragment_globalConfiguration_channel | null; + configuration: (PluginsDetailsFragment_globalConfiguration_configuration | null)[] | null; +} + +export interface PluginsDetailsFragment_channelConfigurations_channel { + __typename: "Channel"; + id: string; + name: string; + slug: string; +} + +export interface PluginsDetailsFragment_channelConfigurations_configuration { + __typename: "ConfigurationItem"; + name: string; + value: string | null; + type: ConfigurationTypeFieldEnum | null; + helpText: string | null; + label: string | null; +} + +export interface PluginsDetailsFragment_channelConfigurations { + __typename: "PluginConfiguration"; + active: boolean; + channel: PluginsDetailsFragment_channelConfigurations_channel | null; + configuration: (PluginsDetailsFragment_channelConfigurations_configuration | null)[] | null; +} + export interface PluginsDetailsFragment { __typename: "Plugin"; id: string; name: string; description: string; - active: boolean; - configuration: (PluginsDetailsFragment_configuration | null)[] | null; + globalConfiguration: PluginsDetailsFragment_globalConfiguration | null; + channelConfigurations: PluginsDetailsFragment_channelConfigurations[]; } diff --git a/src/hooks/useChannelsSearch.ts b/src/hooks/useChannelsSearch.ts new file mode 100644 index 000000000..394c49f4f --- /dev/null +++ b/src/hooks/useChannelsSearch.ts @@ -0,0 +1,21 @@ +import { Channel_channel } from "@saleor/channels/types/Channel"; +import { FetchMoreProps, Search, SearchProps } from "@saleor/types"; +import { filter } from "fuzzaldrin"; +import React from "react"; + +export const useChannelsSearch = function( + channels: T[] +) { + const [query, onQueryChange] = React.useState(""); + const filteredChannels = + filter(channels, query, { key: "name" }) || []; + + return { query, onQueryChange, filteredChannels }; +}; + +export interface ChannelsWithLoadMoreProps + extends FetchMoreProps, + Search, + SearchProps { + channels: Channel_channel[]; +} diff --git a/src/hooks/useChannelsSearchWithLoadMore.ts b/src/hooks/useChannelsSearchWithLoadMore.ts new file mode 100644 index 000000000..9100415b1 --- /dev/null +++ b/src/hooks/useChannelsSearchWithLoadMore.ts @@ -0,0 +1,66 @@ +import { useBaseChannelsList } from "@saleor/channels/queries"; +import chunk from "lodash-es/chunk"; +import compact from "lodash-es/compact"; +import concat from "lodash-es/concat"; +import { useEffect, useState } from "react"; + +import { + ChannelsWithLoadMoreProps, + useChannelsSearch +} from "./useChannelsSearch"; + +const DEFAULT_ITEMS_PER_PAGE = 6; +const INITIAL_INDEX = 0; + +export const useChannelsSearchWithLoadMore = ( + itemsPerPage: number = DEFAULT_ITEMS_PER_PAGE +): ChannelsWithLoadMoreProps => { + const { data, loading } = useBaseChannelsList({}); + + const { + query, + onQueryChange: onSearchChange, + filteredChannels + } = useChannelsSearch(data?.channels); + + const allChannelsChunks = chunk(filteredChannels, itemsPerPage); + + const [currentIndex, setCurrentIndex] = useState(INITIAL_INDEX); + const [currentChannelsChunks, setCurrentChannelsChunks] = useState([]); + + const handleAddInitialChunk = () => { + if (data?.channels && !loading) { + setCurrentChannelsChunks([allChannelsChunks[INITIAL_INDEX]]); + } + }; + + useEffect(handleAddInitialChunk, [loading, query]); + + const onFetchMore = () => { + if (!hasMore) { + return; + } + + const newIndex = currentIndex + 1; + setCurrentIndex(newIndex); + + const newChunk = allChannelsChunks[newIndex]; + setCurrentChannelsChunks([...currentChannelsChunks, newChunk]); + }; + + const hasMore = allChannelsChunks.length > currentChannelsChunks.length; + + const channels = compact(concat([], ...currentChannelsChunks)); + + const totalCount = data?.channels.length; + + return { + query, + onSearchChange, + channels, + hasMore, + totalCount, + onFetchMore, + loading + }; +}; diff --git a/src/orders/components/OrderAddressEditDialog/OrderAddressEditDialog.tsx b/src/orders/components/OrderAddressEditDialog/OrderAddressEditDialog.tsx index 4f9135646..6f6e73ed9 100644 --- a/src/orders/components/OrderAddressEditDialog/OrderAddressEditDialog.tsx +++ b/src/orders/components/OrderAddressEditDialog/OrderAddressEditDialog.tsx @@ -108,6 +108,7 @@ const OrderAddressEditDialog: React.FC = props => { = props => { ) ) : ( <> - {user.email} + + {user.email} + = props => { {canEditAddresses && (
+ ) + } + status={isStatusActive ? "success" : "error"} + /> + ); +}; + +export default PluginAvailabilityStatus; diff --git a/src/plugins/components/PluginsList/PluginAvailabilityStatusPopup/ChannelConfigPluginPopupBody.tsx b/src/plugins/components/PluginsList/PluginAvailabilityStatusPopup/ChannelConfigPluginPopupBody.tsx new file mode 100644 index 000000000..0830f5c4f --- /dev/null +++ b/src/plugins/components/PluginsList/PluginAvailabilityStatusPopup/ChannelConfigPluginPopupBody.tsx @@ -0,0 +1,76 @@ +import { CardContent, Divider, Typography } from "@material-ui/core"; +import CardSpacer from "@saleor/components/CardSpacer"; +import CollectionWithDividers from "@saleor/components/CollectionWithDividers"; +import StatusLabel from "@saleor/components/StatusLabel"; +import { statusLabelMessages } from "@saleor/components/StatusLabel/messages"; +import { Plugin_plugin } from "@saleor/plugins/types/Plugin"; +import { makeStyles } from "@saleor/theme"; +import React from "react"; +import { useIntl } from "react-intl"; + +import { channelConfigPluginMessages as messages } from "../messages"; +import { + getActiveChannelConfigsCount, + getAllChannelConfigsCount +} from "../utils"; +import ScrollableContent from "./ScrollableContent"; + +const useStyles = makeStyles( + theme => ({ + itemContainer: { + padding: theme.spacing(0, 1) + } + }), + { name: "ChannelConfigPluginPopupBody" } +); + +interface ChannelConfigPluginPopupBodyProps { + plugin: Plugin_plugin; +} + +const ChannelConfigPluginPopupBody: React.FC = ({ + plugin: { channelConfigurations } +}) => { + const intl = useIntl(); + const classes = useStyles({}); + + return ( + <> + + + {intl.formatMessage(messages.title, { + allChannelsCount: getAllChannelConfigsCount(channelConfigurations), + activeChannelsCount: getActiveChannelConfigsCount( + channelConfigurations + ) + })} + + + + + + ( +
+ +
+ )} + /> + +
+ + ); +}; + +export default ChannelConfigPluginPopupBody; diff --git a/src/plugins/components/PluginsList/PluginAvailabilityStatusPopup/GlobalConfigPluginPopupBody.tsx b/src/plugins/components/PluginsList/PluginAvailabilityStatusPopup/GlobalConfigPluginPopupBody.tsx new file mode 100644 index 000000000..086933e73 --- /dev/null +++ b/src/plugins/components/PluginsList/PluginAvailabilityStatusPopup/GlobalConfigPluginPopupBody.tsx @@ -0,0 +1,42 @@ +import { CardContent, Typography } from "@material-ui/core"; +import CardSpacer from "@saleor/components/CardSpacer"; +import StatusLabel from "@saleor/components/StatusLabel"; +import { statusLabelMessages } from "@saleor/components/StatusLabel/messages"; +import { Plugin_plugin } from "@saleor/plugins/types/Plugin"; +import React from "react"; +import { useIntl } from "react-intl"; + +import { globalConfigPluginMessages as messages } from "../messages"; + +interface GlobalConfigPluginPopupBodyProps { + plugin: Plugin_plugin; +} + +const GlobalConfigPluginPopupBody: React.FC = ({ + plugin +}) => { + const intl = useIntl(); + + const { active } = plugin.globalConfiguration; + + return ( + <> + + {intl.formatMessage(messages.title)} + + + {intl.formatMessage(messages.description)} + + + + + + ); +}; + +export default GlobalConfigPluginPopupBody; diff --git a/src/plugins/components/PluginsList/PluginAvailabilityStatusPopup/PluginAvailabilityStatusPopup.tsx b/src/plugins/components/PluginsList/PluginAvailabilityStatusPopup/PluginAvailabilityStatusPopup.tsx new file mode 100644 index 000000000..185b3eff4 --- /dev/null +++ b/src/plugins/components/PluginsList/PluginAvailabilityStatusPopup/PluginAvailabilityStatusPopup.tsx @@ -0,0 +1,52 @@ +import { Card, makeStyles, Popper } from "@material-ui/core"; +import { Plugin_plugin } from "@saleor/plugins/types/Plugin"; +import { isPluginGlobal } from "@saleor/plugins/views/utils"; +import React from "react"; + +import ChannelConfigPluginPopupBody from "./ChannelConfigPluginPopupBody"; +import GlobalConfigPluginPopupBody from "./GlobalConfigPluginPopupBody"; + +const useStyles = makeStyles( + () => ({ + container: { + maxWidth: 300, + zIndex: 1000 + } + }), + { name: "PluginChannelsAvailabilityStatusPopup" } +); + +interface PluginAvailabilityStatusPopupProps { + plugin: Plugin_plugin; + isOpen: boolean; + anchor: React.RefObject; +} + +const PluginAvailabilityStatusPopup: React.FC = ({ + plugin, + isOpen, + anchor +}) => { + const classes = useStyles({}); + + const isGlobalPlugin = isPluginGlobal(plugin.globalConfiguration); + + return ( + + + {isGlobalPlugin ? ( + + ) : ( + + )} + + + ); +}; + +export default PluginAvailabilityStatusPopup; diff --git a/src/plugins/components/PluginsList/PluginAvailabilityStatusPopup/ScrollableContent.tsx b/src/plugins/components/PluginsList/PluginAvailabilityStatusPopup/ScrollableContent.tsx new file mode 100644 index 000000000..ec7968ec3 --- /dev/null +++ b/src/plugins/components/PluginsList/PluginAvailabilityStatusPopup/ScrollableContent.tsx @@ -0,0 +1,29 @@ +import List from "@material-ui/core/List"; +import { makeStyles } from "@saleor/theme"; +import React from "react"; + +const useStyles = makeStyles( + () => ({ + container: { + maxHeight: 450, + overflow: "auto" + } + }), + { name: "ScrollableContent" } +); + +interface ScrollableContentProps { + children: React.ReactNode | React.ReactNode[]; +} + +const ScrollableContent: React.FC = ({ children }) => { + const classes = useStyles({}); + + return ( +
+ {children} +
+ ); +}; + +export default ScrollableContent; diff --git a/src/plugins/components/PluginsList/PluginAvailabilityStatusPopup/index.tsx b/src/plugins/components/PluginsList/PluginAvailabilityStatusPopup/index.tsx new file mode 100644 index 000000000..8d1ed9304 --- /dev/null +++ b/src/plugins/components/PluginsList/PluginAvailabilityStatusPopup/index.tsx @@ -0,0 +1,2 @@ +export * from "./PluginAvailabilityStatusPopup"; +export { default } from "./PluginAvailabilityStatusPopup"; diff --git a/src/plugins/components/PluginsList/PluginChannelAvailabilityCell.tsx b/src/plugins/components/PluginsList/PluginChannelAvailabilityCell.tsx new file mode 100644 index 000000000..e9c0ee65c --- /dev/null +++ b/src/plugins/components/PluginsList/PluginChannelAvailabilityCell.tsx @@ -0,0 +1,46 @@ +import { TableCell } from "@material-ui/core"; +import Skeleton from "@saleor/components/Skeleton"; +import { Plugin_plugin } from "@saleor/plugins/types/Plugin"; +import React, { useRef, useState } from "react"; + +import PluginAvailabilityStatus from "./PluginAvailabilityStatus"; +import PluginAvailabilityStatusPopup from "./PluginAvailabilityStatusPopup"; + +interface PluginChannelAvailabilityCellProps { + plugin: Plugin_plugin; +} + +const PluginChannelAvailabilityCell: React.FC = ({ + plugin +}) => { + const [isPopupOpen, setIsPopupOpen] = useState(false); + const popupAnchor = useRef(null); + + const handleMouseOver = () => setIsPopupOpen(true); + + const handleMouseLeave = () => setIsPopupOpen(false); + + return ( + + {plugin ? ( + <> + + + + ) : ( + + )} + + ); +}; + +export default PluginChannelAvailabilityCell; diff --git a/src/plugins/components/PluginsList/PluginChannelConfigurationCell.tsx b/src/plugins/components/PluginsList/PluginChannelConfigurationCell.tsx new file mode 100644 index 000000000..70d6702e6 --- /dev/null +++ b/src/plugins/components/PluginsList/PluginChannelConfigurationCell.tsx @@ -0,0 +1,27 @@ +import { TableCell } from "@material-ui/core"; +import { Plugin_plugin } from "@saleor/plugins/types/Plugin"; +import { isPluginGlobal } from "@saleor/plugins/views/utils"; +import React from "react"; +import { FormattedMessage } from "react-intl"; + +import { pluginChannelConfigurationCellMessages as messages } from "./messages"; + +interface PluginChannelConfigurationCellProps { + plugin: Plugin_plugin; +} + +const PluginChannelConfigurationCell: React.FC = ({ + plugin +}) => { + const message = isPluginGlobal(plugin.globalConfiguration) + ? messages.globalLabel + : messages.channelLabel; + + return ( + + + + ); +}; + +export default PluginChannelConfigurationCell; diff --git a/src/plugins/components/PluginsList/PluginListTableHead.tsx b/src/plugins/components/PluginsList/PluginListTableHead.tsx new file mode 100644 index 000000000..4e5d8a7ad --- /dev/null +++ b/src/plugins/components/PluginsList/PluginListTableHead.tsx @@ -0,0 +1,52 @@ +import { TableHead } from "@material-ui/core"; +import TableCellHeader from "@saleor/components/TableCellHeader"; +import { PluginListUrlSortField } from "@saleor/plugins/urls"; +import { SortPage } from "@saleor/types"; +import { getArrowDirection } from "@saleor/utils/sort"; +import React from "react"; +import { useIntl } from "react-intl"; + +import { pluginsListTableHeadMessages as messages } from "./messages"; + +type PluginListTableHeadProps = SortPage; + +const PluginListTableHead: React.FC = ({ + sort, + onSort +}) => { + const intl = useIntl(); + + return ( + + onSort(PluginListUrlSortField.name)} + colSpan={5} + > + {intl.formatMessage(messages.nameLabel)} + + onSort(PluginListUrlSortField.active)} + > + {intl.formatMessage(messages.confLabel)} + + + {intl.formatMessage(messages.channelLabel)} + + + + ); +}; + +export default PluginListTableHead; diff --git a/src/plugins/components/PluginsList/PluginsList.tsx b/src/plugins/components/PluginsList/PluginsList.tsx index 63742edfc..8bea96534 100644 --- a/src/plugins/components/PluginsList/PluginsList.tsx +++ b/src/plugins/components/PluginsList/PluginsList.tsx @@ -1,44 +1,25 @@ +import { makeStyles } from "@material-ui/core"; import TableBody from "@material-ui/core/TableBody"; import TableCell from "@material-ui/core/TableCell"; import TableFooter from "@material-ui/core/TableFooter"; -import TableHead from "@material-ui/core/TableHead"; import TableRow from "@material-ui/core/TableRow"; import EditIcon from "@material-ui/icons/Edit"; import ResponsiveTable from "@saleor/components/ResponsiveTable"; import Skeleton from "@saleor/components/Skeleton"; -import StatusLabel from "@saleor/components/StatusLabel"; -import TableCellHeader from "@saleor/components/TableCellHeader"; import TablePagination from "@saleor/components/TablePagination"; -import { translateBoolean } from "@saleor/intl"; -import { maybe, renderCollection } from "@saleor/misc"; +import { renderCollection } from "@saleor/misc"; import { PluginListUrlSortField } from "@saleor/plugins/urls"; -import { makeStyles } from "@saleor/theme"; import { ListProps, SortPage } from "@saleor/types"; -import { getArrowDirection } from "@saleor/utils/sort"; import React from "react"; import { useIntl } from "react-intl"; import { Plugins_plugins_edges_node } from "../../types/Plugins"; +import PluginChannelAvailabilityCell from "./PluginChannelAvailabilityCell"; +import PluginChannelConfigurationCell from "./PluginChannelConfigurationCell"; +import PluginListTableHead from "./PluginListTableHead"; -export interface PluginListProps - extends ListProps, - SortPage { - plugins: Plugins_plugins_edges_node[]; -} - -const useStyles = makeStyles( - theme => ({ - colAction: { - "& svg": { - color: theme.palette.primary.main - }, - textAlign: "right", - width: 200 - }, - colActive: { - width: 200 - }, - colName: {}, +export const useStyles = makeStyles( + () => ({ link: { cursor: "pointer" } @@ -46,7 +27,13 @@ const useStyles = makeStyles( { name: "PluginsList" } ); -const numberOfColumns = 3; +export interface PluginListProps + extends ListProps, + SortPage { + plugins: Plugins_plugins_edges_node[]; +} + +const totalColSpan = 10; const PluginList: React.FC = props => { const { @@ -66,47 +53,11 @@ const PluginList: React.FC = props => { return ( - - onSort(PluginListUrlSortField.name)} - className={classes.colName} - > - {intl.formatMessage({ - defaultMessage: "Name", - description: "plugin name" - })} - - onSort(PluginListUrlSortField.active)} - className={classes.colActive} - > - {intl.formatMessage({ - defaultMessage: "Active", - description: "plugin status" - })} - - - {intl.formatMessage({ - defaultMessage: "Action", - description: "user action bar" - })} - - + = props => { {renderCollection( plugins, - plugin => ( - - - {maybe(() => plugin.name, )} - - - {maybe( - () => ( - - ), + plugin => + plugin ? ( + + {plugin.name} + + + +
+ +
+
+
+ ) : ( + + - )} - - -
- -
-
-
- ), +
+
+ ), () => ( - + {intl.formatMessage({ defaultMessage: "No plugins found" })} diff --git a/src/plugins/components/PluginsList/messages.ts b/src/plugins/components/PluginsList/messages.ts new file mode 100644 index 000000000..6fe8c7e73 --- /dev/null +++ b/src/plugins/components/PluginsList/messages.ts @@ -0,0 +1,56 @@ +import { defineMessages } from "react-intl"; + +export const pluginAvailabilityStatusMessages = defineMessages({ + channelTitle: { + defaultMessage: "Active in {activeChannelsCount}", + description: "plugin channel availability status title" + } +}); + +export const channelConfigPluginMessages = defineMessages({ + title: { + defaultMessage: + "Assigned to {activeChannelsCount} of {allChannelsCount} channels", + description: "channel config plugin status popup title" + } +}); + +export const globalConfigPluginMessages = defineMessages({ + title: { + defaultMessage: "Global Plugin", + description: "global config plugin status popup title", + id: "globalConfigPluginMessages title" + }, + description: { + defaultMessage: + "Global plugins are set across all channels in your ecommerce. Only status is shown for those types of plugins", + description: "global config plugin status popup description" + } +}); + +export const pluginsListTableHeadMessages = defineMessages({ + nameLabel: { + defaultMessage: "Name", + description: "table header name col label" + }, + confLabel: { + defaultMessage: "Configuration", + description: "table header configuration col label" + }, + channelLabel: { + defaultMessage: "Channel", + description: "table header channel col label" + } +}); + +export const pluginChannelConfigurationCellMessages = defineMessages({ + globalLabel: { + defaultMessage: "Global", + description: "PluginChannelConfigurationCell global title" + }, + channelLabel: { + defaultMessage: "Per channel", + description: "PluginChannelConfigurationCell channel title", + id: "pluginChannelConfigurationCellMessages per channel" + } +}); diff --git a/src/plugins/components/PluginsList/utils.ts b/src/plugins/components/PluginsList/utils.ts new file mode 100644 index 000000000..4c6f9775c --- /dev/null +++ b/src/plugins/components/PluginsList/utils.ts @@ -0,0 +1,9 @@ +import { Plugin_plugin_channelConfigurations } from "@saleor/plugins/types/Plugin"; + +export const getAllChannelConfigsCount = ( + channelConfigurations: Plugin_plugin_channelConfigurations[] +) => channelConfigurations?.length; + +export const getActiveChannelConfigsCount = ( + channelConfigurations: Plugin_plugin_channelConfigurations[] +) => channelConfigurations?.filter(({ active }) => !!active).length; diff --git a/src/plugins/components/PluginsListPage/PluginsListPage.tsx b/src/plugins/components/PluginsListPage/PluginsListPage.tsx index 47072e22d..5fec3ef02 100644 --- a/src/plugins/components/PluginsListPage/PluginsListPage.tsx +++ b/src/plugins/components/PluginsListPage/PluginsListPage.tsx @@ -21,6 +21,7 @@ import { PluginFilterKeys, PluginListFilterOpts } from "./filters"; +import { pluginsFilterErrorMessages } from "./messages"; export interface PluginsListPageProps extends PageListProps, @@ -58,6 +59,7 @@ const PluginsListPage: React.FC = ({ ; + channels: FilterOpts & AutocompleteFilterOpts; + type: FilterOpts; + status: FilterOpts; } const messages = defineMessages({ - active: { - defaultMessage: "Active", - description: "plugin" + channelStatusSectionTitle: { + defaultMessage: "Status in channel", + description: "status section title" }, - deactivated: { - defaultMessage: "Inactive", - description: "plugin" + channelStatusSectionSubtitle: { + defaultMessage: "Channel status", + description: "status section subtitle" + }, + configTypeSectionTitle: { + defaultMessage: "Configuration Type", + description: "config type section title" } }); @@ -29,16 +47,65 @@ export function createFilterStructure( ): IFilter { return [ { - ...createBooleanField( - PluginFilterKeys.active, - intl.formatMessage(commonMessages.status), - opts.isActive.value, + active: opts.status.active, + name: PluginFilterKeys.status, + label: intl.formatMessage(messages.channelStatusSectionTitle), + multipleFields: [ { - negative: intl.formatMessage(messages.deactivated), - positive: intl.formatMessage(messages.active) + required: true, + ...createBooleanField( + PluginFilterKeys.active, + intl.formatMessage(messages.channelStatusSectionSubtitle), + opts.isActive.value, + { + negative: intl.formatMessage(statusLabelMessages.inactive), + positive: intl.formatMessage(statusLabelMessages.active) + } + ) + }, + { + required: true, + ...createAutocompleteField( + PluginFilterKeys.channels, + intl.formatMessage(sectionNames.channels), + opts.channels.value, + opts.channels.displayValues, + true, + opts.channels.choices, + { + hasMore: opts.channels.hasMore, + initialSearch: "", + loading: opts.channels.loading, + onFetchMore: opts.channels.onFetchMore, + onSearchChange: opts.channels.onSearchChange + } + ) } - ), - active: opts.isActive.active + ] + } as IFilterElement, + { + active: opts.type.active, + name: PluginFilterKeys.type, + ...createOptionsField( + PluginFilterKeys.type, + intl.formatMessage(messages.configTypeSectionTitle), + [], + false, + [ + { + value: PluginConfigurationType.GLOBAL, + label: intl.formatMessage( + pluginChannelConfigurationCellMessages.globalLabel + ) + }, + { + value: PluginConfigurationType.PER_CHANNEL, + label: intl.formatMessage( + pluginChannelConfigurationCellMessages.channelLabel + ) + } + ] + ) } ]; } diff --git a/src/plugins/components/PluginsListPage/messages.ts b/src/plugins/components/PluginsListPage/messages.ts new file mode 100644 index 000000000..b502a559e --- /dev/null +++ b/src/plugins/components/PluginsListPage/messages.ts @@ -0,0 +1,12 @@ +import { defineMessages } from "react-intl"; + +export const pluginsFilterErrorMessages = defineMessages({ + active: { + defaultMessage: "Active is not selected", + description: "plugin filters error messages active" + }, + channels: { + defaultMessage: "No channels selected", + description: "plugin filters error messages channels" + } +}); diff --git a/src/plugins/fixtures.ts b/src/plugins/fixtures.ts index e1608aa5d..f9af2459f 100644 --- a/src/plugins/fixtures.ts +++ b/src/plugins/fixtures.ts @@ -6,7 +6,20 @@ import { Plugins_plugins_edges_node } from "./types/Plugins"; export const pluginList: Plugins_plugins_edges_node[] = [ { __typename: "Plugin", - active: true, + globalConfiguration: null, + channelConfigurations: [ + { + __typename: "PluginConfiguration", + active: true, + configuration: [], + channel: { + __typename: "Channel", + id: "channel-1", + name: "channel 1", + slug: "channel-1" + } + } + ], description: "Lorem ipsum dolor sit amet enim. Etiam ullamcorper. Suspendisse a pellentesque dui, non felis. Maecenas malesuada elit lectus felis, malesuada ultricies. Curabitur et ligula. Ut molestie a, ultricies porta urna. Vestibulum commodo volutpat a, convallis ac, laoreet enim. Phasellus fermentum in, dolor. Pellentesque facilisis. Nulla imperdiet sit amet magna.", id: "Jzx123sEt==", @@ -14,7 +27,20 @@ export const pluginList: Plugins_plugins_edges_node[] = [ }, { __typename: "Plugin", - active: false, + globalConfiguration: null, + channelConfigurations: [ + { + __typename: "PluginConfiguration", + active: true, + configuration: [], + channel: { + __typename: "Channel", + id: "channel-1", + name: "channel 1", + slug: "channel-1" + } + } + ], description: "Lorem ipsum dolor sit amet enim. Etiam ullamcorper. Suspendisse a pellentesque dui, non felis. Maecenas malesuada elit lectus felis, malesuada ultricies. Curabitur et ligula. Ut molestie a, ultricies porta urna. Vestibulum commodo volutpat a, convallis ac, laoreet enim. Phasellus fermentum in, dolor. Pellentesque facilisis. Nulla imperdiet sit amet magna.", id: "Jzx123sEt==", @@ -23,64 +49,76 @@ export const pluginList: Plugins_plugins_edges_node[] = [ ]; export const plugin: Plugin_plugin = { __typename: "Plugin", - active: true, - configuration: [ + globalConfiguration: null, + channelConfigurations: [ { - __typename: "ConfigurationItem", - helpText: "Provide user or account details", - label: "Username or account", - name: "Username or account", - type: ConfigurationTypeFieldEnum.STRING, - value: "avatax_user" - }, - { - __typename: "ConfigurationItem", - helpText: "Provide password or license details", - label: "Password or license", - name: "Password or license", - type: ConfigurationTypeFieldEnum.STRING, - value: "TEM8S2-2ET83-CGKP1-DPSI2-EPZO1" - }, - { - __typename: "ConfigurationItem", - helpText: "This key will enable you to connect to Avatax API", - label: "API key", - name: "apiKey", - type: ConfigurationTypeFieldEnum.SECRET, - value: "9ab9" - }, - { - __typename: "ConfigurationItem", - helpText: "", - label: "Password", - name: "password", - type: ConfigurationTypeFieldEnum.PASSWORD, - value: "" - }, - { - __typename: "ConfigurationItem", - helpText: "", - label: "Empty Password", - name: "password-not-set", - type: ConfigurationTypeFieldEnum.PASSWORD, - value: null - }, - { - __typename: "ConfigurationItem", - helpText: "Determines if Saleor should use Avatax sandbox API.", - label: "Use sandbox", - name: "Use sandbox", - type: ConfigurationTypeFieldEnum.BOOLEAN, - value: "true" - }, - { - __typename: "ConfigurationItem", - helpText: "This is a multiline field", - label: "Multiline Field", - name: "multiline-field", - type: ConfigurationTypeFieldEnum.MULTILINE, - value: - "Lorem ipsum\ndolor sit\namet enim.\nEtiam ullamcorper.\nSuspendisse a\npellentesque dui,\nnon felis." + __typename: "PluginConfiguration", + active: true, + channel: { + __typename: "Channel", + id: "channel-1", + name: "channel 1", + slug: "channel-1" + }, + configuration: [ + { + __typename: "ConfigurationItem", + helpText: "Provide user or account details", + label: "Username or account", + name: "Username or account", + type: ConfigurationTypeFieldEnum.STRING, + value: "avatax_user" + }, + { + __typename: "ConfigurationItem", + helpText: "Provide password or license details", + label: "Password or license", + name: "Password or license", + type: ConfigurationTypeFieldEnum.STRING, + value: "TEM8S2-2ET83-CGKP1-DPSI2-EPZO1" + }, + { + __typename: "ConfigurationItem", + helpText: "This key will enable you to connect to Avatax API", + label: "API key", + name: "apiKey", + type: ConfigurationTypeFieldEnum.SECRET, + value: "9ab9" + }, + { + __typename: "ConfigurationItem", + helpText: "", + label: "Password", + name: "password", + type: ConfigurationTypeFieldEnum.PASSWORD, + value: "" + }, + { + __typename: "ConfigurationItem", + helpText: "", + label: "Empty Password", + name: "password-not-set", + type: ConfigurationTypeFieldEnum.PASSWORD, + value: null + }, + { + __typename: "ConfigurationItem", + helpText: "Determines if Saleor should use Avatax sandbox API.", + label: "Use sandbox", + name: "Use sandbox", + type: ConfigurationTypeFieldEnum.BOOLEAN, + value: "true" + }, + { + __typename: "ConfigurationItem", + helpText: "This is a multiline field", + label: "Multiline Field", + name: "multiline-field", + type: ConfigurationTypeFieldEnum.MULTILINE, + value: + "Lorem ipsum\ndolor sit\namet enim.\nEtiam ullamcorper.\nSuspendisse a\npellentesque dui,\nnon felis." + } + ] } ], description: diff --git a/src/plugins/mutations.ts b/src/plugins/mutations.ts index 1e02d3309..9b0ea774c 100644 --- a/src/plugins/mutations.ts +++ b/src/plugins/mutations.ts @@ -8,8 +8,8 @@ import { PluginUpdate, PluginUpdateVariables } from "./types/PluginUpdate"; const pluginUpdate = gql` ${pluginsDetailsFragment} ${pluginErrorFragment} - mutation PluginUpdate($id: ID!, $input: PluginUpdateInput!) { - pluginUpdate(id: $id, input: $input) { + mutation PluginUpdate($channel: ID!, $id: ID!, $input: PluginUpdateInput!) { + pluginUpdate(channel: $channel, id: $id, input: $input) { errors { ...PluginErrorFragment } diff --git a/src/plugins/queries.ts b/src/plugins/queries.ts index 801d8436f..9c1d70ba3 100644 --- a/src/plugins/queries.ts +++ b/src/plugins/queries.ts @@ -5,7 +5,6 @@ import { import makeQuery from "@saleor/hooks/makeQuery"; import gql from "graphql-tag"; -import { TypedQuery } from "../queries"; import { Plugin, PluginVariables } from "./types/Plugin"; import { Plugins, PluginsVariables } from "./types/Plugins"; @@ -53,6 +52,7 @@ const pluginsDetails = gql` } } `; -export const TypedPluginsDetailsQuery = TypedQuery( + +export const usePluginDetails = makeQuery( pluginsDetails ); diff --git a/src/plugins/types/Plugin.ts b/src/plugins/types/Plugin.ts index 10f65c20d..e4c0a9374 100644 --- a/src/plugins/types/Plugin.ts +++ b/src/plugins/types/Plugin.ts @@ -9,22 +9,59 @@ import { ConfigurationTypeFieldEnum } from "./../../types/globalTypes"; // GraphQL query operation: Plugin // ==================================================== -export interface Plugin_plugin_configuration { +export interface Plugin_plugin_globalConfiguration_channel { + __typename: "Channel"; + id: string; + name: string; + slug: string; +} + +export interface Plugin_plugin_globalConfiguration_configuration { __typename: "ConfigurationItem"; name: string; - type: ConfigurationTypeFieldEnum | null; value: string | null; + type: ConfigurationTypeFieldEnum | null; helpText: string | null; label: string | null; } +export interface Plugin_plugin_globalConfiguration { + __typename: "PluginConfiguration"; + active: boolean; + channel: Plugin_plugin_globalConfiguration_channel | null; + configuration: (Plugin_plugin_globalConfiguration_configuration | null)[] | null; +} + +export interface Plugin_plugin_channelConfigurations_channel { + __typename: "Channel"; + id: string; + name: string; + slug: string; +} + +export interface Plugin_plugin_channelConfigurations_configuration { + __typename: "ConfigurationItem"; + name: string; + value: string | null; + type: ConfigurationTypeFieldEnum | null; + helpText: string | null; + label: string | null; +} + +export interface Plugin_plugin_channelConfigurations { + __typename: "PluginConfiguration"; + active: boolean; + channel: Plugin_plugin_channelConfigurations_channel | null; + configuration: (Plugin_plugin_channelConfigurations_configuration | null)[] | null; +} + export interface Plugin_plugin { __typename: "Plugin"; id: string; name: string; description: string; - active: boolean; - configuration: (Plugin_plugin_configuration | null)[] | null; + globalConfiguration: Plugin_plugin_globalConfiguration | null; + channelConfigurations: Plugin_plugin_channelConfigurations[]; } export interface Plugin { diff --git a/src/plugins/types/PluginUpdate.ts b/src/plugins/types/PluginUpdate.ts index 85af09e9b..56aba46b8 100644 --- a/src/plugins/types/PluginUpdate.ts +++ b/src/plugins/types/PluginUpdate.ts @@ -15,22 +15,59 @@ export interface PluginUpdate_pluginUpdate_errors { field: string | null; } -export interface PluginUpdate_pluginUpdate_plugin_configuration { +export interface PluginUpdate_pluginUpdate_plugin_globalConfiguration_channel { + __typename: "Channel"; + id: string; + name: string; + slug: string; +} + +export interface PluginUpdate_pluginUpdate_plugin_globalConfiguration_configuration { __typename: "ConfigurationItem"; name: string; - type: ConfigurationTypeFieldEnum | null; value: string | null; + type: ConfigurationTypeFieldEnum | null; helpText: string | null; label: string | null; } +export interface PluginUpdate_pluginUpdate_plugin_globalConfiguration { + __typename: "PluginConfiguration"; + active: boolean; + channel: PluginUpdate_pluginUpdate_plugin_globalConfiguration_channel | null; + configuration: (PluginUpdate_pluginUpdate_plugin_globalConfiguration_configuration | null)[] | null; +} + +export interface PluginUpdate_pluginUpdate_plugin_channelConfigurations_channel { + __typename: "Channel"; + id: string; + name: string; + slug: string; +} + +export interface PluginUpdate_pluginUpdate_plugin_channelConfigurations_configuration { + __typename: "ConfigurationItem"; + name: string; + value: string | null; + type: ConfigurationTypeFieldEnum | null; + helpText: string | null; + label: string | null; +} + +export interface PluginUpdate_pluginUpdate_plugin_channelConfigurations { + __typename: "PluginConfiguration"; + active: boolean; + channel: PluginUpdate_pluginUpdate_plugin_channelConfigurations_channel | null; + configuration: (PluginUpdate_pluginUpdate_plugin_channelConfigurations_configuration | null)[] | null; +} + export interface PluginUpdate_pluginUpdate_plugin { __typename: "Plugin"; id: string; name: string; description: string; - active: boolean; - configuration: (PluginUpdate_pluginUpdate_plugin_configuration | null)[] | null; + globalConfiguration: PluginUpdate_pluginUpdate_plugin_globalConfiguration | null; + channelConfigurations: PluginUpdate_pluginUpdate_plugin_channelConfigurations[]; } export interface PluginUpdate_pluginUpdate { @@ -44,6 +81,7 @@ export interface PluginUpdate { } export interface PluginUpdateVariables { + channel: string; id: string; input: PluginUpdateInput; } diff --git a/src/plugins/types/Plugins.ts b/src/plugins/types/Plugins.ts index 01251a86c..e5f61af12 100644 --- a/src/plugins/types/Plugins.ts +++ b/src/plugins/types/Plugins.ts @@ -3,18 +3,65 @@ // @generated // This file was automatically generated and should not be edited. -import { PluginFilterInput, PluginSortingInput } from "./../../types/globalTypes"; +import { PluginFilterInput, PluginSortingInput, ConfigurationTypeFieldEnum } from "./../../types/globalTypes"; // ==================================================== // GraphQL query operation: Plugins // ==================================================== +export interface Plugins_plugins_edges_node_globalConfiguration_channel { + __typename: "Channel"; + id: string; + name: string; + slug: string; +} + +export interface Plugins_plugins_edges_node_globalConfiguration_configuration { + __typename: "ConfigurationItem"; + name: string; + value: string | null; + type: ConfigurationTypeFieldEnum | null; + helpText: string | null; + label: string | null; +} + +export interface Plugins_plugins_edges_node_globalConfiguration { + __typename: "PluginConfiguration"; + active: boolean; + channel: Plugins_plugins_edges_node_globalConfiguration_channel | null; + configuration: (Plugins_plugins_edges_node_globalConfiguration_configuration | null)[] | null; +} + +export interface Plugins_plugins_edges_node_channelConfigurations_channel { + __typename: "Channel"; + id: string; + name: string; + slug: string; +} + +export interface Plugins_plugins_edges_node_channelConfigurations_configuration { + __typename: "ConfigurationItem"; + name: string; + value: string | null; + type: ConfigurationTypeFieldEnum | null; + helpText: string | null; + label: string | null; +} + +export interface Plugins_plugins_edges_node_channelConfigurations { + __typename: "PluginConfiguration"; + active: boolean; + channel: Plugins_plugins_edges_node_channelConfigurations_channel | null; + configuration: (Plugins_plugins_edges_node_channelConfigurations_configuration | null)[] | null; +} + export interface Plugins_plugins_edges_node { __typename: "Plugin"; id: string; name: string; description: string; - active: boolean; + globalConfiguration: Plugins_plugins_edges_node_globalConfiguration | null; + channelConfigurations: Plugins_plugins_edges_node_channelConfigurations[]; } export interface Plugins_plugins_edges { diff --git a/src/plugins/urls.ts b/src/plugins/urls.ts index b5bc8e040..402519cee 100644 --- a/src/plugins/urls.ts +++ b/src/plugins/urls.ts @@ -14,11 +14,17 @@ import { export const pluginSection = "/plugins/"; export const pluginListPath = pluginSection; + export enum PluginListUrlFiltersEnum { + query = "query", active = "active", - query = "query" + channels = "channels", + type = "type" } -export type PluginListUrlFilters = Filters; + +export type PluginListUrlFilters = Filters & { + channels?: string[]; +}; export type PluginListUrlDialog = TabActionDialog; export enum PluginListUrlSortField { name = "name", diff --git a/src/plugins/utils.ts b/src/plugins/utils.ts index b02251cc6..16129fd96 100644 --- a/src/plugins/utils.ts +++ b/src/plugins/utils.ts @@ -1,9 +1,8 @@ +import { PluginConfigurationFragment_configuration } from "@saleor/fragments/types/PluginConfigurationFragment"; import { ConfigurationTypeFieldEnum } from "@saleor/types/globalTypes"; -import { Plugin_plugin_configuration } from "./types/Plugin"; - export function isSecretField( - config: Plugin_plugin_configuration[], + config: PluginConfigurationFragment_configuration[], field: string ) { return [ diff --git a/src/plugins/views/PluginDetails.test.ts b/src/plugins/views/PluginDetails.test.ts index af11fd2fe..c1feb0644 100644 --- a/src/plugins/views/PluginDetails.test.ts +++ b/src/plugins/views/PluginDetails.test.ts @@ -1,12 +1,13 @@ +import { PluginConfigurationFragment_configuration } from "@saleor/fragments/types/PluginConfigurationFragment"; + import { ConfigurationItemInput, ConfigurationTypeFieldEnum } from "../../types/globalTypes"; -import { Plugin_plugin_configuration } from "../types/Plugin"; import { getConfigurationInput } from "./PluginsDetails"; const baseConfig: Omit< - Plugin_plugin_configuration, + PluginConfigurationFragment_configuration, "name" | "type" | "value" > = { __typename: "ConfigurationItem", @@ -14,7 +15,7 @@ const baseConfig: Omit< label: "" }; -const config: Plugin_plugin_configuration[] = [ +const config: PluginConfigurationFragment_configuration[] = [ { ...baseConfig, name: "field-1", diff --git a/src/plugins/views/PluginList/PluginList.tsx b/src/plugins/views/PluginList/PluginList.tsx index faf0dec32..e17a83587 100644 --- a/src/plugins/views/PluginList/PluginList.tsx +++ b/src/plugins/views/PluginList/PluginList.tsx @@ -3,6 +3,7 @@ import SaveFilterTabDialog, { SaveFilterTabDialogFormData } from "@saleor/components/SaveFilterTabDialog"; import { configurationMenuUrl } from "@saleor/configuration"; +import { useChannelsSearchWithLoadMore } from "@saleor/hooks/useChannelsSearchWithLoadMore"; import useListSettings from "@saleor/hooks/useListSettings"; import useNavigator from "@saleor/hooks/useNavigator"; import usePaginator, { @@ -112,13 +113,16 @@ export const PluginsList: React.FC = ({ params }) => { ); const handleSort = createSortHandler(navigate, pluginListUrl, params); + const channelsSearchWithLoadMoreProps = useChannelsSearchWithLoadMore(); + + const filterOpts = getFilterOpts(params, channelsSearchWithLoadMoreProps); return ( <> data.plugins.edges.map(edge => edge.node))} diff --git a/src/plugins/views/PluginList/__snapshots__/filters.test.ts.snap b/src/plugins/views/PluginList/__snapshots__/filters.test.ts.snap index 79b6ca913..74f7f38cd 100644 --- a/src/plugins/views/PluginList/__snapshots__/filters.test.ts.snap +++ b/src/plugins/views/PluginList/__snapshots__/filters.test.ts.snap @@ -3,7 +3,8 @@ exports[`Filtering URL params should not be empty if active filters are present 1`] = ` Object { "active": "true", + "type": "GLOBAL", } `; -exports[`Filtering URL params should not be empty if active filters are present 2`] = `"active=true"`; +exports[`Filtering URL params should not be empty if active filters are present 2`] = `"active=true&type=GLOBAL"`; diff --git a/src/plugins/views/PluginList/filters.test.ts b/src/plugins/views/PluginList/filters.test.ts index f6ad46aac..96fd8da71 100644 --- a/src/plugins/views/PluginList/filters.test.ts +++ b/src/plugins/views/PluginList/filters.test.ts @@ -1,7 +1,11 @@ -import { createFilterStructure } from "@saleor/plugins/components/PluginsListPage"; +import { + createFilterStructure, + PluginFilterKeys +} from "@saleor/plugins/components/PluginsListPage"; import { PluginListUrlFilters } from "@saleor/plugins/urls"; +import { PluginConfigurationType } from "@saleor/types/globalTypes"; import { getFilterQueryParams } from "@saleor/utils/filters"; -import { getExistingKeys, setFilterOptsStatus } from "@test/filters"; +import { getExistingKeys } from "@test/filters"; import { config } from "@test/intl"; import { stringify as stringifyQs } from "qs"; import { createIntl } from "react-intl"; @@ -18,7 +22,7 @@ describe("Filtering query params", () => { it("should not be empty object if params given", () => { const params: PluginListUrlFilters = { - active: true.toString() + type: PluginConfigurationType.GLOBAL }; const filterVariables = getFilterVariables(params); @@ -33,6 +37,25 @@ describe("Filtering URL params", () => { isActive: { active: false, value: true + }, + channels: { + active: false, + choices: [], + displayValues: [], + initialSearch: "", + hasMore: false, + loading: false, + onFetchMore: () => undefined, + onSearchChange: () => undefined, + value: [] + }, + status: { + active: false, + value: false + }, + type: { + active: false, + value: PluginConfigurationType.GLOBAL } }); @@ -47,7 +70,22 @@ describe("Filtering URL params", () => { it("should not be empty if active filters are present", () => { const filterQueryParams = getFilterQueryParams( - setFilterOptsStatus(filters, true), + [ + { + name: PluginFilterKeys.active, + label: "Active", + multiple: false, + active: true, + value: ["true"] + }, + { + name: PluginFilterKeys.type, + label: "Configuration type", + multiple: false, + active: true, + value: [PluginConfigurationType.GLOBAL] + } + ], getFilterQueryParam ); diff --git a/src/plugins/views/PluginList/filters.ts b/src/plugins/views/PluginList/filters.ts index 297652fdd..99c1c99e7 100644 --- a/src/plugins/views/PluginList/filters.ts +++ b/src/plugins/views/PluginList/filters.ts @@ -1,14 +1,22 @@ import { IFilterElement } from "@saleor/components/Filter"; +import { ChannelsWithLoadMoreProps } from "@saleor/hooks/useChannelsSearch"; import { maybe, parseBoolean } from "@saleor/misc"; import { PluginFilterKeys, PluginListFilterOpts } from "@saleor/plugins/components/PluginsListPage"; -import { PluginFilterInput } from "@saleor/types/globalTypes"; +import { + PluginConfigurationType, + PluginFilterInput +} from "@saleor/types/globalTypes"; +import { mapNodeToChoice } from "@saleor/utils/maps"; import { createFilterTabUtils, createFilterUtils, + dedupeFilter, + getMultipleValueQueryParam, + getSingleEnumValueQueryParam, getSingleValueQueryParam } from "../../../utils/filters"; import { @@ -20,27 +28,67 @@ import { export const PLUGIN_FILTERS_KEY = "pluginFilters"; export function getFilterOpts( - params: PluginListUrlFilters + params: PluginListUrlFilters, + { + channels, + hasMore, + onFetchMore, + onSearchChange, + loading + }: ChannelsWithLoadMoreProps ): PluginListFilterOpts { return { isActive: { active: maybe(() => params.active !== undefined, false), value: - params.active !== undefined ? parseBoolean(params.active, true) : true + params.active === undefined + ? undefined + : parseBoolean(params.active, true) + }, + channels: { + active: !!params.channels, + choices: mapNodeToChoice(channels), + displayValues: mapNodeToChoice(channels), + initialSearch: "", + hasMore, + loading, + onFetchMore, + onSearchChange, + value: maybe(() => dedupeFilter(params.channels), []) + }, + type: { + active: !!params.type, + value: getParsedConfigType(params.type) + }, + status: { + active: !!params.channels?.length && params.active !== undefined, + value: + !!dedupeFilter(params.channels)?.length && params.active !== undefined } }; } +const getParsedConfigType = (configTypeString?: string) => + PluginConfigurationType[configTypeString] || undefined; + export function getFilterVariables( params: PluginListUrlFilters ): PluginFilterInput { - return { - active: - params.active !== undefined - ? parseBoolean(params.active, true) - : undefined, - search: params.query + const baseParams = { + type: getParsedConfigType(params.type) }; + + if (!!params.active && !!params.channels?.length) { + return { + ...baseParams, + statusInChannels: { + active: parseBoolean(params.active, true), + channels: params.channels + } + }; + } + + return baseParams; } export function getFilterQueryParam( @@ -49,8 +97,21 @@ export function getFilterQueryParam( const { name } = filter; switch (name) { + case PluginFilterKeys.channels: + return getMultipleValueQueryParam( + filter, + PluginListUrlFiltersEnum.channels + ); + case PluginFilterKeys.active: return getSingleValueQueryParam(filter, PluginListUrlFiltersEnum.active); + + case PluginFilterKeys.type: + return getSingleEnumValueQueryParam( + filter, + PluginListUrlFiltersEnum.type, + PluginConfigurationType + ); } } diff --git a/src/plugins/views/PluginsDetails.tsx b/src/plugins/views/PluginsDetails.tsx index 56cf7f072..a2534ec74 100644 --- a/src/plugins/views/PluginsDetails.tsx +++ b/src/plugins/views/PluginsDetails.tsx @@ -1,8 +1,10 @@ import DialogContentText from "@material-ui/core/DialogContentText"; import ActionDialog from "@saleor/components/ActionDialog"; import { WindowTitle } from "@saleor/components/WindowTitle"; +import { PluginConfigurationFragment_configuration } from "@saleor/fragments/types/PluginConfigurationFragment"; import useNavigator from "@saleor/hooks/useNavigator"; import useNotifier from "@saleor/hooks/useNotifier"; +import useStateFromProps from "@saleor/hooks/useStateFromProps"; import { commonMessages } from "@saleor/intl"; import { ConfigurationItemInput } from "@saleor/types/globalTypes"; import createDialogActionHandlers from "@saleor/utils/handlers/dialogActionHandlers"; @@ -11,11 +13,10 @@ import { FormattedMessage, useIntl } from "react-intl"; import PluginsDetailsPage, { PluginDetailsPageFormData -} from "../components/PluginsDetailsPage"; +} from "../components/PluginsDetailsPage"; // PluginDetailsPageFormData import PluginSecretFieldDialog from "../components/PluginSecretFieldDialog"; import { TypedPluginUpdate } from "../mutations"; -import { TypedPluginsDetailsQuery } from "../queries"; -import { Plugin_plugin_configuration } from "../types/Plugin"; +import { usePluginDetails } from "../queries"; import { PluginUpdate } from "../types/PluginUpdate"; import { pluginListUrl, @@ -24,6 +25,8 @@ import { PluginUrlQueryParams } from "../urls"; import { isSecretField } from "../utils"; +import { isPluginGlobal } from "./utils"; +import { getConfigByChannelId } from "./utils"; export interface PluginsDetailsProps { id: string; @@ -31,7 +34,7 @@ export interface PluginsDetailsProps { } export function getConfigurationInput( - config: Plugin_plugin_configuration[] | null, + config: PluginConfigurationFragment_configuration[] | null, input: ConfigurationItemInput[] | null ): ConfigurationItemInput[] | null { if (config === null || input === null) { @@ -54,6 +57,28 @@ export const PluginsDetails: React.FC = ({ const notify = useNotifier(); const intl = useIntl(); + const { data: pluginData, loading } = usePluginDetails({ + displayLoader: true, + variables: { id } + }); + + const plugin = pluginData?.plugin; + + const initialSelectedChannelValue = + plugin && !isPluginGlobal(plugin.globalConfiguration) + ? plugin.channelConfigurations[0].channel.id + : null; + + const [selectedChannelId, setSelectedChannelId] = useStateFromProps( + initialSelectedChannelValue + ); + + const selectedConfig = isPluginGlobal(plugin?.globalConfiguration) + ? plugin?.globalConfiguration + : plugin?.channelConfigurations.find( + getConfigByChannelId(selectedChannelId) + ); + const [openModal, closeModal] = createDialogActionHandlers< PluginUrlDialog, PluginUrlQueryParams @@ -70,106 +95,104 @@ export const PluginsDetails: React.FC = ({ }; return ( - - {pluginDetails => ( - - {(pluginUpdate, pluginUpdateOpts) => { - const formErrors = pluginUpdateOpts.data?.pluginUpdate.errors || []; + + {(pluginUpdate, pluginUpdateOpts) => { + const formErrors = pluginUpdateOpts.data?.pluginUpdate.errors || []; - const handleFieldUpdate = (value: string) => - pluginUpdate({ - variables: { - id, - input: { - configuration: [ - { - name: params.id, - value - } - ] + const handleFieldUpdate = (value: string) => + pluginUpdate({ + variables: { + channel: selectedChannelId, + id, + input: { + configuration: [ + { + name: params.id, + value } - } - }); + ] + } + } + }); - const handleSubmit = async ( - formData: PluginDetailsPageFormData - ) => { - const result = await pluginUpdate({ - variables: { - id, - input: { - active: formData.active, - configuration: getConfigurationInput( - pluginDetails.data.plugin.configuration, - formData.configuration - ) - } - } - }); + const handleSubmit = async (formData: PluginDetailsPageFormData) => { + const result = await pluginUpdate({ + variables: { + channel: selectedChannelId, + id, + input: { + active: formData.active, + configuration: getConfigurationInput( + selectedConfig?.configuration, + formData.configuration + ) + } + } + }); - return result.data.pluginUpdate.errors; - }; + return result.data.pluginUpdate.errors; + }; - return ( + return ( + <> + + navigate(pluginListUrl())} + onClear={id => + openModal("clear", { + id + }) + } + onEdit={id => + openModal("edit", { + id + }) + } + onSubmit={handleSubmit} + selectedConfig={selectedConfig} + setSelectedChannelId={setSelectedChannelId} + /> + {selectedConfig && ( <> - - navigate(pluginListUrl())} - onClear={id => - openModal("clear", { - id - }) + onClose={closeModal} + open={params.action === "clear" && !!params.id} + title={intl.formatMessage({ + defaultMessage: "Authorization Field Delete", + description: "header" + })} + onConfirm={() => handleFieldUpdate(null)} + > + + + + + - openModal("edit", { - id - }) - } - onSubmit={handleSubmit} + field={selectedConfig?.configuration.find( + field => field.name === params.id + )} + onClose={closeModal} + onConfirm={formData => handleFieldUpdate(formData.value)} + open={params.action === "edit" && !!params.id} /> - {pluginDetails.data?.plugin?.configuration && ( - <> - handleFieldUpdate(null)} - > - - - - - field.name === params.id - )} - onClose={closeModal} - onConfirm={formData => handleFieldUpdate(formData.value)} - open={params.action === "edit" && !!params.id} - /> - - )} - ); - }} - - )} - + )} + + ); + }} + ); }; PluginsDetails.displayName = "PluginsDetails"; diff --git a/src/plugins/views/utils.ts b/src/plugins/views/utils.ts new file mode 100644 index 000000000..512e40b68 --- /dev/null +++ b/src/plugins/views/utils.ts @@ -0,0 +1,11 @@ +import { Plugin_plugin_globalConfiguration } from "@saleor/plugins/types/Plugin"; + +export const isPluginGlobal = ( + globalConfiguration: Plugin_plugin_globalConfiguration +) => !!globalConfiguration; + +export const getConfigByChannelId = (channelIdToCompare: string) => ({ + channel +}: { + channel: { id: string }; +}) => channel.id === channelIdToCompare; diff --git a/src/productTypes/hooks/useProductTypeDelete/messages.ts b/src/productTypes/hooks/useProductTypeDelete/messages.ts index adde90214..095e787ef 100644 --- a/src/productTypes/hooks/useProductTypeDelete/messages.ts +++ b/src/productTypes/hooks/useProductTypeDelete/messages.ts @@ -18,12 +18,14 @@ export const singleWithItemsMessages = defineMessages({ defaultMessage: "You are about to delete product type {typeName}. It is assigned to {assignedItemsCount} {assignedItemsCount,plural,one{product} other{products}}. Deleting this product type will also delete those products. Are you sure you want to do this?", description: - "ProductTypeDeleteWarningDialog single assigned items description" + "ProductTypeDeleteWarningDialog single assigned items description", + id: "ProductTypeDeleteWarningDialog single assigned items description" }, consentLabel: { defaultMessage: "Yes, I want to delete this product type and assigned products", - description: "ProductTypeDeleteWarningDialog single consent label" + description: "ProductTypeDeleteWarningDialog single consent label", + id: "ProductTypeDeleteWarningDialog single consent label" } }); @@ -32,12 +34,14 @@ export const multipleWithItemsMessages = defineMessages({ defaultMessage: "You are about to delete multiple product types. Some of them are assigned to products. Deleting those product types will also delete those products", description: - "ProductTypeDeleteWarningDialog with items multiple description" + "ProductTypeDeleteWarningDialog with items multiple description", + id: "ProductTypeDeleteWarningDialog with items multiple description" }, consentLabel: { defaultMessage: "Yes, I want to delete those products types and assigned products", - description: "ProductTypeDeleteWarningDialog multiple consent label" + description: "ProductTypeDeleteWarningDialog multiple consent label", + id: "ProductTypeDeleteWarningDialog multiple consent label" } }); @@ -46,7 +50,8 @@ export const singleWithoutItemsMessages = defineMessages({ defaultMessage: "Are you sure you want to delete {typeName}? If you remove it you won’t be able to assign it to created products.", description: - "ProductTypeDeleteWarningDialog single assigned items description" + "ProductTypeDeleteWarningDialog single no assigned items description", + id: "ProductTypeDeleteWarningDialog single no assigned items description" } }); @@ -55,6 +60,7 @@ export const multipleWithoutItemsMessages = defineMessages({ defaultMessage: "Are you sure you want to delete selected product types? If you remove them you won’t be able to assign them to created products.", description: - "ProductTypeDeleteWarningDialog single assigned items description" + "ProductTypeDeleteWarningDialog multiple assigned items description", + id: "ProductTypeDeleteWarningDialog multiple assigned items description" } }); diff --git a/src/shipping/components/ShippingZoneSettingsCard/ChannelsSection.tsx b/src/shipping/components/ShippingZoneSettingsCard/ChannelsSection.tsx index 4263af986..d32b924ba 100644 --- a/src/shipping/components/ShippingZoneSettingsCard/ChannelsSection.tsx +++ b/src/shipping/components/ShippingZoneSettingsCard/ChannelsSection.tsx @@ -1,9 +1,9 @@ -import { useChannelsSearch } from "@saleor/channels/components/ChannelsAvailabilityDialog/utils"; import { Channels_channels } from "@saleor/channels/types/Channels"; import CardSpacer from "@saleor/components/CardSpacer"; import MultiAutocompleteSelectField, { MultiAutocompleteChoiceType } from "@saleor/components/MultiAutocompleteSelectField"; +import { useChannelsSearch } from "@saleor/hooks/useChannelsSearch"; import { FormChange } from "@saleor/hooks/useForm"; import { mapNodeToChoice } from "@saleor/utils/maps"; import React from "react"; diff --git a/src/storybook/__snapshots__/Stories.test.ts.snap b/src/storybook/__snapshots__/Stories.test.ts.snap index 14a3d3dde..8794c9e9d 100644 --- a/src/storybook/__snapshots__/Stories.test.ts.snap +++ b/src/storybook/__snapshots__/Stories.test.ts.snap @@ -3181,6 +3181,7 @@ exports[`Storyshots Generics / AddressEdit default 1`] = `
- -
+ />
- -
+ />
- Available in 3/6 +
+
+
+ Available in 3/6 +
+
@@ -5529,16 +5515,16 @@ exports[`Storyshots Generics / Filter default 1`] = ` >
Filters
@@ -50452,9 +50616,20 @@ exports[`Storyshots Views / Categories / Update category products 1`] = ` role="button" >
- Available in 1/2 +
+
+
+ Available in 1/2 +
+
@@ -55325,9 +55500,20 @@ exports[`Storyshots Views / Collections / Collection detailsCollection details d role="button" >
- Available in 2/2 +
+
+
+ Available in 2/2 +
+
@@ -55430,9 +55616,20 @@ exports[`Storyshots Views / Collections / Collection detailsCollection details d role="button" >
- Available in 2/2 +
+
+
+ Available in 2/2 +
+
@@ -55535,9 +55732,20 @@ exports[`Storyshots Views / Collections / Collection detailsCollection details d role="button" >
- Available in 2/2 +
+
+
+ Available in 2/2 +
+
@@ -55640,9 +55848,20 @@ exports[`Storyshots Views / Collections / Collection detailsCollection details d role="button" >
- Available in 2/2 +
+
+
+ Available in 2/2 +
+
@@ -56777,9 +56996,20 @@ exports[`Storyshots Views / Collections / Collection detailsCollection details f role="button" >
- Available in 2/2 +
+
+
+ Available in 2/2 +
+
@@ -56882,9 +57112,20 @@ exports[`Storyshots Views / Collections / Collection detailsCollection details f role="button" >
- Available in 2/2 +
+
+
+ Available in 2/2 +
+
@@ -56987,9 +57228,20 @@ exports[`Storyshots Views / Collections / Collection detailsCollection details f role="button" >
- Available in 2/2 +
+
+
+ Available in 2/2 +
+
@@ -57092,9 +57344,20 @@ exports[`Storyshots Views / Collections / Collection detailsCollection details f role="button" >
- Available in 2/2 +
+
+
+ Available in 2/2 +
+
@@ -59703,9 +59966,20 @@ exports[`Storyshots Views / Collections / Collection list default 1`] = ` role="button" >
- Available in 1/2 +
+
+
+ Available in 1/2 +
+
@@ -68389,6 +68663,7 @@ exports[`Storyshots Views / Customers / Create customer default 1`] = `
- Fully paid +
+
+
+ Fully paid +
+
- Fully paid +
+
+
+ Fully paid +
+
- Fully paid +
+
+
+ Fully paid +
+
- Fully paid +
+
+
+ Fully paid +
+
- Fully paid +
+
+
+ Fully paid +
+
- Fully paid +
+
+
+ Fully paid +
+
- Fully paid +
+
+
+ Fully paid +
+
- Fully paid +
+
+
+ Fully paid +
+
- Available in 1/7 +
+
+
+ Available in 1/7 +
+
@@ -87631,9 +88007,20 @@ exports[`Storyshots Views / Discounts / Sale details products 1`] = ` role="button" >
- Available in 1/7 +
+
+
+ Available in 1/7 +
+
@@ -87736,9 +88123,20 @@ exports[`Storyshots Views / Discounts / Sale details products 1`] = ` role="button" >
- Available in 1/7 +
+
+
+ Available in 1/7 +
+
@@ -87841,9 +88239,20 @@ exports[`Storyshots Views / Discounts / Sale details products 1`] = ` role="button" >
- Available in 1/7 +
+
+
+ Available in 1/7 +
+
@@ -110611,12 +111020,23 @@ exports[`Storyshots Views / Orders / Order details cancelled 1`] = ` class="MuiTypography-root-id CardTitle-title-id MuiTypography-h5-id" >
- Unfulfilled
+
+
+ Unfulfilled +
+
+
- Unfulfilled
+
- #9-2 +
+ Unfulfilled +
+ #9-2 +
+
@@ -110869,13 +111300,24 @@ exports[`Storyshots Views / Orders / Order details cancelled 1`] = ` class="MuiTypography-root-id CardTitle-title-id MuiTypography-h5-id" >
- Unfulfilled
+
- #9-1 +
+ Unfulfilled +
+ #9-1 +
+
@@ -111030,9 +111472,20 @@ exports[`Storyshots Views / Orders / Order details cancelled 1`] = ` class="MuiTypography-root-id CardTitle-title-id MuiTypography-h5-id" >
- Unpaid +
+
+
+ Unpaid +
+
- Unfulfilled
+
+
+ Unfulfilled +
+
+
- Fulfilled (1)
+
- #9-2 +
+ Fulfilled (1) +
+ #9-2 +
+
@@ -112564,13 +113039,24 @@ exports[`Storyshots Views / Orders / Order details default 1`] = ` class="MuiTypography-root-id CardTitle-title-id MuiTypography-h5-id" >
- Fulfilled (1)
+
- #9-1 +
+ Fulfilled (1) +
+ #9-1 +
+
@@ -112765,9 +113251,20 @@ exports[`Storyshots Views / Orders / Order details default 1`] = ` class="MuiTypography-root-id CardTitle-title-id MuiTypography-h5-id" >
- Unpaid +
+
+
+ Unpaid +
+