Handle Cloud limits (#1053)

* Handle limit reached error

* Update changelog

* Move notifications to higher layer

* Handle limits top-level

* Remove unused imports

* Add alert component

* Fetch limits from API

* Display limits in staff list

* Fix disabled text button color

* Display limits in products pages

* Display limits in channel list

* Display limits in warehouse list

* Display limits in order list

* Add stories for limits

* Move alert to corresponding section

* Update schema

* Update changelog

* Fetch only needed limitations

* Fix types

* Fix story

* Display limits in variant creator

* Improve type consistency

* Update snapshots

* Update limit counter after object deletion

* Fix copy

* Improve code readability

* Lint files

* Fix dark mode alerts

* Improve contrast

* Fix sku limit in product variant list

* Update stories

* Update messages

* Extract messages
This commit is contained in:
Dominik Żegleń 2021-04-13 11:59:16 +02:00 committed by GitHub
parent 20fbc19739
commit a3abb9c476
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
53 changed files with 15543 additions and 138 deletions

View file

@ -31,6 +31,8 @@ All notable, unreleased changes to this project will be documented in this file.
- Update schema to contain email plugin changes - #1029 by @dominik-zeglen
- Unconfirmed order manipulation - #967 by @tomaszszymanski129
- Add multiline field plugins - #974 by @dominik-zeglen
- Handle limit reached error - #990 by @dominik-zeglen
- Display Cloud limits - #1004 by @dominik-zeglen
# 2.11.1

View file

@ -0,0 +1,5 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="20" cy="20" r="20" fill="#202124"/>
<circle cx="20" cy="20" r="14" stroke="currentColor" stroke-width="4"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.5 11C18.6716 11 18 11.6716 18 12.5V20.5C18 21.3284 18.6716 22 19.5 22H20.5C21.3284 22 22 21.3284 22 20.5V12.5C22 11.6716 21.3284 11 20.5 11H19.5ZM19.5 25C18.6716 25 18 25.6716 18 26.5V27.5C18 28.3284 18.6716 29 19.5 29H20.5C21.3284 29 22 28.3284 22 27.5V26.5C22 25.6716 21.3284 25 20.5 25H19.5Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 594 B

5
assets/images/alert.svg Normal file
View file

@ -0,0 +1,5 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="20" cy="20" r="20" fill="white"/>
<circle cx="20" cy="20" r="14" stroke="currentColor" stroke-width="4"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.5 11C18.6716 11 18 11.6716 18 12.5V20.5C18 21.3284 18.6716 22 19.5 22H20.5C21.3284 22 22 21.3284 22 20.5V12.5C22 11.6716 21.3284 11 20.5 11H19.5ZM19.5 25C18.6716 25 18 25.6716 18 26.5V27.5C18 28.3284 18.6716 29 19.5 29H20.5C21.3284 29 22 28.3284 22 27.5V26.5C22 25.6716 21.3284 25 20.5 25H19.5Z" fill="#FFB84E"/>
</svg>

After

Width:  |  Height:  |  Size: 587 B

View file

@ -1363,6 +1363,10 @@
"context": "activate",
"string": "Activate"
},
"src_dot_channels_dot_pages_dot_ChannelsListPage_dot_2754800034": {
"context": "alert",
"string": "Channel limit reached"
},
"src_dot_channels_dot_pages_dot_ChannelsListPage_dot_3511613983": {
"context": "channel name",
"string": "Channel Name"
@ -1378,6 +1382,9 @@
"context": "button",
"string": "Create Channel"
},
"src_dot_channels_dot_pages_dot_ChannelsListPage_dot_85767761": {
"string": "You have reached your channel limit, you will be no longer able to add channels to your store. If you would like to up your limit, contact your administration staff about raising your limits."
},
"src_dot_channels_dot_views_dot_ChannelCreate_dot_2071399707": {
"context": "currency code select",
"string": "{code} - {countries}"
@ -3168,6 +3175,9 @@
"src_dot_lastName": {
"string": "Last Name"
},
"src_dot_limitReached": {
"string": "Reached limit for this plan"
},
"src_dot_manage": {
"context": "button",
"string": "Manage"
@ -3712,6 +3722,10 @@
"context": "generate invoice button",
"string": "Generate"
},
"src_dot_orders_dot_components_dot_OrderListPage_dot_2214147779": {
"context": "alert",
"string": "Order limit reached"
},
"src_dot_orders_dot_components_dot_OrderListPage_dot_2225897825": {
"context": "button",
"string": "Order Settings"
@ -3723,6 +3737,9 @@
"src_dot_orders_dot_components_dot_OrderListPage_dot_355376157": {
"string": "Search Orders..."
},
"src_dot_orders_dot_components_dot_OrderListPage_dot_3769643084": {
"string": "You have reached your order limit, you will be billed extra for orders above limit. If you would like to up your limit, contact your administration staff about raising your limits."
},
"src_dot_orders_dot_components_dot_OrderListPage_dot_875489544": {
"context": "tab name",
"string": "All Orders"
@ -5047,6 +5064,10 @@
"context": "export products to csv file, button",
"string": "Export Products"
},
"src_dot_products_dot_components_dot_ProductListPage_dot_3024822321": {
"context": "alert",
"string": "SKU limit reached"
},
"src_dot_products_dot_components_dot_ProductListPage_dot_3550330425": {
"string": "Search Products..."
},
@ -5054,6 +5075,9 @@
"context": "tab name",
"string": "All Products"
},
"src_dot_products_dot_components_dot_ProductListPage_dot_911702010": {
"string": "You have reached your SKU limit, you will be no longer able to add SKUs to your store. If you would like to up your limit, contact your administration staff about raising your limits."
},
"src_dot_products_dot_components_dot_ProductListPage_dot_available": {
"context": "product status",
"string": "Available"
@ -5245,6 +5269,9 @@
"src_dot_products_dot_components_dot_ProductVariantCreatorPage_dot_1346828628": {
"string": "Selected values will be used to create variants for the configurable product."
},
"src_dot_products_dot_components_dot_ProductVariantCreatorPage_dot_1419085897": {
"string": "You choices will add {variantsNumber} SKUs to your catalog which will exceed your limit by {aboveLimitVariantsNumber}. If you would like to up your limit, contact your administration staff about raising your limits."
},
"src_dot_products_dot_components_dot_ProductVariantCreatorPage_dot_168343345": {
"context": "variant attribute",
"string": "Attribute"
@ -5287,6 +5314,10 @@
"src_dot_products_dot_components_dot_ProductVariantCreatorPage_dot_2877822536": {
"string": "Skip pricing for now"
},
"src_dot_products_dot_components_dot_ProductVariantCreatorPage_dot_3024822321": {
"context": "alert",
"string": "SKU limit reached"
},
"src_dot_products_dot_components_dot_ProductVariantCreatorPage_dot_3387090508": {
"string": "Apply unique stock by attribute to each SKU"
},
@ -5456,6 +5487,10 @@
"src_dot_products_dot_components_dot_ProductVariants_dot_277989856": {
"string": "Use variants for products that come in a variety of versions for example different sizes or colors"
},
"src_dot_products_dot_components_dot_ProductVariants_dot_3024822321": {
"context": "alert",
"string": "SKU limit reached"
},
"src_dot_products_dot_components_dot_ProductVariants_dot_3203451491": {
"context": "filtering option",
"string": "All Warehouses"
@ -5475,6 +5510,9 @@
"src_dot_products_dot_components_dot_ProductVariants_dot_693960049": {
"string": "SKU"
},
"src_dot_products_dot_components_dot_ProductVariants_dot_911702010": {
"string": "You have reached your SKU limit, you will be no longer able to add SKUs to your store. If you would like to up your limit, contact your administration staff about raising your limits."
},
"src_dot_products_dot_components_dot_ProductVariants_dot_998917294": {
"context": "product variant name",
"string": "Variant"
@ -6077,6 +6115,13 @@
"context": "tab name",
"string": "All Staff Members"
},
"src_dot_staff_dot_components_dot_StaffListPage_dot_378240223": {
"string": "You have reached your staff member limit, you will be no longer able to add staff members to your store. If you would like to up your limit, contact your administration staff about raising your limits."
},
"src_dot_staff_dot_components_dot_StaffListPage_dot_4002475974": {
"context": "alert",
"string": "Staff Member limit reached"
},
"src_dot_staff_dot_components_dot_StaffListPage_dot_61043583": {
"string": "Search Staff Member"
},
@ -6777,6 +6822,13 @@
"context": "tab name",
"string": "All Warehouses"
},
"src_dot_warehouses_dot_components_dot_WarehouseListPage_dot_3562691996": {
"context": "alert",
"string": "Warehouse limit reached"
},
"src_dot_warehouses_dot_components_dot_WarehouseListPage_dot_478107533": {
"string": "You have reached your warehouse limit, you will be no longer able to add warehouses to your store. If you would like to up your limit, contact your administration staff about raising your limits."
},
"src_dot_warehouses_dot_components_dot_WarehouseListPage_dot_934572530": {
"context": "button",
"string": "Create Warehouse"

View file

@ -1,3 +1,4 @@
import { limits, limitsReached } from "@saleor/fixtures";
import Decorator from "@saleor/storybook/Decorator";
import { storiesOf } from "@storybook/react";
import React from "react";
@ -7,6 +8,7 @@ import ChannelsListPage, { ChannelsListPageProps } from "./ChannelsListPage";
const props: ChannelsListPageProps = {
channelsList,
limits,
navigateToChannelCreate: () => undefined,
onBack: () => undefined,
onRemove: () => undefined,
@ -16,4 +18,8 @@ const props: ChannelsListPageProps = {
storiesOf("Views / Channels / Channels list", module)
.addDecorator(Decorator)
.add("default", () => <ChannelsListPage {...props} />)
.add("empty", () => <ChannelsListPage {...props} channelsList={[]} />);
.add("empty", () => <ChannelsListPage {...props} channelsList={[]} />)
.add("no limits", () => <ChannelsListPage {...props} limits={undefined} />)
.add("limits reached", () => (
<ChannelsListPage {...props} limits={limitsReached} />
));

View file

@ -6,14 +6,17 @@ import TableCell from "@material-ui/core/TableCell";
import TableHead from "@material-ui/core/TableHead";
import TableRow from "@material-ui/core/TableRow";
import DeleteIcon from "@material-ui/icons/Delete";
import Alert from "@saleor/components/Alert/Alert";
import AppHeader from "@saleor/components/AppHeader";
import Container from "@saleor/components/Container";
import PageHeader from "@saleor/components/PageHeader";
import ResponsiveTable from "@saleor/components/ResponsiveTable";
import { RefreshLimits_shop_limits } from "@saleor/components/Shop/types/RefreshLimits";
import Skeleton from "@saleor/components/Skeleton";
import TableCellHeader from "@saleor/components/TableCellHeader";
import { sectionNames } from "@saleor/intl";
import { renderCollection, stopPropagation } from "@saleor/misc";
import { hasLimits, isLimitReached } from "@saleor/utils/limits";
import React from "react";
import { FormattedMessage, useIntl } from "react-intl";
@ -22,6 +25,7 @@ import { useStyles } from "./styles";
export interface ChannelsListPageProps {
channelsList: Channels_channels[] | undefined;
limits: RefreshLimits_shop_limits;
navigateToChannelCreate: () => void;
onBack: () => void;
onRowClick: (id: string) => () => void;
@ -32,6 +36,7 @@ const numberOfColumns = 2;
export const ChannelsListPage: React.FC<ChannelsListPageProps> = ({
channelsList,
limits,
navigateToChannelCreate,
onBack,
onRemove,
@ -40,13 +45,25 @@ export const ChannelsListPage: React.FC<ChannelsListPageProps> = ({
const intl = useIntl();
const classes = useStyles({});
const limitReached = isLimitReached(limits, "channels");
return (
<Container>
<AppHeader onBack={onBack}>
{intl.formatMessage(sectionNames.configuration)}
</AppHeader>
<PageHeader title={intl.formatMessage(sectionNames.channels)}>
<PageHeader
title={intl.formatMessage(sectionNames.channels)}
limit={
hasLimits(limits, "channels") && {
data: limits,
key: "channels",
text: "channels used"
}
}
>
<Button
disabled={limitReached}
onClick={navigateToChannelCreate}
color="primary"
variant="contained"
@ -58,6 +75,15 @@ export const ChannelsListPage: React.FC<ChannelsListPageProps> = ({
/>
</Button>
</PageHeader>
<Alert
show={limitReached}
title={intl.formatMessage({
defaultMessage: "Channel limit reached",
description: "alert"
})}
>
<FormattedMessage defaultMessage="You have reached your channel limit, you will be no longer able to add channels to your store. If you would like to up your limit, contact your administration staff about raising your limits." />
</Alert>
<Card>
<ResponsiveTable>
<TableHead>

View file

@ -1,4 +1,5 @@
import { getChannelsCurrencyChoices } from "@saleor/channels/utils";
import { useShopLimitsQuery } from "@saleor/components/Shop/query";
import { configurationMenuUrl } from "@saleor/configuration";
import useNavigator from "@saleor/hooks/useNavigator";
import useNotifier from "@saleor/hooks/useNotifier";
@ -30,8 +31,13 @@ export const ChannelsList: React.FC<ChannelsListProps> = ({ params }) => {
const intl = useIntl();
const { data, refetch } = useChannelsList({ displayLoader: true });
const limitOpts = useShopLimitsQuery({
variables: {
channels: true
}
});
const selectedChannel = data?.channels.find(
const selectedChannel = data?.channels?.find(
channel => channel.id === params?.id
);
@ -50,6 +56,7 @@ export const ChannelsList: React.FC<ChannelsListProps> = ({ params }) => {
})
});
refetch();
limitOpts.refetch();
closeModal();
} else {
errors.map(error =>
@ -89,6 +96,7 @@ export const ChannelsList: React.FC<ChannelsListProps> = ({ params }) => {
<>
<ChannelsListPage
channelsList={data?.channels}
limits={limitOpts.data?.shop.limits}
navigateToChannelCreate={navigateToChannelCreate}
onBack={() => navigate(configurationMenuUrl)}
onRowClick={id => () => navigate(channelUrl(id))}

View file

@ -0,0 +1,13 @@
import Decorator from "@saleor/storybook/Decorator";
import { storiesOf } from "@storybook/react";
import React from "react";
import Alert from "./Alert";
storiesOf("Generics / Alert", module)
.addDecorator(Decorator)
.add("default", () => (
<Alert show={true} title="Title">
Content
</Alert>
));

View file

@ -0,0 +1,84 @@
import alertIconDark from "@assets/images/alert-dark.svg";
import alertIcon from "@assets/images/alert.svg";
import Card from "@material-ui/core/Card";
import CardContent from "@material-ui/core/CardContent";
import IconButton from "@material-ui/core/IconButton";
import Typography from "@material-ui/core/Typography";
import CloseIcon from "@material-ui/icons/Close";
import useStateFromProps from "@saleor/hooks/useStateFromProps";
import useTheme from "@saleor/hooks/useTheme";
import { makeStyles } from "@saleor/theme";
import classNames from "classnames";
import React from "react";
import SVG from "react-inlinesvg";
const useStyles = makeStyles(
theme => ({
close: {
color: theme.palette.common.black,
position: "absolute",
right: theme.spacing(-1),
top: theme.spacing(-2)
},
content: {
color: theme.palette.getContrastText(theme.palette.alert.paper.warning),
columnGap: theme.spacing(2) + "px",
display: "grid",
gridTemplateColumns: "40px 1fr"
},
icon: {
color: theme.palette.alert.icon.warning
},
root: {
background: theme.palette.alert.paper.warning,
marginBottom: theme.spacing(3)
},
titleBar: {
margin: theme.spacing(1, 0),
position: "relative"
}
}),
{ name: "Alert" }
);
export interface AlertProps {
className?: string;
show: boolean;
title: string;
}
const Alert: React.FC<AlertProps> = ({ children, className, show, title }) => {
const classes = useStyles({});
const [visible, setVisible] = useStateFromProps(show);
const { isDark } = useTheme();
return visible ? (
<Card elevation={0} className={classNames(classes.root, className)}>
<CardContent>
<div className={classes.content}>
<div>
<SVG
className={classes.icon}
src={isDark ? alertIconDark : alertIcon}
/>
</div>
<div>
<div className={classes.titleBar}>
<Typography variant="h5">{title}</Typography>
<IconButton
className={classes.close}
onClick={() => setVisible(false)}
>
<CloseIcon />
</IconButton>
</div>
{children}
</div>
</div>
</CardContent>
</Card>
) : null;
};
Alert.displayName = "Alert";
export default Alert;

View file

View file

@ -1,13 +1,19 @@
import Typography from "@material-ui/core/Typography";
import { LimitInfoFragment } from "@saleor/fragments/types/LimitInfoFragment";
import { makeStyles } from "@saleor/theme";
import React from "react";
import ExtendedPageHeader from "../ExtendedPageHeader";
import { RefreshLimits_shop_limits } from "../Shop/types/RefreshLimits";
import Skeleton from "../Skeleton";
const useStyles = makeStyles(
theme => ({
limit: {
marginRight: theme.spacing(3)
},
root: {
alignItems: "center",
display: "flex"
},
title: {
@ -24,15 +30,27 @@ const useStyles = makeStyles(
{ name: "PageHeader" }
);
interface LimitInfo {
data: RefreshLimits_shop_limits;
key: keyof LimitInfoFragment;
text: string;
}
interface PageHeaderProps {
children?: React.ReactNode;
className?: string;
inline?: boolean;
limit?: LimitInfo;
title?: React.ReactNode;
}
function formatLimit(limit: LimitInfo): string {
return `${limit.data.currentUsage[limit.key]}/${
limit.data.allowedUsage[limit.key]
} ${limit.text}`;
}
const PageHeader: React.FC<PageHeaderProps> = props => {
const { children, className, inline, title } = props;
const { children, className, inline, limit, title } = props;
const classes = useStyles(props);
@ -47,7 +65,14 @@ const PageHeader: React.FC<PageHeaderProps> = props => {
</Typography>
}
>
<div className={classes.root}>{children}</div>
<div className={classes.root}>
{limit && (
<Typography className={classes.limit} color="textSecondary">
{formatLimit(limit)}
</Typography>
)}
{children}
</div>
</ExtendedPageHeader>
);
};

View file

@ -1,6 +1,9 @@
import { limitFragment } from "@saleor/fragments/shop";
import makeQuery from "@saleor/hooks/makeQuery";
import gql from "graphql-tag";
import { TypedQuery } from "../../queries";
import { RefreshLimits, RefreshLimitsVariables } from "./types/RefreshLimits";
import { ShopInfo } from "./types/ShopInfo";
const shopInfo = gql`
@ -35,3 +38,22 @@ const shopInfo = gql`
}
`;
export const TypedShopInfoQuery = TypedQuery<ShopInfo, {}>(shopInfo);
const limitInfo = gql`
${limitFragment}
query RefreshLimits(
$channels: Boolean = false
$orders: Boolean = false
$productVariants: Boolean = false
$staffUsers: Boolean = false
$warehouses: Boolean = false
) {
shop {
...ShopLimitFragment
}
}
`;
export const useShopLimitsQuery = makeQuery<
RefreshLimits,
RefreshLimitsVariables
>(limitInfo);

View file

@ -0,0 +1,49 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL query operation: RefreshLimits
// ====================================================
export interface RefreshLimits_shop_limits_currentUsage {
__typename: "Limits";
channels: number | null;
orders: number | null;
productVariants: number | null;
staffUsers: number | null;
warehouses: number | null;
}
export interface RefreshLimits_shop_limits_allowedUsage {
__typename: "Limits";
channels: number | null;
orders: number | null;
productVariants: number | null;
staffUsers: number | null;
warehouses: number | null;
}
export interface RefreshLimits_shop_limits {
__typename: "LimitInfo";
currentUsage: RefreshLimits_shop_limits_currentUsage;
allowedUsage: RefreshLimits_shop_limits_allowedUsage;
}
export interface RefreshLimits_shop {
__typename: "Shop";
limits: RefreshLimits_shop_limits;
}
export interface RefreshLimits {
shop: RefreshLimits_shop;
}
export interface RefreshLimitsVariables {
channels?: boolean | null;
orders?: boolean | null;
productVariants?: boolean | null;
staffUsers?: boolean | null;
warehouses?: boolean | null;
}

View file

@ -1,3 +1,5 @@
import { AlertColors } from "@saleor/theme";
export type IThemeColors = Record<
"primary" | "secondary" | "error" | "paperBorder" | "autofill",
string
@ -28,11 +30,27 @@ export type IThemeColors = Record<
| "textHover",
string
>;
} & {
alert: AlertColors;
} & {
theme: "dark" | "light";
};
export const dark: IThemeColors = {
alert: {
paper: {
error: "#E0444E",
info: "#2E2F31",
success: "#5DC292",
warning: "#E29A2E"
},
icon: {
error: "#FE6E76",
info: "#FAFAFA",
success: "#5DC292",
warning: "#FFB84E"
}
},
highlightInactive: {
default: "#78797A"
},
@ -73,6 +91,20 @@ export const dark: IThemeColors = {
theme: "dark"
};
export const light: IThemeColors = {
alert: {
paper: {
error: "#FFD6D9",
info: "#FFFFFF",
success: "#DFF3E9",
warning: "#FFF4E4"
},
icon: {
error: "#FE6E76",
info: "#28234A",
success: "#5DC292",
warning: "#FFB84E"
}
},
highlightInactive: {
default: "#C8C8C8"
},

View file

@ -42,7 +42,7 @@ export const useStyles = makeStyles(
position: "fixed",
top: 0,
width: "100%",
zIndex: 1200
zIndex: 10000
},
error: {
"& > div": {

View file

@ -1,4 +1,5 @@
import { ShopInfo_shop_permissions } from "./components/Shop/types/ShopInfo";
import { ShopLimitFragment_limits } from "./fragments/types/ShopLimitFragment";
import { User_userPermissions } from "./fragments/types/User";
import {
FetchMoreProps,
@ -503,3 +504,42 @@ export const address = {
streetAddress1: "419 Ruiz Orchard Apt. 199",
streetAddress2: "0238 Cremin Freeway"
};
export const limits: ShopLimitFragment_limits = {
__typename: "LimitInfo",
allowedUsage: {
__typename: "Limits",
channels: 5,
orders: 500,
productVariants: 1000,
staffUsers: 10,
warehouses: 3
},
currentUsage: {
__typename: "Limits",
channels: 2,
orders: 230,
productVariants: 100,
staffUsers: 7,
warehouses: 1
}
};
export const limitsReached: ShopLimitFragment_limits = {
__typename: "LimitInfo",
allowedUsage: {
__typename: "Limits",
channels: 5,
orders: 500,
productVariants: 1000,
staffUsers: 10,
warehouses: 3
},
currentUsage: {
__typename: "Limits",
channels: 5,
orders: 500,
productVariants: 1000,
staffUsers: 10,
warehouses: 3
}
};

View file

@ -2,6 +2,27 @@ import gql from "graphql-tag";
import { fragmentAddress } from "./address";
export const limitFragment = gql`
fragment LimitInfoFragment on Limits {
channels @include(if: $channels)
orders @include(if: $orders)
productVariants @include(if: $productVariants)
staffUsers @include(if: $staffUsers)
warehouses @include(if: $warehouses)
}
fragment ShopLimitFragment on Shop {
limits {
currentUsage {
...LimitInfoFragment
}
allowedUsage {
...LimitInfoFragment
}
}
}
`;
export const shopFragment = gql`
${fragmentAddress}
fragment ShopFragment on Shop {

View file

@ -0,0 +1,17 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL fragment: LimitInfoFragment
// ====================================================
export interface LimitInfoFragment {
__typename: "Limits";
channels: number | null;
orders: number | null;
productVariants: number | null;
staffUsers: number | null;
warehouses: number | null;
}

View file

@ -0,0 +1,37 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL fragment: ShopLimitFragment
// ====================================================
export interface ShopLimitFragment_limits_currentUsage {
__typename: "Limits";
channels: number | null;
orders: number | null;
productVariants: number | null;
staffUsers: number | null;
warehouses: number | null;
}
export interface ShopLimitFragment_limits_allowedUsage {
__typename: "Limits";
channels: number | null;
orders: number | null;
productVariants: number | null;
staffUsers: number | null;
warehouses: number | null;
}
export interface ShopLimitFragment_limits {
__typename: "LimitInfo";
currentUsage: ShopLimitFragment_limits_currentUsage;
allowedUsage: ShopLimitFragment_limits_allowedUsage;
}
export interface ShopLimitFragment {
__typename: "Shop";
limits: ShopLimitFragment_limits;
}

View file

@ -1,7 +1,8 @@
import { isJwtError } from "@saleor/auth/errors";
import { commonMessages } from "@saleor/intl";
import { getMutationStatus, maybe } from "@saleor/misc";
import { getMutationStatus } from "@saleor/misc";
import { MutationResultAdditionalProps } from "@saleor/types";
import { GqlErrors, hasError } from "@saleor/utils/api";
import { ApolloError } from "apollo-client";
import { DocumentNode } from "graphql";
import {
@ -40,13 +41,7 @@ function makeMutation<TData, TVariables>(
const [mutateFn, result] = useBaseMutation(mutation, {
onCompleted,
onError: (err: ApolloError) => {
if (
maybe(
() =>
err.graphQLErrors[0].extensions.exception.code ===
"ReadOnlyException"
)
) {
if (hasError(err, GqlErrors.ReadOnlyException)) {
notify({
status: "error",
text: intl.formatMessage(commonMessages.readOnly)
@ -57,7 +52,7 @@ function makeMutation<TData, TVariables>(
status: "error",
text: intl.formatMessage(commonMessages.sessionExpired)
});
} else {
} else if (!hasError(err, GqlErrors.LimitReachedException)) {
notify({
status: "error",
text: intl.formatMessage(commonMessages.somethingWentWrong)

View file

@ -112,7 +112,7 @@ function useForm<T extends FormData>(
const result = onSubmit(data);
if (result) {
const errors = await result;
if (errors.length === 0) {
if (errors?.length === 0) {
setChanged(false);
}
}

View file

@ -55,6 +55,9 @@ export const commonMessages = defineMessages({
lastName: {
defaultMessage: "Last Name"
},
limitReached: {
defaultMessage: "Reached limit for this plan"
},
no: {
defaultMessage: "No"
},

View file

@ -10,6 +10,7 @@ import useUser from "./hooks/useUser";
import { commonMessages } from "./intl";
import { getMutationStatus } from "./misc";
import { MutationResultAdditionalProps } from "./types";
import { GqlErrors, hasError } from "./utils/api";
export interface TypedMutationInnerProps<TData, TVariables> {
children: (
@ -43,10 +44,7 @@ export function TypedMutation<TData, TVariables>(
text: intl.formatMessage(commonMessages.somethingWentWrong)
});
}
if (
err.graphQLErrors[0].extensions.exception?.code ===
"ReadOnlyException"
) {
if (hasError(err, GqlErrors.ReadOnlyException)) {
notify({
status: "error",
text: intl.formatMessage(commonMessages.readOnly)
@ -57,7 +55,7 @@ export function TypedMutation<TData, TVariables>(
status: "error",
text: intl.formatMessage(commonMessages.sessionExpired)
});
} else {
} else if (!hasError(err, GqlErrors.LimitReachedException)) {
notify({
status: "error",
text: intl.formatMessage(commonMessages.somethingWentWrong)

View file

@ -1,13 +1,16 @@
import Button from "@material-ui/core/Button";
import Card from "@material-ui/core/Card";
import Alert from "@saleor/components/Alert/Alert";
import CardMenu from "@saleor/components/CardMenu";
import Container from "@saleor/components/Container";
import FilterBar from "@saleor/components/FilterBar";
import PageHeader from "@saleor/components/PageHeader";
import { RefreshLimits_shop_limits } from "@saleor/components/Shop/types/RefreshLimits";
import { sectionNames } from "@saleor/intl";
import { OrderListUrlSortField } from "@saleor/orders/urls";
import { makeStyles } from "@saleor/theme";
import { FilterPageProps, PageListProps, SortPage } from "@saleor/types";
import { isLimitReached } from "@saleor/utils/limits";
import React from "react";
import { FormattedMessage, useIntl } from "react-intl";
@ -23,6 +26,7 @@ export interface OrderListPageProps
extends PageListProps,
FilterPageProps<OrderFilterKeys, OrderListFilterOpts>,
SortPage<OrderListUrlSortField> {
limits: RefreshLimits_shop_limits;
orders: OrderList_orders_edges_node[];
onSettingsOpen: () => void;
}
@ -40,6 +44,7 @@ const OrderListPage: React.FC<OrderListPageProps> = ({
currentTab,
initialSearch,
filterOpts,
limits,
tabs,
onAdd,
onAll,
@ -84,6 +89,15 @@ const OrderListPage: React.FC<OrderListPageProps> = ({
/>
</Button>
</PageHeader>
<Alert
show={isLimitReached(limits, "orders")}
title={intl.formatMessage({
defaultMessage: "Order limit reached",
description: "alert"
})}
>
<FormattedMessage defaultMessage="You have reached your order limit, you will be billed extra for orders above limit. If you would like to up your limit, contact your administration staff about raising your limits." />
</Alert>
<Card>
<FilterBar
currentTab={currentTab}

View file

@ -4,6 +4,7 @@ import DeleteFilterTabDialog from "@saleor/components/DeleteFilterTabDialog";
import SaveFilterTabDialog, {
SaveFilterTabDialogFormData
} from "@saleor/components/SaveFilterTabDialog";
import { useShopLimitsQuery } from "@saleor/components/Shop/query";
import useListSettings from "@saleor/hooks/useListSettings";
import useNavigator from "@saleor/hooks/useNavigator";
import useNotifier from "@saleor/hooks/useNotifier";
@ -71,6 +72,11 @@ export const OrderList: React.FC<OrderListProps> = ({ params }) => {
});
const { channel, availableChannels } = useAppChannel();
const limitOpts = useShopLimitsQuery({
variables: {
orders: true
}
});
const noChannel = !channel && typeof channel !== "undefined";
const channelOpts = availableChannels
@ -150,6 +156,7 @@ export const OrderList: React.FC<OrderListProps> = ({ params }) => {
currentTab={currentTab}
disabled={loading}
filterOpts={getFilterOpts(params, channelOpts)}
limits={limitOpts.data?.shop.limits}
orders={maybe(() => data.orders.edges.map(edge => edge.node))}
pageInfo={pageInfo}
sort={getSortParams(params)}

View file

@ -1,5 +1,6 @@
import Button from "@material-ui/core/Button";
import Card from "@material-ui/core/Card";
import Alert from "@saleor/components/Alert/Alert";
import CardMenu from "@saleor/components/CardMenu";
import ColumnPicker, {
ColumnPickerChoice
@ -7,6 +8,7 @@ import ColumnPicker, {
import Container from "@saleor/components/Container";
import FilterBar from "@saleor/components/FilterBar";
import PageHeader from "@saleor/components/PageHeader";
import { RefreshLimits_shop_limits } from "@saleor/components/Shop/types/RefreshLimits";
import { ProductListColumns } from "@saleor/config";
import { sectionNames } from "@saleor/intl";
import {
@ -23,6 +25,7 @@ import {
PageListProps,
SortPage
} from "@saleor/types";
import { hasLimits, isLimitReached } from "@saleor/utils/limits";
import React from "react";
import { FormattedMessage, useIntl } from "react-intl";
@ -46,6 +49,7 @@ export interface ProductListPageProps
channelsCount: number;
currencySymbol: string;
gridAttributes: GridAttributes_grid_edges_node[];
limits: RefreshLimits_shop_limits;
totalGridAttributes: number;
products: ProductList_products_edges_node[];
onExport: () => void;
@ -70,6 +74,7 @@ export const ProductListPage: React.FC<ProductListPageProps> = props => {
currentTab,
defaultSettings,
gridAttributes,
limits,
availableInGridAttributes,
filterOpts,
hasMore,
@ -120,9 +125,20 @@ export const ProductListPage: React.FC<ProductListPageProps> = props => {
}))
];
const limitReached = isLimitReached(limits, "productVariants");
return (
<Container>
<PageHeader title={intl.formatMessage(sectionNames.products)}>
<PageHeader
title={intl.formatMessage(sectionNames.products)}
limit={
hasLimits(limits, "productVariants") && {
data: limits,
key: "productVariants",
text: "SKUs used"
}
}
>
<CardMenu
className={classes.settings}
menuItems={[
@ -153,6 +169,7 @@ export const ProductListPage: React.FC<ProductListPageProps> = props => {
onSave={handleSave}
/>
<Button
disabled={limitReached}
onClick={onAdd}
color="primary"
variant="contained"
@ -164,6 +181,15 @@ export const ProductListPage: React.FC<ProductListPageProps> = props => {
/>
</Button>
</PageHeader>
<Alert
show={limitReached}
title={intl.formatMessage({
defaultMessage: "SKU limit reached",
description: "alert"
})}
>
<FormattedMessage defaultMessage="You have reached your SKU limit, you will be no longer able to add SKUs to your store. If you would like to up your limit, contact your administration staff about raising your limits." />
</Alert>
<Card>
<FilterBar
currencySymbol={currencySymbol}

View file

@ -2,7 +2,7 @@ import placeholderImage from "@assets/images/placeholder255x255.png";
import { channelsList } from "@saleor/channels/fixtures";
import { createChannelsData } from "@saleor/channels/utils";
import { collections } from "@saleor/collections/fixtures";
import { fetchMoreProps, listActionsProps } from "@saleor/fixtures";
import { fetchMoreProps, limits, listActionsProps } from "@saleor/fixtures";
import { product as productFixture } from "@saleor/products/fixtures";
import { taxTypes } from "@saleor/storybook/stories/taxes/fixtures";
import { warehouseList } from "@saleor/warehouses/fixtures";
@ -42,6 +42,7 @@ const props: ProductUpdatePageProps = {
hasChannelChanged: false,
header: product.name,
media: product.media,
limits,
onAssignReferencesClick: () => undefined,
onBack: () => undefined,
onChannelsChange: () => undefined,

View file

@ -16,6 +16,7 @@ import Metadata from "@saleor/components/Metadata/Metadata";
import PageHeader from "@saleor/components/PageHeader";
import SaveButtonBar from "@saleor/components/SaveButtonBar";
import SeoForm from "@saleor/components/SeoForm";
import { RefreshLimits_shop_limits } from "@saleor/components/Shop/types/RefreshLimits";
import { SingleAutocompleteChoiceType } from "@saleor/components/SingleAutocompleteSelectField";
import { ProductChannelListingErrorFragment } from "@saleor/fragments/types/ProductChannelListingErrorFragment";
import { ProductErrorWithAttributesFragment } from "@saleor/fragments/types/ProductErrorWithAttributesFragment";
@ -73,6 +74,7 @@ export interface ProductUpdatePageProps extends ListActions, ChannelProps {
fetchMoreCategories: FetchMoreProps;
fetchMoreCollections: FetchMoreProps;
isMediaUrlModalVisible?: boolean;
limits: RefreshLimits_shop_limits;
variants: ProductDetails_product_variants[];
media: ProductDetails_product_media[];
hasChannelChanged: boolean;
@ -139,6 +141,7 @@ export const ProductUpdatePage: React.FC<ProductUpdatePageProps> = ({
media,
hasChannelChanged,
header,
limits,
placeholderImage,
product,
saveButtonBarState,
@ -310,6 +313,7 @@ export const ProductUpdatePage: React.FC<ProductUpdatePageProps> = ({
{hasVariants ? (
<ProductVariants
disabled={disabled}
limits={limits}
variants={variants}
product={product}
onRowClick={onVariantShow}

View file

@ -1,6 +1,7 @@
import { attributes } from "@saleor/attributes/fixtures";
import { productChannels } from "@saleor/channels/fixtures";
import Container from "@saleor/components/Container";
import { limitsReached } from "@saleor/fixtures";
import { ProductVariantBulkCreate_productVariantBulkCreate_errors } from "@saleor/products/types/ProductVariantBulkCreate";
import { ProductErrorCode } from "@saleor/types/globalTypes";
import { warehouseList } from "@saleor/warehouses/fixtures";
@ -98,6 +99,7 @@ const props: ProductVariantCreatorContentProps = {
},
dispatchFormDataAction: () => undefined,
errors: [],
variantsLeft: 6,
step: ProductVariantCreatorStep.values,
warehouses: warehouseList
};
@ -190,5 +192,15 @@ storiesOf("Views / Products / Create multiple variants / summary", module)
storiesOf("Views / Products / Create multiple variants", module)
.addDecorator(Decorator)
.add("interactive", () => (
<ProductVariantCreatorPage {...props} onSubmit={() => undefined} />
<ProductVariantCreatorPage
{...props}
limits={{
...limitsReached,
currentUsage: {
...limitsReached.currentUsage,
productVariants: limitsReached.currentUsage.productVariants - 6
}
}}
onSubmit={() => undefined}
/>
));

View file

@ -22,6 +22,7 @@ export interface ProductVariantCreatorContentProps {
dispatchFormDataAction: React.Dispatch<ProductVariantCreateReducerAction>;
errors: ProductVariantBulkCreate_productVariantBulkCreate_errors[];
step: ProductVariantCreatorStep;
variantsLeft: number | null;
warehouses: WarehouseFragment[];
}
@ -32,6 +33,7 @@ const ProductVariantCreatorContent: React.FC<ProductVariantCreatorContentProps>
dispatchFormDataAction,
errors,
step,
variantsLeft,
warehouses
}) => {
const selectedAttributes = attributes.filter(attribute =>
@ -48,6 +50,7 @@ const ProductVariantCreatorContent: React.FC<ProductVariantCreatorContentProps>
<ProductVariantCreateValues
attributes={selectedAttributes}
data={data}
variantsLeft={variantsLeft}
onValueClick={(attributeId, valueId) =>
dispatchFormDataAction({
selectValue: {

View file

@ -3,6 +3,7 @@ import Typography from "@material-ui/core/Typography";
import Container from "@saleor/components/Container";
import Hr from "@saleor/components/Hr";
import PageHeader from "@saleor/components/PageHeader";
import { RefreshLimits_shop_limits } from "@saleor/components/Shop/types/RefreshLimits";
import useWizard from "@saleor/hooks/useWizard";
import { validatePrice } from "@saleor/products/utils/validation";
import { makeStyles } from "@saleor/theme";
@ -15,6 +16,7 @@ import ProductVariantCreatorContent, {
ProductVariantCreatorContentProps
} from "./ProductVariantCreatorContent";
import ProductVariantCreateTabs from "./ProductVariantCreatorTabs";
import { getVariantsNumber } from "./ProductVariantCreatorValues";
import reduceProductVariantCreateFormData, {
ProductVariantCreateReducerActionType
} from "./reducer";
@ -42,11 +44,16 @@ const useStyles = makeStyles(
function canHitNext(
step: ProductVariantCreatorStep,
data: ProductVariantCreateFormData
data: ProductVariantCreateFormData,
variantsLeft: number | null
): boolean {
switch (step) {
case ProductVariantCreatorStep.values:
return data.attributes.every(attribute => attribute.values.length > 0);
return (
(data.attributes.every(attribute => attribute.values.length > 0) &&
variantsLeft === null) ||
getVariantsNumber(data) <= variantsLeft
);
case ProductVariantCreatorStep.prices:
if (data.price.mode === "all") {
if (data.price.channels.some(channel => validatePrice(channel.price))) {
@ -85,8 +92,9 @@ function canHitNext(
export interface ProductVariantCreatePageProps
extends Omit<
ProductVariantCreatorContentProps,
"data" | "dispatchFormDataAction" | "step" | "onStepClick"
"data" | "dispatchFormDataAction" | "step" | "variantsLeft" | "onStepClick"
> {
limits: RefreshLimits_shop_limits;
onSubmit: (data: ProductVariantBulkCreateInput[]) => void;
}
@ -138,6 +146,7 @@ const ProductVariantCreatePage: React.FC<ProductVariantCreatePageProps> = props
attributes,
channelListings,
errors,
limits,
onSubmit,
warehouses,
...contentProps
@ -177,6 +186,10 @@ const ProductVariantCreatePage: React.FC<ProductVariantCreatePageProps> = props
React.useEffect(reloadForm, [attributes.length, warehouses.length]);
const variantsLeft = limits.allowedUsage.productVariants
? limits.allowedUsage.productVariants - limits.currentUsage.productVariants
: null;
return (
<Container>
<ProductVariantCreateTabs step={step} onStepClick={setStep} />
@ -203,7 +216,7 @@ const ProductVariantCreatePage: React.FC<ProductVariantCreatePageProps> = props
data-test-id="next-step"
className={classes.button}
color="primary"
disabled={!canHitNext(step, wizardData)}
disabled={!canHitNext(step, wizardData, variantsLeft)}
variant="contained"
onClick={nextStep}
>
@ -213,7 +226,7 @@ const ProductVariantCreatePage: React.FC<ProductVariantCreatePageProps> = props
<Button
className={classes.button}
color="primary"
disabled={!canHitNext(step, wizardData)}
disabled={!canHitNext(step, wizardData, variantsLeft)}
variant="contained"
onClick={() => onSubmit(wizardData.variants)}
>
@ -232,6 +245,7 @@ const ProductVariantCreatePage: React.FC<ProductVariantCreatePageProps> = props
data={wizardData}
dispatchFormDataAction={dispatchFormDataAction}
errors={errors}
variantsLeft={variantsLeft}
step={step}
warehouses={warehouses}
/>

View file

@ -1,5 +1,6 @@
import Card from "@material-ui/core/Card";
import CardContent from "@material-ui/core/CardContent";
import Alert from "@saleor/components/Alert/Alert";
import CardSpacer from "@saleor/components/CardSpacer";
import CardTitle from "@saleor/components/CardTitle";
import ControlledCheckbox from "@saleor/components/ControlledCheckbox";
@ -9,12 +10,21 @@ import { ProductDetails_product_productType_variantAttributes } from "@saleor/pr
import { makeStyles } from "@saleor/theme";
import { isSelected } from "@saleor/utils/lists";
import React from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { ProductVariantCreateFormData } from "./form";
export function getVariantsNumber(data: ProductVariantCreateFormData): number {
return data.attributes.reduce(
(variants, attribute) => variants * attribute.values.length,
1
);
}
export interface ProductVariantCreatorValuesProps {
attributes: ProductDetails_product_productType_variantAttributes[];
data: ProductVariantCreateFormData;
variantsLeft: number | null;
onValueClick: (attributeId: string, valueId: string) => void;
}
@ -30,11 +40,30 @@ const useStyles = makeStyles(
);
const ProductVariantCreatorValues: React.FC<ProductVariantCreatorValuesProps> = props => {
const { attributes, data, onValueClick } = props;
const { attributes, data, variantsLeft, onValueClick } = props;
const classes = useStyles(props);
const intl = useIntl();
const variantsNumber = getVariantsNumber(data);
return (
<>
{variantsLeft !== null && (
<Alert
show={variantsNumber > variantsLeft}
title={intl.formatMessage({
defaultMessage: "SKU limit reached",
description: "alert"
})}
>
<FormattedMessage
defaultMessage="You choices will add {variantsNumber} SKUs to your catalog which will exceed your limit by {aboveLimitVariantsNumber}. If you would like to up your limit, contact your administration staff about raising your limits."
values={{
variantsNumber,
aboveLimitVariantsNumber: variantsNumber - variantsLeft
}}
/>
</Alert>
)}
{attributes.map(attribute => (
<React.Fragment key={attribute.id}>
<Card>

View file

@ -5,11 +5,13 @@ import Hidden from "@material-ui/core/Hidden";
import { fade } from "@material-ui/core/styles/colorManipulator";
import TableCell from "@material-ui/core/TableCell";
import Typography from "@material-ui/core/Typography";
import Alert from "@saleor/components/Alert/Alert";
import CardTitle from "@saleor/components/CardTitle";
import Checkbox from "@saleor/components/Checkbox";
import LinkChoice from "@saleor/components/LinkChoice";
import Money from "@saleor/components/Money";
import ResponsiveTable from "@saleor/components/ResponsiveTable";
import { RefreshLimits_shop_limits } from "@saleor/components/Shop/types/RefreshLimits";
import { SingleAutocompleteChoiceType } from "@saleor/components/SingleAutocompleteSelectField";
import Skeleton from "@saleor/components/Skeleton";
import {
@ -18,6 +20,7 @@ import {
} from "@saleor/components/SortableTable";
import TableHead from "@saleor/components/TableHead";
import { makeStyles } from "@saleor/theme";
import { isLimitReached } from "@saleor/utils/limits";
import React from "react";
import { FormattedMessage, IntlShape, useIntl } from "react-intl";
@ -82,6 +85,9 @@ const useStyles = makeStyles(
width: 200
}
},
alert: {
margin: theme.spacing(3, 3, 0)
},
colGrab: {
width: 60
},
@ -180,6 +186,7 @@ function getAvailabilityLabel(
interface ProductVariantsProps extends ListActions, ChannelProps {
disabled: boolean;
limits: RefreshLimits_shop_limits;
product: ProductDetails_product;
variants: ProductDetails_product_variants[];
onVariantReorder: ReorderAction;
@ -194,6 +201,7 @@ const numberOfColumns = 7;
export const ProductVariants: React.FC<ProductVariantsProps> = props => {
const {
disabled,
limits,
variants,
product,
onRowClick,
@ -213,6 +221,7 @@ export const ProductVariants: React.FC<ProductVariantsProps> = props => {
const intl = useIntl();
const [warehouse, setWarehouse] = React.useState<string>(null);
const hasVariants = maybe(() => variants.length > 0, true);
const limitReached = isLimitReached(limits, "productVariants");
return (
<Card>
@ -224,6 +233,7 @@ export const ProductVariants: React.FC<ProductVariantsProps> = props => {
toolbar={
hasVariants ? (
<Button
disabled={limitReached}
onClick={onVariantAdd}
variant="text"
color="primary"
@ -236,6 +246,7 @@ export const ProductVariants: React.FC<ProductVariantsProps> = props => {
</Button>
) : (
<Button
disabled={limitReached}
onClick={onVariantsAdd}
variant="text"
color="primary"
@ -250,6 +261,17 @@ export const ProductVariants: React.FC<ProductVariantsProps> = props => {
}
/>
<Alert
className={classes.alert}
show={limitReached}
title={intl.formatMessage({
defaultMessage: "SKU limit reached",
description: "alert"
})}
>
<FormattedMessage defaultMessage="You have reached your SKU limit, you will be no longer able to add SKUs to your store. If you would like to up your limit, contact your administration staff about raising your limits." />
</Alert>
{variants.length > 0 ? (
<CardContent className={classes.warehouseSelectContainer}>
<Typography className={classes.warehouseLabel}>

View file

@ -7,6 +7,7 @@ import DeleteFilterTabDialog from "@saleor/components/DeleteFilterTabDialog";
import SaveFilterTabDialog, {
SaveFilterTabDialogFormData
} from "@saleor/components/SaveFilterTabDialog";
import { useShopLimitsQuery } from "@saleor/components/Shop/query";
import {
DEFAULT_INITIAL_PAGINATION_DATA,
DEFAULT_INITIAL_SEARCH_DATA,
@ -150,6 +151,11 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
skip: params.action !== "export"
});
const { availableChannels, channel } = useAppChannel();
const limitOpts = useShopLimitsQuery({
variables: {
productVariants: true
}
});
const noChannel = !channel && typeof channel !== "undefined";
@ -303,6 +309,7 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
});
reset();
refetch();
limitOpts.refetch();
}
}
});
@ -370,6 +377,7 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
)}
onAdd={() => navigate(productAddUrl())}
disabled={loading}
limits={limitOpts.data?.shop.limits}
products={maybe(() => data.products.edges.map(edge => edge.node))}
onFetchMore={() =>
attributes.loadMore(

View file

@ -14,6 +14,7 @@ import useAppChannel from "@saleor/components/AppLayout/AppChannelContext";
import { AttributeInput } from "@saleor/components/Attributes";
import ChannelsAvailabilityDialog from "@saleor/components/ChannelsAvailabilityDialog";
import NotFoundPage from "@saleor/components/NotFoundPage";
import { useShopLimitsQuery } from "@saleor/components/Shop/query";
import { WindowTitle } from "@saleor/components/WindowTitle";
import { DEFAULT_INITIAL_SEARCH_DATA } from "@saleor/config";
import { useFileUploadMutation } from "@saleor/files/mutations";
@ -136,6 +137,11 @@ export const ProductUpdate: React.FC<ProductUpdateProps> = ({ id, params }) => {
variables: { id }
});
const { channel } = useAppChannel();
const limitOpts = useShopLimitsQuery({
variables: {
productVariants: true
}
});
const [uploadFile, uploadFileOpts] = useFileUploadMutation({});
@ -208,6 +214,7 @@ export const ProductUpdate: React.FC<ProductUpdateProps> = ({ id, params }) => {
closeModal();
reset();
refetch();
limitOpts.refetch();
}
}
});
@ -459,6 +466,7 @@ export const ProductUpdate: React.FC<ProductUpdateProps> = ({ id, params }) => {
channelsErrors={channelsErrors}
fetchCategories={searchCategories}
fetchCollections={searchCollections}
limits={limitOpts.data?.shop.limits}
saveButtonBarState={formTransitionState}
media={data?.product?.media}
header={product?.name}

View file

@ -1,3 +1,4 @@
import { useShopLimitsQuery } from "@saleor/components/Shop/query";
import { WindowTitle } from "@saleor/components/WindowTitle";
import useNavigator from "@saleor/hooks/useNavigator";
import useNotifier from "@saleor/hooks/useNotifier";
@ -40,6 +41,11 @@ const ProductVariantCreator: React.FC<ProductVariantCreatorProps> = ({
}
}
});
const limitOpts = useShopLimitsQuery({
variables: {
productVariants: true
}
});
return (
<>
@ -61,6 +67,7 @@ const ProductVariantCreator: React.FC<ProductVariantCreatorProps> = ({
price: ""
}))}
attributes={data?.product?.productType?.variantAttributes || []}
limits={limitOpts.data?.shop?.limits}
onSubmit={inputs =>
bulkProductVariantCreate({
variables: { id, inputs }

View file

@ -1,5 +1,7 @@
import {
filterPageProps,
limits,
limitsReached,
pageListProps,
searchPageProps,
sortPageProps,
@ -26,6 +28,7 @@ const props: StaffListPageProps = {
value: StaffMemberStatus.ACTIVE
}
},
limits,
onAdd: undefined,
onBack: () => undefined,
sort: {
@ -40,4 +43,8 @@ storiesOf("Views / Staff / Staff members", module)
.add("default", () => <StaffListPage {...props} />)
.add("when loading", () => (
<StaffListPage {...props} disabled={true} staffMembers={undefined} />
))
.add("no limits", () => <StaffListPage {...props} limits={undefined} />)
.add("limits reached", () => (
<StaffListPage {...props} limits={limitsReached} />
));

View file

@ -1,9 +1,11 @@
import Button from "@material-ui/core/Button";
import Card from "@material-ui/core/Card";
import Alert from "@saleor/components/Alert/Alert";
import AppHeader from "@saleor/components/AppHeader";
import { Container } from "@saleor/components/Container";
import FilterBar from "@saleor/components/FilterBar";
import PageHeader from "@saleor/components/PageHeader";
import { RefreshLimits_shop_limits } from "@saleor/components/Shop/types/RefreshLimits";
import { sectionNames } from "@saleor/intl";
import { StaffListUrlSortField } from "@saleor/staff/urls";
import {
@ -12,6 +14,7 @@ import {
SortPage,
TabPageProps
} from "@saleor/types";
import { hasLimits, isLimitReached } from "@saleor/utils/limits";
import React from "react";
import { FormattedMessage, useIntl } from "react-intl";
@ -28,6 +31,7 @@ export interface StaffListPageProps
FilterPageProps<StaffFilterKeys, StaffListFilterOpts>,
SortPage<StaffListUrlSortField>,
TabPageProps {
limits: RefreshLimits_shop_limits;
staffMembers: StaffList_staffUsers_edges_node[];
onAdd: () => void;
onBack: () => void;
@ -37,6 +41,7 @@ const StaffListPage: React.FC<StaffListPageProps> = ({
currentTab,
filterOpts,
initialSearch,
limits,
onAdd,
onAll,
onBack,
@ -51,20 +56,44 @@ const StaffListPage: React.FC<StaffListPageProps> = ({
const intl = useIntl();
const structure = createFilterStructure(intl, filterOpts);
const reachedLimit = isLimitReached(limits, "staffUsers");
return (
<Container>
<AppHeader onBack={onBack}>
{intl.formatMessage(sectionNames.configuration)}
</AppHeader>
<PageHeader title={intl.formatMessage(sectionNames.staff)}>
<Button color="primary" variant="contained" onClick={onAdd}>
<PageHeader
title={intl.formatMessage(sectionNames.staff)}
limit={
hasLimits(limits, "staffUsers") && {
data: limits,
key: "staffUsers",
text: "members"
}
}
>
<Button
color="primary"
disabled={reachedLimit}
variant="contained"
onClick={onAdd}
>
<FormattedMessage
defaultMessage="Invite staff member"
description="button"
/>
</Button>
</PageHeader>
<Alert
title={intl.formatMessage({
defaultMessage: "Staff Member limit reached",
description: "alert"
})}
show={reachedLimit}
>
<FormattedMessage defaultMessage="You have reached your staff member limit, you will be no longer able to add staff members to your store. If you would like to up your limit, contact your administration staff about raising your limits." />
</Alert>
<Card>
<FilterBar
allTabLabel={intl.formatMessage({

View file

@ -43,7 +43,7 @@ const staffMemberAddMutation = gql`
}
}
`;
export const TypedStaffMemberAddMutation = TypedMutation<
export const useStaffMemberAddMutation = makeMutation<
StaffMemberAdd,
StaffMemberAddVariables
>(staffMemberAddMutation);

View file

@ -3,6 +3,7 @@ import DeleteFilterTabDialog from "@saleor/components/DeleteFilterTabDialog";
import SaveFilterTabDialog, {
SaveFilterTabDialogFormData
} from "@saleor/components/SaveFilterTabDialog";
import { useShopLimitsQuery } from "@saleor/components/Shop/query";
import { APP_MOUNT_URI, DEFAULT_INITIAL_SEARCH_DATA } from "@saleor/config";
import { configurationMenuUrl } from "@saleor/configuration";
import useListSettings from "@saleor/hooks/useListSettings";
@ -27,9 +28,8 @@ import StaffAddMemberDialog, {
AddMemberFormData
} from "../../components/StaffAddMemberDialog";
import StaffListPage from "../../components/StaffListPage";
import { TypedStaffMemberAddMutation } from "../../mutations";
import { useStaffMemberAddMutation } from "../../mutations";
import { useStaffListQuery } from "../../queries";
import { StaffMemberAdd } from "../../types/StaffMemberAdd";
import {
staffListUrl,
StaffListUrlDialog,
@ -74,6 +74,23 @@ export const StaffList: React.FC<StaffListProps> = ({ params }) => {
displayLoader: true,
variables: queryVariables
});
const limitOpts = useShopLimitsQuery({
variables: {
staffUsers: true
}
});
const [addStaffMember, addStaffMemberData] = useStaffMemberAddMutation({
onCompleted: data => {
if (data.staffCreate.errors.length === 0) {
notify({
status: "success",
text: intl.formatMessage(commonMessages.savedChanges)
});
navigate(staffMemberDetailsUrl(data.staffCreate.user.id));
}
}
});
const { loadNextPage, loadPreviousPage, pageInfo } = paginate(
staffQueryData?.staffUsers.pageInfo,
@ -127,16 +144,6 @@ export const StaffList: React.FC<StaffListProps> = ({ params }) => {
handleTabChange(tabs.length + 1);
};
const handleStaffMemberAddSuccess = (data: StaffMemberAdd) => {
if (data.staffCreate.errors.length === 0) {
notify({
status: "success",
text: intl.formatMessage(commonMessages.savedChanges)
});
navigate(staffMemberDetailsUrl(data.staffCreate.user.id));
}
};
const {
loadMore: loadMorePermissionGroups,
search: searchPermissionGroups,
@ -145,9 +152,6 @@ export const StaffList: React.FC<StaffListProps> = ({ params }) => {
variables: DEFAULT_INITIAL_SEARCH_DATA
});
return (
<TypedStaffMemberAddMutation onCompleted={handleStaffMemberAddSuccess}>
{(addStaffMember, addStaffMemberData) => {
const handleStaffMemberAdd = (variables: AddMemberFormData) =>
addStaffMember({
variables: {
@ -178,13 +182,12 @@ export const StaffList: React.FC<StaffListProps> = ({ params }) => {
onTabDelete={() => openModal("delete-search")}
onTabSave={() => openModal("save-search")}
tabs={tabs.map(tab => tab.name)}
disabled={loading || addStaffMemberData.loading}
disabled={loading || addStaffMemberData.loading || limitOpts.loading}
limits={limitOpts.data?.shop.limits}
settings={settings}
pageInfo={pageInfo}
sort={getSortParams(params)}
staffMembers={staffQueryData?.staffUsers.edges.map(
edge => edge.node
)}
staffMembers={staffQueryData?.staffUsers.edges.map(edge => edge.node)}
onAdd={() => openModal("add")}
onBack={() => navigate(configurationMenuUrl)}
onNextPage={loadNextPage}
@ -205,8 +208,7 @@ export const StaffList: React.FC<StaffListProps> = ({ params }) => {
onClose={closeModal}
onConfirm={handleStaffMemberAdd}
fetchMorePermissionGroups={{
hasMore:
searchPermissionGroupsOpts.data?.search.pageInfo.hasNextPage,
hasMore: searchPermissionGroupsOpts.data?.search.pageInfo.hasNextPage,
loading: searchPermissionGroupsOpts.loading,
onFetchMore: loadMorePermissionGroups
}}
@ -227,9 +229,6 @@ export const StaffList: React.FC<StaffListProps> = ({ params }) => {
/>
</>
);
}}
</TypedStaffMemberAddMutation>
);
};
export default StaffList;

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,8 @@ import React from "react";
import {
filterPageProps,
limits,
limitsReached,
listActionsProps,
pageListProps,
sortPageProps
@ -46,6 +48,7 @@ const props: OrderListPageProps = {
value: [OrderStatusFilter.CANCELED, OrderStatusFilter.FULFILLED]
}
},
limits,
onSettingsOpen: () => undefined,
orders,
sort: {
@ -65,4 +68,8 @@ storiesOf("Views / Orders / Order list", module)
disabled={true}
/>
))
.add("when no data", () => <OrderListPage {...props} orders={[]} />);
.add("when no data", () => <OrderListPage {...props} orders={[]} />)
.add("no limits", () => <OrderListPage {...props} limits={undefined} />)
.add("limits reached", () => (
<OrderListPage {...props} limits={limitsReached} />
));

View file

@ -11,6 +11,8 @@ import React from "react";
import {
fetchMoreProps,
filterPageProps,
limits,
limitsReached,
listActionsProps,
pageListProps,
sortPageProps
@ -41,6 +43,7 @@ const props: ProductListPageProps = {
defaultSettings: defaultListSettings[ListViews.PRODUCT_LIST],
filterOpts: productListFilterOpts,
gridAttributes: attributes,
limits,
onExport: () => undefined,
products,
selectedChannelId: "123",
@ -71,4 +74,8 @@ storiesOf("Views / Products / Product list", module)
selectedChannelId={""}
products={products.map(product => ({ ...product, channelListings: [] }))}
/>
))
.add("no limits", () => <ProductListPage {...props} limits={undefined} />)
.add("limits reached", () => (
<ProductListPage {...props} limits={limitsReached} />
));

View file

@ -2,7 +2,12 @@ import placeholderImage from "@assets/images/placeholder255x255.png";
import { channelsList } from "@saleor/channels/fixtures";
import { createChannelsData } from "@saleor/channels/utils";
import { collections } from "@saleor/collections/fixtures";
import { fetchMoreProps, listActionsProps } from "@saleor/fixtures";
import {
fetchMoreProps,
limits,
limitsReached,
listActionsProps
} from "@saleor/fixtures";
import ProductUpdatePage, {
ProductUpdatePageProps
} from "@saleor/products/components/ProductUpdatePage";
@ -42,6 +47,7 @@ const props: ProductUpdatePageProps = {
hasChannelChanged: false,
header: product.name,
media: product.media,
limits,
onAssignReferencesClick: () => undefined,
onBack: () => undefined,
onChannelsChange: () => undefined,
@ -184,4 +190,8 @@ storiesOf("Views / Products / Product edit", module)
))
.add("with channels", () => (
<ProductUpdatePage {...props} currentChannels={channels} />
))
.add("no limits", () => <ProductUpdatePage {...props} limits={undefined} />)
.add("limits reached", () => (
<ProductUpdatePage {...props} limits={limitsReached} />
));

View file

@ -45,7 +45,7 @@ const buttonOverrides = (colors: IThemeColors): Overrides => ({
}
},
textPrimary: {
"& span": {
"&:not($disabled) span": {
color: colors.primary
}
}

View file

@ -217,7 +217,8 @@ const createTheme = (colors: IThemeColors): SaleorTheme =>
color: colors.font.default
},
h5: {
fontSize: "1.3125rem"
fontSize: "1.3125rem",
fontWeight: 500
}
}
});

View file

@ -6,6 +6,7 @@ const createPalette = (colors: IThemeColors): SaleorPaletteOptions => ({
action: {
active: colors.checkbox.default
},
alert: colors.alert,
background: colors.background,
divider: colors.divider,
error: {

View file

@ -5,7 +5,13 @@ import {
PaletteOptions
} from "@material-ui/core/styles/createPalette";
export type AlertPalette = Record<
"success" | "error" | "warning" | "info",
string
>;
export type AlertColors = Record<"paper" | "icon", AlertPalette>;
interface ExtraPaletteOptions {
alert?: AlertColors;
textHighlighted?: {
active: string;
inactive: string;

12
src/utils/api.ts Normal file
View file

@ -0,0 +1,12 @@
import { ApolloError } from "apollo-client";
export enum GqlErrors {
LimitReachedException = "LimitReachedException",
ReadOnlyException = "ReadOnlyException"
}
export function hasError(err: ApolloError, ...errorCodes: string[]): boolean {
return err.graphQLErrors.some(gqlError =>
errorCodes.includes(gqlError.extensions.exception.code)
);
}

24
src/utils/limits.ts Normal file
View file

@ -0,0 +1,24 @@
import { RefreshLimits_shop_limits } from "@saleor/components/Shop/types/RefreshLimits";
import { LimitInfoFragment } from "@saleor/fragments/types/LimitInfoFragment";
export function hasLimits(
limits: RefreshLimits_shop_limits,
key: keyof LimitInfoFragment
): boolean {
if (limits === undefined) {
return false;
}
return limits.allowedUsage[key] !== null;
}
export function isLimitReached(
limits: RefreshLimits_shop_limits,
key: keyof LimitInfoFragment
): boolean {
if (!hasLimits(limits, key)) {
return false;
}
return limits.currentUsage[key] >= limits.allowedUsage[key];
}

View file

@ -1,4 +1,6 @@
import {
limits,
limitsReached,
pageListProps,
searchPageProps,
sortPageProps,
@ -19,6 +21,7 @@ const props: WarehouseListPageProps = {
...searchPageProps,
...sortPageProps,
...tabPageProps,
limits,
onBack: () => undefined,
onRemove: () => undefined,
sort: {
@ -34,4 +37,8 @@ storiesOf("Views / Warehouses / Warehouse list", module)
.add("loading", () => (
<WarehouseListPage {...props} disabled={true} warehouses={undefined} />
))
.add("no data", () => <WarehouseListPage {...props} warehouses={[]} />);
.add("no data", () => <WarehouseListPage {...props} warehouses={[]} />)
.add("no limits", () => <WarehouseListPage {...props} limits={undefined} />)
.add("limits reached", () => (
<WarehouseListPage {...props} limits={limitsReached} />
));

View file

@ -1,9 +1,11 @@
import Button from "@material-ui/core/Button";
import Card from "@material-ui/core/Card";
import Alert from "@saleor/components/Alert/Alert";
import AppHeader from "@saleor/components/AppHeader";
import Container from "@saleor/components/Container";
import PageHeader from "@saleor/components/PageHeader";
import SearchBar from "@saleor/components/SearchBar";
import { RefreshLimits_shop_limits } from "@saleor/components/Shop/types/RefreshLimits";
import { WarehouseWithShippingFragment } from "@saleor/fragments/types/WarehouseWithShippingFragment";
import { sectionNames } from "@saleor/intl";
import {
@ -12,6 +14,7 @@ import {
SortPage,
TabPageProps
} from "@saleor/types";
import { hasLimits, isLimitReached } from "@saleor/utils/limits";
import { WarehouseListUrlSortField } from "@saleor/warehouses/urls";
import React from "react";
import { FormattedMessage, useIntl } from "react-intl";
@ -23,6 +26,7 @@ export interface WarehouseListPageProps
SearchPageProps,
SortPage<WarehouseListUrlSortField>,
TabPageProps {
limits: RefreshLimits_shop_limits;
warehouses: WarehouseWithShippingFragment[];
onBack: () => void;
onRemove: (id: string) => void;
@ -32,6 +36,7 @@ export const WarehouseListPage: React.FC<WarehouseListPageProps> = ({
warehouses,
currentTab,
disabled,
limits,
initialSearch,
pageInfo,
settings,
@ -52,19 +57,44 @@ export const WarehouseListPage: React.FC<WarehouseListPageProps> = ({
}) => {
const intl = useIntl();
const limitReached = isLimitReached(limits, "warehouses");
return (
<Container>
<AppHeader onBack={onBack}>
<FormattedMessage {...sectionNames.configuration} />
</AppHeader>
<PageHeader title={intl.formatMessage(sectionNames.warehouses)}>
<Button color="primary" variant="contained" onClick={onAdd}>
<PageHeader
title={intl.formatMessage(sectionNames.warehouses)}
limit={
hasLimits(limits, "warehouses") && {
data: limits,
key: "warehouses",
text: "warehouses used"
}
}
>
<Button
color="primary"
disabled={limitReached}
variant="contained"
onClick={onAdd}
>
<FormattedMessage
defaultMessage="Create Warehouse"
description="button"
/>
</Button>
</PageHeader>
<Alert
show={limitReached}
title={intl.formatMessage({
defaultMessage: "Warehouse limit reached",
description: "alert"
})}
>
<FormattedMessage defaultMessage="You have reached your warehouse limit, you will be no longer able to add warehouses to your store. If you would like to up your limit, contact your administration staff about raising your limits." />
</Alert>
<Card>
<SearchBar
allTabLabel={intl.formatMessage({

View file

@ -2,6 +2,7 @@ import DeleteFilterTabDialog from "@saleor/components/DeleteFilterTabDialog";
import SaveFilterTabDialog, {
SaveFilterTabDialogFormData
} from "@saleor/components/SaveFilterTabDialog";
import { useShopLimitsQuery } from "@saleor/components/Shop/query";
import { WindowTitle } from "@saleor/components/WindowTitle";
import { configurationMenuUrl } from "@saleor/configuration";
import useListSettings from "@saleor/hooks/useListSettings";
@ -67,6 +68,11 @@ const WarehouseList: React.FC<WarehouseListProps> = ({ params }) => {
displayLoader: true,
variables: queryVariables
});
const limitOpts = useShopLimitsQuery({
variables: {
warehouses: true
}
});
const [deleteWarehouse, deleteWarehouseOpts] = useWarehouseDelete({
onCompleted: data => {
if (data.deleteWarehouse.errors.length === 0) {
@ -75,6 +81,7 @@ const WarehouseList: React.FC<WarehouseListProps> = ({ params }) => {
text: intl.formatMessage(commonMessages.savedChanges)
});
refetch();
limitOpts.refetch();
closeModal();
}
}
@ -141,6 +148,7 @@ const WarehouseList: React.FC<WarehouseListProps> = ({ params }) => {
onTabChange={handleTabChange}
onTabDelete={() => openModal("delete-search")}
onTabSave={() => openModal("save-search")}
limits={limitOpts.data?.shop.limits}
tabs={tabs.map(tab => tab.name)}
warehouses={maybe(() => data.warehouses.edges.map(edge => edge.node))}
settings={settings}