🏷️ Rework product list view (#3296)

This commit is contained in:
Michał Droń 2023-03-20 13:06:33 +01:00 committed by GitHub
parent c0b69e7c31
commit f8d86510b2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
129 changed files with 3225 additions and 1183 deletions

View file

@ -18,6 +18,7 @@ import {
PERMISSION_GROUP_LIST_SELECTORS,
PLUGINS_LIST_SELECTORS,
PRODUCT_TYPES_LIST_SELECTORS,
PRODUCTS_LIST,
SALES_SELECTORS,
SHIPPING_ZONES_LIST_SELECTORS,
STAFF_MEMBERS_LIST_SELECTORS,
@ -25,6 +26,7 @@ import {
} from "../elements/";
import { PERMISSIONS_OPTIONS } from "../fixtures/permissionsUsers";
import { urlList } from "../fixtures/urlList";
import { ensureCanvasStatic } from "../support/customCommands/sharedElementsOperations/canvas";
import {
expectConfigurationAvailableSectionsNumber,
expectConfigurationSectionsToBeVisible,
@ -430,6 +432,9 @@ describe("As a staff user I want to navigate through shop using different permis
cy.get(MENU_SELECTORS.CATALOG.collections).should("be.visible");
expectMainMenuAvailableSections(5);
});
ensureCanvasStatic(PRODUCTS_LIST.dataGridTable).then(() => {
cy.assertCanvasRowsNumber(PRODUCTS_LIST.dataGridTable, 21);
});
},
);

View file

@ -2,10 +2,9 @@
/// <reference types="../../support"/>
import { PRODUCT_DETAILS } from "../../elements/catalog/products/product-details";
import { PRODUCTS_LIST } from "../../elements/catalog/products/products-list";
import { SHARED_ELEMENTS } from "../../elements/shared/sharedElements";
import { demoProductsNames } from "../../fixtures/products";
import { productDetailsUrl, urlList } from "../../fixtures/urlList";
import { productDetailsUrl } from "../../fixtures/urlList";
import { getFirstProducts } from "../../support/api/requests/Product";
import { deleteCollectionsStartsWith } from "../../support/api/utils/catalog/collectionsUtils";
import {
@ -19,39 +18,6 @@ describe("Tests for images", () => {
cy.clearSessionData().loginUserViaRequest();
});
it(
"Images on product list should be displayed",
{ tags: ["@products", "@allEnv", "@stable"] },
() => {
cy.addAliasToGraphRequest("ProductList")
.visit(urlList.products)
.wait("@ProductList")
.its("response.body.data.products.edges")
.then(products => {
cy.get(SHARED_ELEMENTS.skeleton).should("not.exist");
cy.get(PRODUCTS_LIST.productImage)
.each($image => {
cy.wrap($image)
.invoke("attr", "src")
.then(imageUrl => {
cy.request(imageUrl);
})
.then(respImage => {
expect(respImage.status).to.eq(200);
});
})
.then(images => {
const expectedProductsSvgAvatars =
products.length - images.length;
cy.get(PRODUCTS_LIST.imageIcon).should(
"have.length",
expectedProductsSvgAvatars,
);
});
});
},
);
it(
"Should display product image",
{ tags: ["@products", "@allEnv", "@stable"] },

View file

@ -1,3 +1,4 @@
/* eslint-disable import/no-duplicates */
import * as MENU_SELECTORS from "./left-menu/left-menu-selectors";
import * as LOGIN_SELECTORS from "./login-selectors";
import * as SET_PASSWORD_SELECTORS from "./login-selectors";

View file

@ -3,9 +3,12 @@ import {
CATEGORY_DETAILS_SELECTORS,
} from "./categories";
import { COLLECTION_SELECTORS } from "./collection-selectors";
import { PRODUCT_DETAILS, PRODUCTS_LIST } from "./products";
export {
CATEGORIES_LIST_SELECTORS,
CATEGORY_DETAILS_SELECTORS,
COLLECTION_SELECTORS,
PRODUCT_DETAILS,
PRODUCTS_LIST,
};

View file

@ -0,0 +1,4 @@
import { PRODUCT_DETAILS } from "./product-details";
import { PRODUCTS_LIST } from "./products-list";
export { PRODUCT_DETAILS, PRODUCTS_LIST };

View file

@ -1,5 +1,6 @@
export const PRODUCTS_LIST = {
productsList: "[data-test-id^='id-']",
dataGridTable: "[data-testid='data-grid-canvas']",
productsNames: "[data-test-id='name']",
dialogProductTypeInput: "[data-test-id='dialog-product-type']",
createProductBtn: "[data-test-id='add-product']",

View file

@ -9,6 +9,8 @@ import {
CATEGORIES_LIST_SELECTORS,
CATEGORY_DETAILS_SELECTORS,
COLLECTION_SELECTORS,
PRODUCT_DETAILS,
PRODUCTS_LIST,
} from "./catalog";
import { CHANNELS_SELECTORS } from "./channels";
import { CONFIGURATION_SELECTORS } from "./configuration/configuration-selectors";
@ -67,8 +69,10 @@ export {
PERMISSION_GROUP_LIST_SELECTORS,
PLUGINS_DETAILS_SELECTORS,
PLUGINS_LIST_SELECTORS,
PRODUCT_DETAILS,
PRODUCT_TYPE_DETAILS_SELECTORS,
PRODUCT_TYPES_LIST_SELECTORS,
PRODUCTS_LIST,
SALES_SELECTORS,
SET_PASSWORD_SELECTORS,
SHARED_ELEMENTS,

View file

@ -1,4 +1,4 @@
import { SHARED_ELEMENTS } from "./sharedElements";
import { ADDRESS_SELECTORS } from "./addressForm";
import { SHARED_ELEMENTS } from "./sharedElements";
export { SHARED_ELEMENTS, ADDRESS_SELECTORS };

View file

@ -41,3 +41,6 @@ Cypress.Commands.add("checkIfDataAreNotNull", data => {
Cypress.Commands.add("checkIfElementIsVisible", element => {
cy.get(element).should("be.visible");
});
Cypress.Commands.add("assertCanvasRowsNumber", (canvas, rowNumber) => {
cy.get(canvas).find("tr").should("have.length", rowNumber);
});

View file

@ -0,0 +1,62 @@
import { recurse } from "cypress-recurse";
import pixelmatch from "pixelmatch";
export function ensureCanvasStatic(selector = "canvas") {
cy.log(`ensure the image in **${selector}** is static`);
const noLog = { log: false };
const delay = 300; // ms, when grabbing new image
// take the current image
return cy
.get(selector, noLog)
.then($canvas => {
const ctx1 = $canvas[0].getContext("2d");
const width = $canvas[0].width;
const height = $canvas[0].height;
let img1 = ctx1.getImageData(0, 0, width, height);
// initial delay to make sure we catch updates
cy.wait(delay, noLog);
return recurse(
() =>
cy.get(selector, noLog).then($canvas => {
const ctx2 = $canvas[0].getContext("2d");
const img2 = ctx2.getImageData(0, 0, width, height);
const diff = ctx2.createImageData(width, height);
// number of different pixels
const number = pixelmatch(
img1.data,
img2.data,
diff.data,
width,
height,
{
threshold: 0.1,
},
);
// for next comparison, use the new image
// as the base - this way we can get to the end
// of any animation
img1 = img2;
return number;
}),
// predicate function
numberOfDifferentPixels => numberOfDifferentPixels < 10,
// recurse options
{
// by default uses the default command timeout
log: numberOfDifferentPixels =>
cy.log(`**${numberOfDifferentPixels}** diff pixels`),
delay,
},
);
})
.then(() => {
cy.log(`picture in **${selector}** is static`);
});
}

View file

@ -79,6 +79,9 @@
"context": "input label",
"string": "Stock reservation for anonymous user (in minutes)"
},
"+U6ozc": {
"string": "Type"
},
"+VEhV8": {
"context": "product channels",
"string": "Availability"
@ -1468,6 +1471,10 @@
"context": "header",
"string": "Translation Product Variant \"{productName}\" - {languageCode}"
},
"9B2mOB": {
"context": "tile view pagination label",
"string": "No. of products"
},
"9C7PZE": {
"context": "navigation section name",
"string": "Navigation"
@ -1705,6 +1712,9 @@
"context": "section description",
"string": "You can set basic checkout rules that will be applied globally to all your channels"
},
"AyQkmp": {
"string": "Sorting by this column is not available"
},
"AzMSmb": {
"context": "caption",
"string": "If enabled this attribute can be used as a column in product table."
@ -3824,6 +3834,9 @@
"context": "activate app",
"string": "Are you sure you want to activate this app? Activating will start gathering events."
},
"Q4m1CG": {
"string": "Edit Product"
},
"Q55cTG": {
"context": "order refund amount",
"string": "Previously refunded"
@ -4182,10 +4195,6 @@
"context": "export filtered items to csv file",
"string": "Current search ({number})"
},
"Sd0Ppm": {
"context": "product publication date",
"string": "Published on {date}"
},
"Sjd7wm": {
"context": "product filter label",
"string": "Product"
@ -5290,10 +5299,6 @@
"context": "gift card history message",
"string": "Gift card balance was reset by {resetBy}"
},
"aFLtLk": {
"context": "tab name",
"string": "All Products"
},
"aHc89n": {
"context": "select warehouse to restock items",
"string": "Select Warehouse"
@ -7383,10 +7388,6 @@
"context": "dialog content",
"string": "Select one of customer addresses or add a new address:"
},
"qpQ0uB": {
"context": "product publication date",
"string": "Unpublished"
},
"qrWOxx": {
"string": "No members found"
},
@ -7412,10 +7413,6 @@
"r+dgiv": {
"string": "Taxes"
},
"r0hgpM": {
"context": "product publication date",
"string": "Becomes published on {date}"
},
"r1aQ2f": {
"context": "dialog header",
"string": "Unassign Attribute from Product Type"
@ -7773,6 +7770,9 @@
"context": "order total amount",
"string": "Total"
},
"tiY7bx": {
"string": "Add new product"
},
"tlGXkh": {
"context": "input description",
"string": "Unlimited"

78
package-lock.json generated
View file

@ -26,7 +26,7 @@
"@material-ui/lab": "^4.0.0-alpha.61",
"@material-ui/styles": "^4.11.4",
"@reach/auto-id": "^0.16.0",
"@saleor/macaw-ui": "^0.8.0-pre.49",
"@saleor/macaw-ui": "^0.8.0-pre.50",
"@saleor/sdk": "^0.4.4",
"@sentry/react": "^6.0.0",
"@types/faker": "^5.1.6",
@ -60,6 +60,7 @@
"lz-string": "^1.4.4",
"marked": "^4.0.17",
"moment-timezone": "^0.5.32",
"pixelmatch": "^5.3.0",
"qs": "^6.9.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
@ -145,6 +146,7 @@
"codecov": "^3.7.1",
"core-js": "^3.7.0",
"cross-env": "^6.0.3",
"cypress-recurse": "^1.27.0",
"dotenv": "^10.0.0",
"env-var": "^7.3.0",
"esbuild-loader": "^2.18.0",
@ -7271,9 +7273,9 @@
}
},
"node_modules/@saleor/macaw-ui": {
"version": "0.8.0-pre.49",
"resolved": "https://registry.npmjs.org/@saleor/macaw-ui/-/macaw-ui-0.8.0-pre.49.tgz",
"integrity": "sha512-SHUfBE1AaDsam/K3ZcotDtUH6/jcyRR9Gi/Zpvw/cTcwoosq09WcewMWZNdHgY5Fkbgve7HTKb+wCyah+sBO8w==",
"version": "0.8.0-pre.50",
"resolved": "https://registry.npmjs.org/@saleor/macaw-ui/-/macaw-ui-0.8.0-pre.50.tgz",
"integrity": "sha512-PGDkk1KmabYOQTPPzu+cmBxEyjdiYQdmzAiqtMIKwo8zff8nYPVE7yVKF9rJ7o3twqAiqF7fuAObo8qVjgZUYg==",
"dependencies": {
"@floating-ui/react-dom-interactions": "^0.5.0",
"@radix-ui/react-radio-group": "^1.1.1",
@ -14883,6 +14885,15 @@
"mocha": ">=3.1.2"
}
},
"node_modules/cypress-recurse": {
"version": "1.27.0",
"resolved": "https://registry.npmjs.org/cypress-recurse/-/cypress-recurse-1.27.0.tgz",
"integrity": "sha512-BCD83UqaxlD+JiqZn1PvIhHRXasgfCt57vLC1Fcyifvxh4QklELRcYUJV3MdhKamMkmajaErLfnCNbZ8VJ5SIg==",
"dev": true,
"dependencies": {
"humanize-duration": "^3.27.3"
}
},
"node_modules/cypress-repeat": {
"version": "2.3.3",
"license": "MIT",
@ -19911,6 +19922,12 @@
"node": ">=8.12.0"
}
},
"node_modules/humanize-duration": {
"version": "3.28.0",
"resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.28.0.tgz",
"integrity": "sha512-jMAxraOOmHuPbffLVDKkEKi/NeG8dMqP8lGRd6Tbf7JgAeG33jjgPWDbXXU7ypCI0o+oNKJFgbSB9FKVdWNI2A==",
"dev": true
},
"node_modules/husky": {
"version": "8.0.2",
"license": "MIT",
@ -27389,6 +27406,17 @@
"node": ">= 6"
}
},
"node_modules/pixelmatch": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-5.3.0.tgz",
"integrity": "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==",
"dependencies": {
"pngjs": "^6.0.0"
},
"bin": {
"pixelmatch": "bin/pixelmatch"
}
},
"node_modules/pkg-dir": {
"version": "3.0.0",
"devOptional": true,
@ -27472,6 +27500,14 @@
"semver-compare": "^1.0.0"
}
},
"node_modules/pngjs": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz",
"integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==",
"engines": {
"node": ">=12.13.0"
}
},
"node_modules/pnp-webpack-plugin": {
"version": "1.5.0",
"license": "MIT",
@ -41575,9 +41611,9 @@
}
},
"@saleor/macaw-ui": {
"version": "0.8.0-pre.49",
"resolved": "https://registry.npmjs.org/@saleor/macaw-ui/-/macaw-ui-0.8.0-pre.49.tgz",
"integrity": "sha512-SHUfBE1AaDsam/K3ZcotDtUH6/jcyRR9Gi/Zpvw/cTcwoosq09WcewMWZNdHgY5Fkbgve7HTKb+wCyah+sBO8w==",
"version": "0.8.0-pre.50",
"resolved": "https://registry.npmjs.org/@saleor/macaw-ui/-/macaw-ui-0.8.0-pre.50.tgz",
"integrity": "sha512-PGDkk1KmabYOQTPPzu+cmBxEyjdiYQdmzAiqtMIKwo8zff8nYPVE7yVKF9rJ7o3twqAiqF7fuAObo8qVjgZUYg==",
"requires": {
"@floating-ui/react-dom-interactions": "^0.5.0",
"@radix-ui/react-radio-group": "^1.1.1",
@ -47190,6 +47226,15 @@
"lodash": "^4.17.15"
}
},
"cypress-recurse": {
"version": "1.27.0",
"resolved": "https://registry.npmjs.org/cypress-recurse/-/cypress-recurse-1.27.0.tgz",
"integrity": "sha512-BCD83UqaxlD+JiqZn1PvIhHRXasgfCt57vLC1Fcyifvxh4QklELRcYUJV3MdhKamMkmajaErLfnCNbZ8VJ5SIg==",
"dev": true,
"requires": {
"humanize-duration": "^3.27.3"
}
},
"cypress-repeat": {
"version": "2.3.3",
"optional": true,
@ -50376,6 +50421,12 @@
"version": "1.1.1",
"devOptional": true
},
"humanize-duration": {
"version": "3.28.0",
"resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.28.0.tgz",
"integrity": "sha512-jMAxraOOmHuPbffLVDKkEKi/NeG8dMqP8lGRd6Tbf7JgAeG33jjgPWDbXXU7ypCI0o+oNKJFgbSB9FKVdWNI2A==",
"dev": true
},
"husky": {
"version": "8.0.2",
"optional": true
@ -55373,6 +55424,14 @@
"version": "4.0.5",
"devOptional": true
},
"pixelmatch": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-5.3.0.tgz",
"integrity": "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==",
"requires": {
"pngjs": "^6.0.0"
}
},
"pkg-dir": {
"version": "3.0.0",
"devOptional": true,
@ -55429,6 +55488,11 @@
"semver-compare": "^1.0.0"
}
},
"pngjs": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz",
"integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="
},
"pnp-webpack-plugin": {
"version": "1.5.0",
"optional": true,

View file

@ -33,7 +33,7 @@
"@material-ui/lab": "^4.0.0-alpha.61",
"@material-ui/styles": "^4.11.4",
"@reach/auto-id": "^0.16.0",
"@saleor/macaw-ui": "^0.8.0-pre.49",
"@saleor/macaw-ui": "^0.8.0-pre.50",
"@saleor/sdk": "^0.4.4",
"@sentry/react": "^6.0.0",
"@types/faker": "^5.1.6",
@ -67,6 +67,7 @@
"lz-string": "^1.4.4",
"marked": "^4.0.17",
"moment-timezone": "^0.5.32",
"pixelmatch": "^5.3.0",
"qs": "^6.9.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
@ -144,6 +145,7 @@
"@types/webappsec-credential-management": "^0.5.1",
"@typescript-eslint/eslint-plugin": "^5.41.0",
"@typescript-eslint/parser": "^5.41.0",
"@vitejs/plugin-react-swc": "^3.2.0",
"babel-core": "^7.0.0-bridge.0",
"babel-jest": "^27.5.1",
"babel-loader": "^8.0.6",
@ -151,6 +153,7 @@
"codecov": "^3.7.1",
"core-js": "^3.7.0",
"cross-env": "^6.0.3",
"cypress-recurse": "^1.27.0",
"dotenv": "^10.0.0",
"env-var": "^7.3.0",
"esbuild-loader": "^2.18.0",
@ -172,7 +175,6 @@
"vite-plugin-html": "^3.2.0",
"vite-plugin-pwa": "^0.13.3",
"vite-plugin-sentry": "^1.1.6",
"@vitejs/plugin-react-swc": "^3.2.0",
"workbox-cacheable-response": "^6.1.2",
"workbox-expiration": "^6.1.2",
"workbox-precaching": "^6.1.2",

View file

@ -0,0 +1,126 @@
import {
FilterContent,
FilterElement,
FilterErrorMessages,
IFilter,
InvalidFilters,
} from "@dashboard/components/Filter";
import useFilter from "@dashboard/components/Filter/useFilter";
import { extractInvalidFilters } from "@dashboard/components/Filter/utils";
import { ClickAwayListener, Grow, Popper } from "@material-ui/core";
import { sprinkles } from "@saleor/macaw-ui/next";
import React, { useMemo, useState } from "react";
import { FilterButton } from "./FilterButton";
import { getSelectedFilterAmount } from "./utils";
export interface FilterProps<TFilterKeys extends string = string> {
currencySymbol?: string;
errorMessages?: FilterErrorMessages<TFilterKeys>;
menu: IFilter<TFilterKeys>;
onFilterAdd: (filter: Array<FilterElement<string>>) => void;
onFilterAttributeFocus?: (id?: string) => void;
}
export const Filter = ({
currencySymbol,
menu,
onFilterAdd,
onFilterAttributeFocus,
errorMessages,
}: FilterProps) => {
const anchor = React.useRef<HTMLDivElement>();
const [isFilterMenuOpened, setFilterMenuOpened] = useState(false);
const [filterErrors, setFilterErrors] = useState<InvalidFilters<string>>({});
const [data, dispatch, reset] = useFilter(menu);
const isFilterActive = menu.some(filterElement => filterElement.active);
const selectedFilterAmount = useMemo(
() => getSelectedFilterAmount(menu, data),
[data, menu],
);
const handleSubmit = () => {
const invalidFilters = extractInvalidFilters(data, menu);
if (Object.keys(invalidFilters).length > 0) {
setFilterErrors(invalidFilters);
return;
}
setFilterErrors({});
onFilterAdd(data);
setFilterMenuOpened(false);
};
const handleClear = () => {
reset();
setFilterErrors({});
};
return (
<ClickAwayListener
onClickAway={event => {
if ((event.target as HTMLElement).getAttribute("role") !== "option") {
setFilterMenuOpened(false);
}
}}
mouseEvent="onMouseUp"
>
<div ref={anchor}>
<FilterButton
isFilterActive={isFilterActive}
onClick={() => setFilterMenuOpened(!isFilterMenuOpened)}
selectedFilterAmount={selectedFilterAmount}
/>
<Popper
className={sprinkles({
backgroundColor: "surfaceNeutralPlain",
overflowY: "scroll",
boxShadow: "modal",
zIndex: "3",
})}
style={{
width: "376px",
height: "450px",
}}
open={isFilterMenuOpened}
anchorEl={anchor.current}
transition
disablePortal
placement="bottom-start"
modifiers={{
flip: {
enabled: false,
},
hide: {
enabled: false,
},
preventOverflow: {
boundariesElement: "scrollParent",
enabled: false,
},
}}
>
{() => (
<Grow>
<FilterContent
errorMessages={errorMessages}
errors={filterErrors}
dataStructure={menu}
currencySymbol={currencySymbol}
filters={data}
onClear={handleClear}
onFilterPropertyChange={dispatch}
onFilterAttributeFocus={onFilterAttributeFocus}
onSubmit={handleSubmit}
/>
</Grow>
)}
</Popper>
</div>
</ClickAwayListener>
);
};
Filter.displayName = "Filter";

View file

@ -0,0 +1,62 @@
import { FilterErrorMessages, IFilter } from "@dashboard/components/Filter";
import { FilterProps, SearchPageProps } from "@dashboard/types";
import { Box } from "@saleor/macaw-ui/next";
import React, { ReactNode } from "react";
import { Filter } from "./Filter";
import SearchInput from "./SearchInput";
export interface FilterBarProps<TKeys extends string = string>
extends FilterProps<TKeys>,
SearchPageProps {
searchPlaceholder: string;
errorMessages?: FilterErrorMessages<TKeys>;
filterStructure: IFilter<TKeys>;
actions?: ReactNode;
}
export const FilterBar: React.FC<FilterBarProps> = ({
currencySymbol,
filterStructure,
initialSearch,
searchPlaceholder,
onSearchChange,
onFilterChange,
onFilterAttributeFocus,
errorMessages,
actions,
}: FilterBarProps) => (
<>
<Box
display="grid"
__gridTemplateColumns="1fr 1fr"
gap={7}
paddingBottom={5}
paddingX={9}
borderColor="neutralPlain"
borderBottomStyle="solid"
borderBottomWidth={1}
>
<Box display="flex" alignItems="center" gap={7}>
<Filter
errorMessages={errorMessages}
menu={filterStructure}
currencySymbol={currencySymbol}
onFilterAdd={onFilterChange}
onFilterAttributeFocus={onFilterAttributeFocus}
/>
<Box __width="320px">
<SearchInput
initialSearch={initialSearch}
placeholder={searchPlaceholder}
onSearchChange={onSearchChange}
/>
</Box>
</Box>
<Box display="flex" justifyContent="flex-end">
{actions}
</Box>
</Box>
</>
);
FilterBar.displayName = "FilterBar";

View file

@ -0,0 +1,35 @@
import { Box, DropdownButton } from "@saleor/macaw-ui/next";
import React, { MouseEventHandler } from "react";
import { FormattedMessage } from "react-intl";
interface FilterButtonProps {
isFilterActive: boolean;
onClick: MouseEventHandler<HTMLButtonElement>;
selectedFilterAmount: number;
}
export const FilterButton = ({
isFilterActive,
onClick,
selectedFilterAmount,
}: FilterButtonProps) => (
<DropdownButton data-test-id="show-filters-button" onClick={onClick}>
<FormattedMessage
id="FNpv6K"
defaultMessage="Filters"
description="button"
/>
{isFilterActive && (
<>
<Box
as="span"
backgroundColor="interactiveNeutralDefault"
height={6}
width={1}
marginX={3}
/>
{selectedFilterAmount}
</>
)}
</DropdownButton>
);

View file

@ -0,0 +1,41 @@
import Debounce from "@dashboard/components/Debounce";
import { SearchPageProps } from "@dashboard/types";
import { Box, SearchInput as MacawSearchInput } from "@saleor/macaw-ui/next";
import React from "react";
export interface SearchInputProps extends SearchPageProps {
placeholder: string;
}
const SearchInput: React.FC<SearchInputProps> = props => {
const { initialSearch, onSearchChange, placeholder } = props;
const [search, setSearch] = React.useState(initialSearch);
React.useEffect(() => setSearch(initialSearch), [initialSearch]);
return (
<Debounce debounceFn={onSearchChange} time={500}>
{debounceSearchChange => {
const handleSearchChange = (event: React.ChangeEvent<any>) => {
const value = event.target.value;
setSearch(value);
debounceSearchChange(value);
};
return (
<Box display="flex" alignItems="center" width="100%">
<MacawSearchInput
size="small"
value={search}
onChange={handleSearchChange}
placeholder={placeholder}
/>
</Box>
);
}}
</Debounce>
);
};
SearchInput.displayName = "SearchInput";
export default SearchInput;

View file

@ -0,0 +1 @@
export * from "./FilterBar";

View file

@ -0,0 +1,42 @@
import { FilterElement, IFilter } from "@dashboard/components/Filter";
import { getSelectedFilterAmount } from "./utils";
describe("getSelectedFilterAmount", () => {
it("should return 0 when no selected filters", () => {
// Arrange
const menu = [{ name: "test1" }, { name: "test2" }] as IFilter<string>;
const data = [
{ name: "test1", active: false },
{ name: "test2", active: false },
] as Array<FilterElement<string>>;
// Act
const result = getSelectedFilterAmount(menu, data);
// Assert
expect(result).toBe(0);
});
it("should return amount of selected filters", () => {
// Arrange
const menu = [
{ name: "test1" },
{ name: "test2" },
{ name: "test3" },
] as IFilter<string>;
const data = [
{ name: "test1", active: true },
{ name: "test2", active: false },
{ name: "test3", active: true },
] as Array<FilterElement<string>>;
// Act
const result = getSelectedFilterAmount(menu, data);
// Assert
expect(result).toBe(2);
});
});

View file

@ -0,0 +1,17 @@
import { FilterElement, IFilter } from "@dashboard/components/Filter";
export const getSelectedFilterAmount = <TFilterKeys extends string = string>(
menu: IFilter<TFilterKeys>,
data: Array<FilterElement<TFilterKeys>>,
) =>
menu.reduce((acc, filterElement) => {
const dataFilterElement = data.find(
({ name }) => name === filterElement.name,
);
if (!dataFilterElement) {
return acc;
}
return acc + (dataFilterElement.active ? 1 : 0);
}, 0);

View file

@ -5,6 +5,9 @@ interface LimitsInfoProps {
text: string;
}
/**
* @deprecated use `Text` instead
*/
export const LimitsInfo: React.FC<LimitsInfoProps> = ({ text }) => (
<Box position="absolute" left={10} bottom={3}>
{text}

View file

@ -9,20 +9,22 @@ import { TopNavWrapper } from "./TopNavWrapper";
interface TopNavProps {
title: string | React.ReactNode;
href?: string;
withoutBorder?: boolean;
}
export const TopNav: React.FC<PropsWithChildren<TopNavProps>> = ({
title,
href,
withoutBorder = false,
children,
}) => {
const { availableChannels, channel, isPickerActive, setChannel } =
useAppChannel(false);
return (
<TopNavWrapper>
<TopNavWrapper withoutBorder={withoutBorder}>
{href && <TopNavLink to={href} />}
<Box __flex={1} marginLeft={5}>
<Box __flex={1}>
<Text variant="title">{title}</Text>
</Box>
<Box display="flex" flexWrap="nowrap">

View file

@ -1,4 +1,4 @@
import { ArrowLeftIcon, Button } from "@saleor/macaw-ui/next";
import { ArrowLeftIcon, Button, sprinkles } from "@saleor/macaw-ui/next";
import React from "react";
import { Link } from "react-router-dom";
@ -6,7 +6,7 @@ export const TopNavLink: React.FC<{
to: string;
variant?: "secondary" | "tertiary";
}> = ({ to, variant = "secondary" }) => (
<Link to={to}>
<Link to={to} className={sprinkles({ marginRight: 5 })}>
<Button
icon={<ArrowLeftIcon />}
variant={variant}

View file

@ -3,13 +3,16 @@ import React from "react";
import { topBarHeight } from "../consts";
export const TopNavWrapper: React.FC = ({ children }) => (
export const TopNavWrapper: React.FC<{ withoutBorder?: boolean }> = ({
children,
withoutBorder,
}) => (
<Box
display="flex"
alignItems="center"
paddingX={9}
paddingY={8}
borderBottomWidth={1}
borderBottomWidth={withoutBorder ? 0 : 1}
borderBottomStyle="solid"
borderColor="neutralPlain"
position="relative"

View file

@ -0,0 +1,55 @@
import {
Box,
Button,
ChervonDownIcon,
Dropdown,
List,
Text,
} from "@saleor/macaw-ui/next";
import React from "react";
interface ButtonWithDropdownProps {
onClick: () => void;
options: Array<{ label: string; testId: string; onSelect: () => void }>;
testId?: string;
}
export const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
onClick,
options,
children,
testId,
}) => (
<Dropdown>
<Dropdown.Trigger>
<Button data-test-id={testId} onClick={onClick}>
{children}
<ChervonDownIcon />
</Button>
</Dropdown.Trigger>
<Dropdown.Content align="end">
<Box>
<List
padding={5}
borderRadius={4}
boxShadow="overlay"
backgroundColor="surfaceNeutralPlain"
>
{options.map(item => (
<Dropdown.Item>
<List.Item
borderRadius={4}
paddingX={4}
paddingY={5}
onClick={item.onSelect}
data-test-id={item.testId}
>
<Text>{item.label}</Text>
</List.Item>
</Dropdown.Item>
))}
</List>
</Box>
</Dropdown.Content>
</Dropdown>
);

View file

@ -0,0 +1 @@
export * from "./ButtonWithDropdown";

View file

@ -28,6 +28,9 @@ export interface ButtonWithSelectProps
href?: string;
}
/**
* @deprecated use [`ButtonWithDropdown`](https://github.com/saleor/saleor-dashboard/blob/main/src/components/ButtonWithDropdown/ButtonWithDropdown.tsx) instead
*/
export const ButtonWithSelect: React.FC<ButtonWithSelectProps> = ({
options,
children,

View file

@ -66,6 +66,9 @@ const useStyles = makeStyles(
{ name: "CardMenu" },
);
/**
* @deprecated use [`TopNavMenu`](https://github.com/saleor/saleor-dashboard/blob/main/src/components/TopNavMenu/TopNavMenu.tsx) instead
*/
const CardMenu: React.FC<CardMenuProps> = props => {
const {
className,

View file

@ -4,11 +4,11 @@ import { FetchMoreProps } from "@dashboard/types";
import { ClickAwayListener, Grow, Popper } from "@material-ui/core";
import {
Choice,
ColumnsIcon,
IconButton,
IconButtonProps,
makeStyles,
} from "@saleor/macaw-ui";
import { TableEditIcon } from "@saleor/macaw-ui/next";
import { score } from "fuzzaldrin";
import sortBy from "lodash/sortBy";
import React from "react";
@ -64,9 +64,8 @@ const ColumnPicker: React.FC<ColumnPickerProps> = props => {
// Component is uncontrolled but we need to reset it somehow, so we change
// initial prop after reset callback to force value refreshing
const [initialColumnsChoices, setInitialColumnsChoices] = useStateFromProps(
initialColumns,
);
const [initialColumnsChoices, setInitialColumnsChoices] =
useStateFromProps(initialColumns);
const onChange: FormChange<string[]> = event => {
selectedColumns.current = event.target.value;
@ -112,7 +111,7 @@ const ColumnPicker: React.FC<ColumnPickerProps> = props => {
state={isExpanded ? "active" : "default"}
onClick={() => setExpansionState(prevState => !prevState)}
>
<ColumnsIcon />
<TableEditIcon />
</IconButton>
<Popper
className={classes.popper}

View file

@ -2,42 +2,59 @@ import "@glideapps/glide-data-grid/dist/index.css";
import { usePreventHistoryBack } from "@dashboard/hooks/usePreventHistoryBack";
import DataEditor, {
DataEditorProps,
DataEditorRef,
EditableGridCell,
GridCell,
GridColumn,
GridMouseEventArgs,
GridSelection,
HeaderClickedEventArgs,
Item,
} from "@glideapps/glide-data-grid";
import { GetRowThemeCallback } from "@glideapps/glide-data-grid/dist/ts/data-grid/data-grid-render";
import { Card, CardContent, Typography } from "@material-ui/core";
import { useTheme } from "@saleor/macaw-ui";
import { Box, useTheme } from "@saleor/macaw-ui/next";
import clsx from "clsx";
import range from "lodash/range";
import throttle from "lodash/throttle";
import React from "react";
import React, {
MutableRefObject,
ReactElement,
ReactNode,
useCallback,
useMemo,
useRef,
useState,
} from "react";
import { FormattedMessage } from "react-intl";
import { contentMaxWidth } from "../AppLayout/consts";
import { CardMenuItem } from "../CardMenu";
import ColumnPicker from "../ColumnPicker";
import { FullScreenContainer } from "./FullScreenContainer";
import { Header } from "./Header";
import { RowActions } from "./RowActions";
import { ColumnPickerProps } from "../ColumnPicker";
import { FullScreenContainer } from "./components/FullScreenContainer";
import { Header } from "./components/Header";
import { RowActions } from "./components/RowActions";
import { TooltipContainer } from "./components/TooltipContainer";
import useCells from "./customCells/useCells";
import { headerIcons } from "./headerIcons";
import useDatagridChange, {
DatagridChange,
OnDatagridChange,
} from "./hooks/useDatagridChange";
import { useFullScreenMode } from "./hooks/useFullScreenMode";
import { usePortalClasses } from "./hooks/usePortalClasses";
import { useScrollRight } from "./hooks/useScrollRight";
import { useTooltipContainer } from "./hooks/useTooltipContainer";
import useStyles, {
cellHeight,
useDatagridTheme,
useFullScreenStyles,
} from "./styles";
import { AvailableColumn } from "./types";
import useCells from "./useCells";
import useColumns from "./useColumns";
import useDatagridChange, {
DatagridChange,
OnDatagridChange,
} from "./useDatagridChange";
import { useFullScreenMode } from "./useFullScreenMode";
import { usePortalClasses } from "./usePortalClasses";
import { getDefultColumnPickerProps } from "./utils";
export interface GetCellContentOpts {
changes: React.MutableRefObject<DatagridChange[]>;
changes: MutableRefObject<DatagridChange[]>;
added: number[];
removed: number[];
getChangeIndex: (column: string, row: number) => number;
@ -48,20 +65,33 @@ export interface MenuItemsActions {
}
export interface DatagridProps {
addButtonLabel: string;
addButtonLabel?: string;
availableColumns: readonly AvailableColumn[];
emptyText: string;
getCellError: (item: Item, opts: GetCellContentOpts) => boolean;
getCellContent: (item: Item, opts: GetCellContentOpts) => GridCell;
getColumnTooltipContent?: (colIndex: number) => string;
menuItems: (index: number) => CardMenuItem[];
rows: number;
title: string;
title?: string;
fullScreenTitle?: string;
selectionActions: (
selection: number[],
actions: MenuItemsActions,
) => React.ReactNode;
) => ReactNode;
onChange?: OnDatagridChange;
onHeaderClicked?: (colIndex: number, event: HeaderClickedEventArgs) => void;
renderColumnPicker?: (
defaultProps: Partial<ColumnPickerProps>,
) => ReactElement;
onRowClick?: (item: Item) => void;
onColumnMoved?: (startIndex: number, endIndex: number) => void;
onColumnResize?: (column: GridColumn, newSize: number) => void;
readonly?: boolean;
rowMarkers?: DataEditorProps["rowMarkers"];
freezeColumns?: DataEditorProps["freezeColumns"];
verticalBorder?: DataEditorProps["verticalBorder"];
columnSelect?: DataEditorProps["columnSelect"];
}
export const Datagrid: React.FC<DatagridProps> = ({
@ -75,28 +105,41 @@ export const Datagrid: React.FC<DatagridProps> = ({
selectionActions,
title,
fullScreenTitle,
onHeaderClicked,
onChange,
}): React.ReactElement => {
renderColumnPicker,
onRowClick,
getColumnTooltipContent,
readonly = false,
rowMarkers = "checkbox",
freezeColumns = 1,
verticalBorder,
columnSelect = "none",
onColumnMoved,
onColumnResize,
...datagridProps
}): ReactElement => {
const classes = useStyles();
const fullScreenClasses = useFullScreenStyles(classes);
const datagridTheme = useDatagridTheme();
const editor = React.useRef<DataEditorRef>();
const { themeValues } = useTheme();
const datagridTheme = useDatagridTheme(readonly);
const editor = useRef<DataEditorRef>();
const cellProps = useCells();
const { scrolledToRight, scroller } = useScrollRight();
const defualtColumnPickerProps = getDefultColumnPickerProps(
classes.ghostIcon,
);
const fullScreenClasses = useFullScreenStyles(classes);
const { isOpen, isAnimationOpenFinished, toggle } = useFullScreenMode();
usePortalClasses({ className: classes.portal });
const { clearTooltip, tooltip, setTooltip } = useTooltipContainer();
const {
availableColumnsChoices,
columns,
columnChoices,
defaultColumns,
displayedColumns,
onColumnMoved,
onColumnResize,
onColumnsChange,
picker,
} = useColumns(availableColumns);
const [selection, setSelection] = useState<GridSelection>();
const [hoverRow, setHoverRow] = useState<number | undefined>(undefined);
usePortalClasses({ className: classes.portal });
usePreventHistoryBack(scroller);
const {
added,
@ -108,83 +151,132 @@ export const Datagrid: React.FC<DatagridProps> = ({
onRowAdded,
} = useDatagridChange(availableColumns, rows, onChange);
const theme = useTheme();
const rowsTotal = rows - removed.length + added.length;
const hasMenuItem = !!menuItems(0).length;
const hasColumnGroups = availableColumns.some(col => col.group);
const headerTitle = isAnimationOpenFinished
? fullScreenTitle ?? title
: title;
const [scrolledToRight, setScrolledToRight] = React.useState(false);
const scroller: HTMLDivElement = document.querySelector(".dvn-scroller");
const scrollerInner: HTMLDivElement =
document.querySelector(".dvn-scroll-inner");
usePreventHistoryBack(scroller);
React.useEffect(() => {
if (!(scroller && scrollerInner)) {
return;
}
const handler = throttle(() => {
const isScrolledToRight =
scroller.scrollWidth - scroller.clientWidth - scroller.scrollLeft < 2;
setScrolledToRight(isScrolledToRight);
}, 100);
scroller.addEventListener("scroll", handler);
return () => scroller.removeEventListener("scroll", handler);
}, [scroller, scrollerInner]);
const getCellContentEnh = React.useCallback(
const handleGetCellContent = useCallback(
([column, row]: Item): GridCell => {
const item = [
availableColumns.findIndex(ac => ac.id === displayedColumns[column]),
row,
] as const;
const item = [column, row] as const;
const opts = { changes, added, removed, getChangeIndex };
const columnId = availableColumns[column].id;
const columnId = availableColumns[column]?.id;
const changed = !!changes.current[getChangeIndex(columnId, row)]?.data;
return {
...getCellContent(item, opts),
...(changed
? { themeOverride: { bgCell: theme.palette.saleor.active[5] } }
? {
themeOverride: {
bgCell: themeValues.colors.background.surfaceBrandHighlight,
},
}
: {}),
...(getCellError(item, opts)
? {
themeOverride: {
bgCell:
theme.palette.saleor.theme === "light"
? theme.palette.saleor.fail.light
: theme.palette.saleor.errorAction[5],
themeValues.colors.background.interactiveCriticalHovering,
},
}
: {}),
};
},
[getCellContent, availableColumns, displayedColumns, added, removed],
[
availableColumns,
changes,
added,
removed,
getChangeIndex,
getCellContent,
themeValues,
getCellError,
],
);
const onCellEditedEnh = React.useCallback(
const handleOnCellEdited = useCallback(
([column, row]: Item, newValue: EditableGridCell): void => {
onCellEdited(
[
availableColumns.findIndex(ac => ac.id === displayedColumns[column]),
row,
],
newValue,
);
onCellEdited([column, row], newValue);
editor.current.updateCells(
range(displayedColumns.length).map(offset => ({
range(availableColumns.length).map(offset => ({
cell: [column + offset, row],
})),
);
},
[onCellEdited, getCellContent, availableColumns, displayedColumns],
[onCellEdited, availableColumns],
);
const [selection, setSelection] = React.useState<GridSelection>();
const handleCellClick = useCallback(
(item: Item) => {
if (onRowClick && item[0] !== -1) {
onRowClick(item);
}
},
[onRowClick],
);
const props = useCells();
const handleRowHover = useCallback(
(args: GridMouseEventArgs) => {
if (readonly) {
setHoverRow(args.kind !== "cell" ? undefined : args.location[1]);
}
},
[readonly],
);
const removeRows = React.useCallback(
const handleGridSelectionChange = (gridSelection: GridSelection) => {
// In readonly we not allow selecting cells, but we allow selcting column
if (readonly && !gridSelection.current) {
setSelection(gridSelection);
}
if (!readonly) {
setSelection(gridSelection);
}
};
const handleGetThemeOverride = useCallback<GetRowThemeCallback>(
(row: number) => {
if (row !== hoverRow) {
return undefined;
}
const overrideTheme = {
bgCell: themeValues.colors.background.surfaceNeutralHighlight,
bgCellMedium: themeValues.colors.background.surfaceNeutralHighlight,
accentLight: undefined,
};
if (readonly) {
overrideTheme.accentLight =
themeValues.colors.background.surfaceNeutralHighlight;
}
return overrideTheme;
},
[hoverRow, readonly, themeValues],
);
const handleHeaderClicked = useCallback(
(colIndex: number, event: HeaderClickedEventArgs) => {
if (getColumnTooltipContent) {
const content = getColumnTooltipContent(colIndex);
if (content) {
setTooltip(content, event.bounds);
}
}
if (onHeaderClicked) {
onHeaderClicked(colIndex, event);
}
},
[getColumnTooltipContent, onHeaderClicked, setTooltip],
);
const handleRemoveRows = useCallback(
(rows: number[]) => {
if (selection?.rows) {
onRowsRemoved(rows);
@ -194,19 +286,36 @@ export const Datagrid: React.FC<DatagridProps> = ({
[selection, onRowsRemoved],
);
const selectionActionsComponent = React.useMemo(
() =>
selection?.rows.length > 0
? selectionActions(Array.from(selection.rows), { removeRows })
: null,
[selection, selectionActions, removeRows],
const handleColumnResize = useCallback(
(column: GridColumn, newSize: number) => {
if (tooltip) {
clearTooltip();
}
onColumnResize(column, newSize);
},
[clearTooltip, onColumnResize, tooltip],
);
const rowsTotal = rows - removed.length + added.length;
const hasColumnGroups = columns.some(col => col.group);
const headerTitle = isAnimationOpenFinished
? fullScreenTitle ?? title
: title;
const handleColumnMoved = useCallback(
(startIndex: number, endIndex: number) => {
if (tooltip) {
clearTooltip();
}
onColumnMoved(startIndex, endIndex);
},
[clearTooltip, onColumnMoved, tooltip],
);
const selectionActionsComponent = useMemo(
() =>
selection?.rows.length > 0
? selectionActions(Array.from(selection.rows), {
removeRows: handleRemoveRows,
})
: null,
[selection, selectionActions, handleRemoveRows],
);
return (
<FullScreenContainer
@ -214,26 +323,30 @@ export const Datagrid: React.FC<DatagridProps> = ({
className={fullScreenClasses.fullScreenContainer}
>
<Card className={classes.root}>
<Header title={headerTitle}>
<Header.ButtonFullScreen isOpen={isOpen} onToggle={toggle}>
{isOpen ? (
<FormattedMessage
id="QjPJ78"
defaultMessage="Close"
description="close full-screen"
/>
) : (
<FormattedMessage
id="483Xnh"
defaultMessage="Open"
description="open full-screen"
/>
{headerTitle && (
<Header title={headerTitle}>
<Header.ButtonFullScreen isOpen={isOpen} onToggle={toggle}>
{isOpen ? (
<FormattedMessage
id="QjPJ78"
defaultMessage="Close"
description="close full-screen"
/>
) : (
<FormattedMessage
id="483Xnh"
defaultMessage="Open"
description="open full-screen"
/>
)}
</Header.ButtonFullScreen>
{addButtonLabel && (
<Header.ButtonAddRow onAddRow={onRowAdded}>
{addButtonLabel}
</Header.ButtonAddRow>
)}
</Header.ButtonFullScreen>
<Header.ButtonAddRow onAddRow={onRowAdded}>
{addButtonLabel}
</Header.ButtonAddRow>
</Header>
</Header>
)}
<CardContent classes={{ root: classes.cardContentRoot }}>
{rowsTotal > 0 ? (
<>
@ -243,28 +356,43 @@ export const Datagrid: React.FC<DatagridProps> = ({
</div>
)}
<div className={classes.editorContainer}>
<Box
backgroundColor="plain"
borderTopWidth={1}
borderTopStyle="solid"
borderColor="neutralPlain"
__maxWidth={contentMaxWidth}
margin="auto"
/>
<DataEditor
{...props}
{...cellProps}
{...datagridProps}
verticalBorder={verticalBorder}
headerIcons={headerIcons}
theme={datagridTheme}
className={classes.datagrid}
getCellContent={getCellContentEnh}
onCellEdited={onCellEditedEnh}
columns={columns}
getCellContent={handleGetCellContent}
onCellEdited={handleOnCellEdited}
columns={availableColumns}
rows={rowsTotal}
freezeColumns={1}
freezeColumns={freezeColumns}
smoothScrollX
rowMarkers="checkbox"
rowMarkers={rowMarkers}
rowSelect="multi"
rowSelectionMode="multi"
rangeSelect="multi-rect"
columnSelect="none"
columnSelect={columnSelect}
getCellsForSelection
onColumnMoved={onColumnMoved}
onColumnResize={onColumnResize}
onGridSelectionChange={setSelection}
onColumnMoved={handleColumnMoved}
onColumnResize={handleColumnResize}
onHeaderClicked={handleHeaderClicked}
onCellClicked={handleCellClick}
onGridSelectionChange={handleGridSelectionChange}
onItemHovered={handleRowHover}
getRowThemeOverride={handleGetThemeOverride}
gridSelection={selection}
rowHeight={cellHeight}
headerHeight={cellHeight}
headerHeight={cellHeight + 16}
ref={editor}
onPaste
rightElementProps={{
@ -274,50 +402,60 @@ export const Datagrid: React.FC<DatagridProps> = ({
<div
className={clsx(classes.rowActionBar, {
[classes.rowActionBarScrolledToRight]: scrolledToRight,
[classes.rowActionvBarWithItems]: hasMenuItem,
})}
>
<div
className={clsx(classes.rowActionBarShadow, {
[classes.rowActionBarShadowActive]: !scrolledToRight,
[classes.rowActionBarShadowActive]:
!scrolledToRight && hasMenuItem,
})}
/>
<div className={classes.columnPicker}>
<ColumnPicker
IconButtonProps={{
className: classes.ghostIcon,
variant: "ghost",
hoverOutline: false,
}}
availableColumns={availableColumnsChoices}
initialColumns={columnChoices}
defaultColumns={defaultColumns}
onSave={onColumnsChange}
hasMore={false}
loading={false}
onFetchMore={() => undefined}
onQueryChange={picker.setQuery}
query={picker.query}
/>
<div
className={clsx(classes.columnPicker, {
[classes.columnPickerBackground]: !hasMenuItem,
})}
>
{renderColumnPicker
? renderColumnPicker(defualtColumnPickerProps)
: null}
</div>
{hasColumnGroups && (
<div
className={clsx(classes.rowAction, {
[classes.rowActionScrolledToRight]: scrolledToRight,
})}
className={clsx(
classes.rowAction,
classes.rowColumnGroup,
{
[classes.rowActionScrolledToRight]:
scrolledToRight,
},
)}
/>
)}
{Array(rowsTotal)
.fill(0)
.map((_, index) => (
<RowActions
menuItems={menuItems(index)}
disabled={index >= rowsTotal - added.length}
/>
))}
{hasMenuItem &&
Array(rowsTotal)
.fill(0)
.map((_, index) => (
<RowActions
menuItems={menuItems(index)}
disabled={index >= rowsTotal - added.length}
/>
))}
</div>
}
rowMarkerWidth={48}
/>
<Box
position="relative"
__marginTop="-1px"
backgroundColor="plain"
borderTopWidth={1}
borderTopStyle="solid"
borderColor="neutralPlain"
__maxWidth={contentMaxWidth}
margin="auto"
zIndex="2"
/>
{/* FIXME: https://github.com/glideapps/glide-data-grid/issues/505 */}
{hasColumnGroups && (
<div className={classes.columnGroupFixer} />
@ -325,10 +463,22 @@ export const Datagrid: React.FC<DatagridProps> = ({
</div>
</>
) : (
<Typography align="center">{emptyText}</Typography>
<Box
borderTopStyle="solid"
borderTopWidth={1}
borderColor="neutralHighlight"
paddingY={9}
>
<Typography align="center">{emptyText}</Typography>
</Box>
)}
</CardContent>
</Card>
<TooltipContainer
clearTooltip={clearTooltip}
bounds={tooltip?.bounds}
title={tooltip?.title}
/>
</FullScreenContainer>
);
};

View file

@ -2,7 +2,7 @@ import { useTheme } from "@saleor/macaw-ui";
import React, { CSSProperties, FC, PropsWithChildren } from "react";
import ReactDOM from "react-dom";
import { useDelayedState } from "./useDelayedState";
import { useDelayedState } from "../hooks/useDelayedState";
const modalRoot =
document.getElementById("modal-root") || document.createElement("div");

View file

@ -3,7 +3,7 @@ import { Button, makeStyles, PlusSmallIcon } from "@saleor/macaw-ui";
import clsx from "clsx";
import React, { FC, PropsWithChildren } from "react";
import CardTitle from "../CardTitle";
import CardTitle from "../../CardTitle";
const useStyles = makeStyles(
theme => ({

View file

@ -1,8 +1,8 @@
import { IconButton, MoreHorizontalIcon } from "@saleor/macaw-ui";
import React from "react";
import CardMenu, { CardMenuItem } from "../CardMenu";
import useStyles from "./styles";
import CardMenu, { CardMenuItem } from "../../CardMenu";
import useStyles from "../styles";
interface RowActionsProps {
menuItems: CardMenuItem[];

View file

@ -0,0 +1,58 @@
import { Tooltip } from "@saleor/macaw-ui";
import { Box } from "@saleor/macaw-ui/next";
import React from "react";
interface TooltipContainerProps {
clearTooltip: () => void;
title?: string;
bounds?: {
x: number;
y: number;
width: number;
height: number;
};
}
export const TooltipContainer: React.FC<TooltipContainerProps> = ({
title,
bounds,
clearTooltip,
}) => {
if (!bounds || !title) {
return null;
}
const { x, y, width, height } = bounds;
return (
<>
{/* This box fill all space base on bound props, serves for container to clear tooltip onMouseLeave */}
<Box
position="absolute"
as="div"
__width={width}
__height={height}
__top={y}
__left={x}
zIndex="3"
onMouseLeave={clearTooltip}
/>
{/* This box is used for positioning tootip in middle */}
<Box
position="absolute"
as="div"
__width={1}
__height={1}
__top={y}
__left={x + width / 2}
zIndex="2"
>
{/* TODO: use tooltip from new macaw when it will be ready */}
<Tooltip open={true} title={title} placement="top">
<span></span>
</Tooltip>
</Box>
</>
);
};

View file

@ -10,8 +10,8 @@ import React from "react";
import SingleAutocompleteSelectField, {
SingleAutocompleteSelectFieldProps,
} from "../SingleAutocompleteSelectField";
import { Choice } from "../SingleSelectField";
} from "../../SingleAutocompleteSelectField";
import { Choice } from "../../SingleSelectField";
export type DropdownChoice = Choice<string, string>;
export type DropdownCellContentProps = Pick<

View file

@ -6,7 +6,7 @@ import {
} from "@glideapps/glide-data-grid";
import React from "react";
import { usePriceField } from "../PriceField/usePriceField";
import { usePriceField } from "../../PriceField/usePriceField";
interface MoneyCellProps {
readonly kind: "money-cell";

View file

@ -6,7 +6,7 @@ import {
} from "@glideapps/glide-data-grid";
import React from "react";
import { Locale } from "../Locale";
import { Locale } from "../../Locale";
export const numberCellEmptyValue = Symbol();
interface NumberCellProps {

View file

@ -0,0 +1,121 @@
import {
CustomCell,
CustomCellRenderer,
getMiddleCenterBias,
TextCellEntry,
} from "@glideapps/glide-data-grid";
import React from "react";
export interface ThumbnailCellProps {
readonly kind: "thumbnail-cell";
readonly image: string;
readonly name: string;
}
export type ThumbnailCell = CustomCell<ThumbnailCellProps>;
export const thumbnailCellRenderer: CustomCellRenderer<ThumbnailCell> = {
isMatch: (cell: CustomCell): cell is ThumbnailCell =>
(cell.data as any).kind === "thumbnail-cell",
draw: (args, cell) => {
const { ctx, rect, theme, imageLoader, col, row } = args;
const { image, name } = cell.data;
const xPad = 7;
const size = rect.height - xPad * 2;
const drawX = rect.x + xPad;
const drawY = rect.y + xPad;
const imageResult = imageLoader.loadOrGetImage(image, col, row);
ctx.save();
if (imageResult !== undefined && image) {
ctx.save();
roundedImage(ctx, drawX, drawY, size, size, 4);
ctx.strokeStyle = theme.borderColor;
ctx.stroke();
ctx.clip();
ctx.drawImage(imageResult, drawX, drawY, size, size);
ctx.restore();
} else {
ctx.save();
ctx.beginPath();
roundedImage(ctx, drawX, drawY, size, size, 4);
ctx.fillStyle = theme.borderColor;
ctx.fill();
ctx.restore();
}
if (name !== undefined) {
ctx.fillStyle = theme.textDark;
ctx.fillText(
name,
drawX + size + xPad,
rect.y + rect.height / 2 + getMiddleCenterBias(ctx, theme),
);
}
ctx.restore();
return true;
},
provideEditor: () => ({
editor: p => {
const { isHighlighted, onChange, value } = p;
return (
<TextCellEntry
highlight={isHighlighted}
autoFocus={true}
readOnly={value.readonly}
value={value.data.name ?? ""}
onChange={e =>
onChange({
...value,
data: {
...value.data,
name: e.target.value,
},
})
}
/>
);
},
disablePadding: true,
deletedValue: cell => ({
...cell,
copyData: "",
data: {
...cell.data,
name: "",
},
}),
}),
onPaste: (v, d) => ({
...d,
name: v,
}),
};
function roundedImage(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number,
) {
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
ctx.lineTo(x + radius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
}

View file

@ -1,7 +1,7 @@
import {
NumberCell,
numberCellEmptyValue,
} from "@dashboard/components/Datagrid/NumberCell";
} from "@dashboard/components/Datagrid/customCells/NumberCell";
import { GridCell, GridCellKind } from "@glideapps/glide-data-grid";
import {
@ -10,6 +10,7 @@ import {
DropdownChoice,
} from "./DropdownCell";
import { MoneyCell } from "./MoneyCell";
import { ThumbnailCell } from "./ThumbnailCell";
const common = {
allowOverlay: true,
@ -25,6 +26,20 @@ export function textCell(value: string): GridCell {
};
}
export function readonlyTextCell(
value: string,
hasCursorPointer: boolean = true,
): GridCell {
return {
cursor: hasCursorPointer ? "pointer" : "default",
allowOverlay: false,
readonly: true,
data: value,
displayData: value,
kind: GridCellKind.Text,
};
}
export function booleanCell(value: boolean): GridCell {
return {
...common,
@ -34,6 +49,13 @@ export function booleanCell(value: boolean): GridCell {
};
}
export function loadingCell(): GridCell {
return {
kind: GridCellKind.Loading,
allowOverlay: true,
};
}
export function numberCell(
value: number | typeof numberCellEmptyValue,
): NumberCell {
@ -63,16 +85,18 @@ export function moneyCell(value: number | null, currency: string): MoneyCell {
export function dropdownCell(
value: DropdownChoice,
opts: DropdownCellContentProps &
dataOpts: DropdownCellContentProps &
(
| { choices: DropdownChoice[] }
| { update: (text: string) => Promise<DropdownChoice[]> }
),
opts?: Partial<GridCell>,
): DropdownCell {
return {
...common,
...opts,
data: {
...opts,
...dataOpts,
kind: "dropdown-cell",
value,
},
@ -80,3 +104,21 @@ export function dropdownCell(
copyData: value.label,
};
}
export function thumbnailCell(
name: string,
image: string,
opts?: Partial<GridCell>,
): ThumbnailCell {
return {
...common,
...opts,
kind: GridCellKind.Custom,
copyData: name ?? "",
data: {
kind: "thumbnail-cell",
image,
name,
},
};
}

View file

@ -5,6 +5,7 @@ import { useMemo } from "react";
import { dropdownCellRenderer } from "./DropdownCell";
import { moneyCellRenderer } from "./MoneyCell";
import { numberCellRenderer } from "./NumberCell";
import { thumbnailCellRenderer } from "./ThumbnailCell";
function useCells() {
const { locale } = useLocale();
@ -13,6 +14,7 @@ function useCells() {
moneyCellRenderer(),
numberCellRenderer(locale),
dropdownCellRenderer,
thumbnailCellRenderer,
],
[locale],
);

View file

@ -0,0 +1,18 @@
import { SpriteMap } from "@glideapps/glide-data-grid";
export const headerIcons: SpriteMap = {
arrowUp:
p => `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill="${p.bgColor}"
d="M8.067 14h7.865a.5.5 0 0 0 .385-.82l-3.933-4.72a.5.5 0 0 0-.768 0l-3.933 4.72a.5.5 0 0 0 .384.82Z"
/>
</svg>`,
arrowDown:
p => `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill="${p.bgColor}"
d="M15.932 10H8.067a.5.5 0 0 0-.384.82l3.933 4.72a.5.5 0 0 0 .768 0l3.933-4.72a.5.5 0 0 0-.385-.82Z"
/>
</svg>`,
};

View file

@ -3,9 +3,11 @@ import { addAtIndex, removeAtIndex } from "@dashboard/utils/lists";
import { GridColumn } from "@glideapps/glide-data-grid";
import { useCallback, useMemo, useState } from "react";
import { AvailableColumn } from "./types";
import { AvailableColumn } from "../types";
function useColumns(availableColumns: readonly AvailableColumn[]) {
export function useColumnsDefault(
availableColumns: readonly AvailableColumn[],
) {
const [query, setQuery] = useState("");
const [displayedColumns, setDisplayedColumns] = useStateFromProps(
availableColumns.map(({ id }) => id),
@ -18,7 +20,7 @@ function useColumns(availableColumns: readonly AvailableColumn[]) {
addAtIndex(old[startIndex], removeAtIndex(old, startIndex), endIndex),
);
},
[],
[setDisplayedColumns],
);
const onColumnResize = useCallback(
(column: GridColumn, newSize: number) =>
@ -29,7 +31,7 @@ function useColumns(availableColumns: readonly AvailableColumn[]) {
: prevColumn,
),
),
[],
[setColumnState],
);
const onColumnsChange = useCallback(
(picked: string[]) =>
@ -39,7 +41,7 @@ function useColumns(availableColumns: readonly AvailableColumn[]) {
.filter(column => !prevColumns.find(c => c === column))
.map(column => availableColumns.find(ac => ac.id === column).id),
]),
[availableColumns],
[availableColumns, setDisplayedColumns],
);
const columns = useMemo(
@ -62,9 +64,10 @@ function useColumns(availableColumns: readonly AvailableColumn[]) {
})),
[availableColumns],
);
const defaultColumns = useMemo(() => availableColumns.map(({ id }) => id), [
availableColumns,
]);
const defaultColumns = useMemo(
() => availableColumns.map(({ id }) => id),
[availableColumns],
);
return {
availableColumnsChoices,
@ -81,5 +84,3 @@ function useColumns(availableColumns: readonly AvailableColumn[]) {
},
};
}
export default useColumns;

View file

@ -1,7 +1,7 @@
import { act, renderHook } from "@testing-library/react-hooks";
import React from "react";
import { AvailableColumn } from "./types";
import { AvailableColumn } from "../types";
import useDatagridChange, {
DatagridChangeStateContext,
useDatagridChangeState,

View file

@ -11,7 +11,7 @@ import {
useState,
} from "react";
import { AvailableColumn } from "./types";
import { AvailableColumn } from "../types";
export interface DatagridChange {
data: any;
@ -32,13 +32,21 @@ export interface UseDatagridChangeState {
removed: number[];
setRemoved: Dispatch<SetStateAction<number[]>>;
changes: MutableRefObject<DatagridChange[]>;
clear: () => void;
}
export function useDatagridChangeState(): UseDatagridChangeState {
const [added, setAdded] = useState<number[]>([]);
const [removed, setRemoved] = useState<number[]>([]);
const changes = useRef<DatagridChange[]>([]);
const clear = () => {
changes.current = [];
setAdded([]);
setRemoved([]);
};
return {
clear,
added,
setAdded,
removed,
@ -47,9 +55,8 @@ export function useDatagridChangeState(): UseDatagridChangeState {
};
}
export const DatagridChangeStateContext = createContext<UseDatagridChangeState>(
undefined,
);
export const DatagridChangeStateContext =
createContext<UseDatagridChangeState>(undefined);
export const useDatagridChangeStateContext = () =>
useContext(DatagridChangeStateContext);
@ -58,13 +65,8 @@ function useDatagridChange(
rows: number,
onChange?: OnDatagridChange,
) {
const {
added,
setAdded,
removed,
setRemoved,
changes,
} = useDatagridChangeStateContext();
const { added, setAdded, removed, setRemoved, changes } =
useDatagridChangeStateContext();
const getChangeIndex = useCallback(
(column: string, row: number): number =>
changes.current.findIndex(

View file

@ -0,0 +1,17 @@
import { themes, useTheme } from "@saleor/macaw-ui/next";
export const useEmptyColumn = () => {
const { theme: currentTheme } = useTheme();
const theme = themes[currentTheme];
return {
id: "empty",
title: "",
width: 20,
themeOverride: {
accentColor: theme.colors.background.plain,
accentLight: theme.colors.background.plain,
bgHeaderHovered: theme.colors.background.plain,
},
};
};

View file

@ -0,0 +1,27 @@
import throttle from "lodash/throttle";
import { useEffect, useState } from "react";
export const useScrollRight = () => {
const [scrolledToRight, setScrolledToRight] = useState(false);
const scroller: HTMLDivElement = document.querySelector(".dvn-scroller");
const scrollerInner: HTMLDivElement =
document.querySelector(".dvn-scroll-inner");
useEffect(() => {
if (!(scroller && scrollerInner)) {
return;
}
const handler = throttle(() => {
const isScrolledToRight =
scroller.scrollWidth - scroller.clientWidth - scroller.scrollLeft < 2;
setScrolledToRight(isScrolledToRight);
}, 100);
scroller.addEventListener("scroll", handler);
return () => scroller.removeEventListener("scroll", handler);
}, [scroller, scrollerInner]);
return { scrolledToRight, scroller };
};

View file

@ -0,0 +1,32 @@
import { useState } from "react";
interface Bounds {
x: number;
y: number;
width: number;
height: number;
}
interface UseTooltipContainerState {
title: string;
bounds: Bounds;
}
export const useTooltipContainer = () => {
const [tooltip, setTooltipState] = useState<
UseTooltipContainerState | undefined
>(undefined);
const setTooltip = (title: string, bounds: Bounds) => {
setTooltipState({
title,
bounds,
});
};
const clearTooltip = () => {
setTooltipState(undefined);
};
return { tooltip, setTooltip, clearTooltip };
};

View file

@ -1,25 +1,24 @@
import { Theme } from "@glideapps/glide-data-grid";
import { makeStyles, useTheme } from "@saleor/macaw-ui";
import { themes } from "@saleor/macaw-ui/next";
import { makeStyles } from "@saleor/macaw-ui";
import { useTheme, vars } from "@saleor/macaw-ui/next";
import { useMemo } from "react";
export const cellHeight = 36;
const useStyles = makeStyles(
theme => {
() => {
const rowActionSelected = {
background: theme.palette.background.paper,
color: theme.palette.saleor.main[1],
background: vars.colors.background.plain,
color: vars.colors.border.neutralHighlight,
};
const activeBorderColor =
theme.palette.saleor.theme === "light" ? "#D4D4D4" : "#232323";
const activeBorderColor = vars.colors.border.neutralDefault;
return {
actionBtnBar: {
position: "absolute",
zIndex: 1,
background: theme.palette.background.paper,
borderRadius: 8,
background: vars.colors.background.plain,
borderRadius: vars.borderRadius[4],
// Right and left toolbars
width: `calc(100% - 64px - ${cellHeight} - 1px)`,
marginTop: 1,
@ -28,16 +27,20 @@ const useStyles = makeStyles(
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
padding: theme.spacing(1),
padding: vars.space[4],
},
columnPicker: {
display: "flex",
alignItems: "center",
justifyContent: "center",
height: cellHeight,
height: cellHeight + 16,
},
columnPickerBackground: {
background: vars.colors.background.plain,
},
ghostIcon: {
color: theme.palette.saleor.main[3],
color: vars.colors.foreground.iconNeutralPlain,
padding: vars.space["3"],
},
portal: {
"& input::-webkit-outer-spin-button, input::-webkit-inner-spin-button":
@ -49,14 +52,14 @@ const useStyles = makeStyles(
appearance: "textfield",
},
"& .clip-region": {
border: `1px solid ${theme.palette.saleor.main[1]}`,
border: `1px solid ${vars.colors.border.brandSubdued}`,
},
"& .gdg-growing-entry": {
flex: 1,
marginTop: 0,
},
"& .gdg-style": {
background: theme.palette.background.paper,
background: vars.colors.background.plain,
border: "none",
// Setting these with !important because we never intend to style
// this particular element, like, never ever
@ -67,11 +70,11 @@ const useStyles = makeStyles(
appearance: "none",
background: "none",
border: "none",
fontSize: themes.defaultLight.fontSize.bodySmall,
letterSpacing: "0.015em",
lineHeight: themes.defaultLight.lineHeight.bodySmall,
fontWeight: themes.defaultLight.fontWeight.bodySmall,
padding: themes.defaultLight.space[3],
fontSize: vars.fontSize.bodySmall,
letterSpacing: vars.letterSpacing.bodyStrongSmall,
lineHeight: vars.lineHeight.bodyEmpMedium,
fontWeight: vars.fontWeight.bodySmall,
padding: vars.space[3],
outline: 0,
},
'& input[type="number"]': {
@ -86,26 +89,29 @@ const useStyles = makeStyles(
datagrid: {
"& .dvn-scroller": {
overscrollBehaviorX: "none",
scrollbarWidth: "none",
},
"& .dvn-scroller::-webkit-scrollbar": {
display: "none",
},
borderTop: `1px solid ${theme.palette.divider}`,
borderBottom: `1px solid ${theme.palette.divider}`,
borderRadius: 0,
boxSizing: "content-box",
width: "100%",
paddingBottom: "1px",
color: "red",
},
root: {
position: "relative",
},
rowActionBar: {
height: "100%",
background: theme.palette.background.paper,
borderLeft: `1px solid ${activeBorderColor}`,
width: 36,
},
rowActionvBarWithItems: {
borderLeft: `1px solid ${activeBorderColor}`,
background: vars.colors.background.plain,
},
rowActionBarScrolledToRight: {
borderLeftColor: theme.palette.divider,
borderLeftColor: vars.colors.border.neutralHighlight,
},
rowAction: {
"&:hover, $rowActionSelected": {
@ -114,19 +120,22 @@ const useStyles = makeStyles(
"&:not(:last-child)": {
marginBottom: -1,
},
border: `1px solid ${theme.palette.divider}`,
borderLeftColor: activeBorderColor,
border: `1px solid ${vars.colors.border.neutralHighlight}`,
borderLeft: "none",
borderRight: "none",
cursor: "pointer",
color: theme.palette.saleor.main[5],
color: vars.colors.foreground.iconNeutralPlain,
marginLeft: -1,
display: "flex",
alignItems: "center",
justifyContent: "center",
height: `calc(${cellHeight}px - 1px)`,
},
rowColumnGroup: {
height: cellHeight + 15,
},
rowActionScrolledToRight: {
borderLeftColor: theme.palette.divider,
borderLeftColor: vars.colors.border.neutralHighlight,
},
columnGroupFixer: {
position: "absolute",
@ -135,7 +144,7 @@ const useStyles = makeStyles(
height: cellHeight,
width: 10,
borderLeft: 0,
background: theme.palette.background.paper,
background: vars.colors.background.plain,
},
editorContainer: {
position: "relative",
@ -146,9 +155,7 @@ const useStyles = makeStyles(
width: 1,
position: "absolute",
zIndex: -1,
transition: theme.transitions.create("box-shadow", {
duration: theme.transitions.duration.short,
}),
transition: "box-shadow .2s ease-in-out",
boxShadow: "-1px 0px 12px transparent",
},
rowActionBarShadowActive: {
@ -156,8 +163,12 @@ const useStyles = makeStyles(
},
rowActionSelected,
cardContentRoot: {
padding: "0 0 2.4rem 0",
padding: "0",
flex: 1,
"&:last-child": {
padding: "0",
},
},
};
},
@ -180,38 +191,53 @@ export const useFullScreenStyles = makeStyles<ReturnType<typeof useStyles>>(
{ name: "Datagrid-fullscreen" },
);
export function useDatagridTheme() {
const theme = useTheme();
export function useDatagridTheme(
readonly?: boolean,
hasHeaderClickable?: boolean,
) {
const { themeValues } = useTheme();
const datagridTheme = useMemo(
(): Partial<Theme> => ({
accentColor: theme.palette.secondary.main,
accentLight: theme.palette.background.default,
accentColor: themeValues.colors.background.interactiveBrandDefault,
accentLight:
themeValues.colors.background.interactiveBrandSecondaryPressing,
accentFg: "transparent",
bgCell: theme.palette.background.paper,
bgHeader: theme.palette.background.paper,
bgHeaderHasFocus: theme.palette.background.paper,
bgHeaderHovered: theme.palette.background.paper,
bgBubbleSelected: theme.palette.background.paper,
textHeader: theme.palette.text.secondary,
borderColor: theme.palette.divider,
bgCell: themeValues.colors.background.plain,
bgHeader: themeValues.colors.background.plain,
bgHeaderHasFocus: themeValues.colors.background.plain,
bgHeaderHovered: hasHeaderClickable
? themeValues.colors.background.surfaceNeutralHighlight
: themeValues.colors.background.plain,
bgBubbleSelected: themeValues.colors.background.plain,
textHeader: themeValues.colors.foreground.iconNeutralPlain,
borderColor: themeValues.colors.border.neutralHighlight,
fontFamily: "'Inter var', sans-serif",
baseFontStyle: themes.defaultLight.fontSize.bodySmall,
headerFontStyle: themes.defaultLight.fontSize.bodySmall,
editorFontSize: themes.defaultLight.fontSize.bodySmall,
textMedium: theme.palette.text.primary,
textGroupHeader: theme.palette.text.secondary,
textBubble: theme.palette.text.primary,
textDark: theme.palette.text.primary,
textLight: theme.palette.text.primary,
baseFontStyle: themeValues.fontSize.bodySmall,
headerFontStyle: themeValues.fontSize.captionSmall,
editorFontSize: themeValues.fontSize.bodySmall,
textMedium: themeValues.colors.background.interactiveNeutralDefault,
textGroupHeader: themeValues.colors.foreground.iconNeutralPlain,
textBubble: themeValues.colors.background.interactiveNeutralDefault,
textDark: themeValues.colors.background.interactiveNeutralDefault,
textLight: themeValues.colors.background.interactiveNeutralDefault,
textHeaderSelected: themeValues.colors.foreground.textBrandDefault,
cellHorizontalPadding: 8,
cellVerticalPadding: 8,
lineHeight: 20,
}),
[theme],
[themeValues, hasHeaderClickable],
);
return datagridTheme;
const readonylDatagridTheme = useMemo(
() => ({
...datagridTheme,
accentColor: themeValues.colors.background.surfaceBrandDepressed,
accentLight: themeValues.colors.background.plain,
}),
[themeValues, datagridTheme],
);
return readonly ? readonylDatagridTheme : datagridTheme;
}
export default useStyles;

View file

@ -1,6 +1,11 @@
import { Theme } from "@glideapps/glide-data-grid";
export interface AvailableColumn {
id: string;
title: string;
width: number;
group?: string;
hasMenu?: boolean;
icon?: string;
themeOverride?: Partial<Theme>;
}

View file

@ -0,0 +1,11 @@
import { ColumnPickerProps } from "../ColumnPicker";
export const getDefultColumnPickerProps = (
className: string,
): Partial<ColumnPickerProps> => ({
IconButtonProps: {
className,
variant: "ghost",
hoverOutline: false,
},
});

View file

@ -15,12 +15,14 @@ export interface FilterBarProps<TKeys extends string = string>
SearchBarProps {
errorMessages?: FilterErrorMessages<TKeys>;
filterStructure: IFilter<TKeys>;
withoutBorder?: boolean;
}
const useStyles = makeStyles(
const useStyles = makeStyles<{ withoutBorder?: boolean }>(
theme => ({
root: {
borderBottom: `1px solid ${theme.palette.divider}`,
borderBottom: props =>
props.withoutBorder ? "none" : `1px solid ${theme.palette.divider}`,
display: "flex",
flexWrap: "wrap",
padding: theme.spacing(1, 4),

View file

@ -21,11 +21,8 @@ export const useExitFormDialog = (
const id = useRef(formId || Symbol()).current;
const exitDialogProps = useContext(ExitFormDialogContext);
const {
setIsDirty,
setIsSubmitDisabled,
setExitDialogSubmitRef,
} = exitDialogProps;
const { setIsDirty, setIsSubmitDisabled, setExitDialogSubmitRef } =
exitDialogProps;
React.useEffect(() => {
if (isDisabled !== undefined) {

View file

@ -50,10 +50,10 @@ export const formatMoneyRange = (
locale: string,
) => {
try {
const formattedMoneyRange = (Intl.NumberFormat(locale, {
const formattedMoneyRange = Intl.NumberFormat(locale, {
style: "currency",
currency: moneyFrom.currency,
}) as any).formatRange(moneyFrom.amount, moneyTo.amount);
}).formatRange(moneyFrom.amount, moneyTo.amount);
// TODO: remove casting from formatRange when typescript
// is updated to 4.7 or higher
return formattedMoneyRange;

View file

@ -3,7 +3,7 @@ import React from "react";
import { useIntl } from "react-intl";
import { LocaleConsumer } from "../Locale";
import { formatMoney, formatMoneyRange } from "../Money";
import { getMoneyRange } from "./utils";
export interface MoneyRangeProps {
from?: IMoney;
@ -15,38 +15,7 @@ export const MoneyRange: React.FC<MoneyRangeProps> = ({ from, to }) => {
return (
<LocaleConsumer>
{({ locale }) => {
if (from && to) {
return from.amount === to.amount
? formatMoney(from, locale)
: formatMoneyRange(from, to, locale);
}
if (from && !to) {
return intl.formatMessage(
{
id: "lW5uJO",
defaultMessage: "from {money}",
description: "money",
},
{
money: formatMoney(from, locale),
},
);
}
if (!from && to) {
return intl.formatMessage(
{
id: "hptDxW",
defaultMessage: "to {money}",
description: "money",
},
{
money: formatMoney(to, locale),
},
);
}
return "-";
}}
{({ locale }) => getMoneyRange(locale, intl, from, to)}
</LocaleConsumer>
);
};

View file

@ -1,2 +1,3 @@
export { default } from "./MoneyRange";
export * from "./MoneyRange";
export { getMoneyRange } from "./utils";

View file

@ -0,0 +1,85 @@
import { IMoney } from "@dashboard/utils/intl";
import { IntlShape } from "react-intl";
import { Locale } from "../Locale";
import { getMoneyRange } from "./utils";
const intl = {
formatMessage: ({ defaultMessage }, params) => {
if (defaultMessage.includes("{money}")) {
return defaultMessage.replace("{money}", params.money);
}
return defaultMessage;
},
} as IntlShape;
describe("getMoneyRange", () => {
it("should return - when no from and to values", () => {
// Arrange & Act
const result = getMoneyRange(Locale.PL, intl);
// Assert
expect(result).toBe("-");
});
it("should return formated money with currency when from and to have same amount", () => {
// Arrange
const fromMoney: IMoney = {
amount: 10,
currency: "eur",
};
const toMoney: IMoney = {
amount: 10,
currency: "eur",
};
// Act & Assert
expect(getMoneyRange(Locale.EN, intl, fromMoney, toMoney)).toBe("€10.00");
});
it("should return formated money range when from and to have different amount", () => {
// Arrange
const fromMoney: IMoney = {
amount: 10,
currency: "eur",
};
const toMoney: IMoney = {
amount: 20,
currency: "eur",
};
// Act & Assert
expect(getMoneyRange(Locale.EN, intl, fromMoney, toMoney)).toBe(
"€10.00 - €20.00",
);
});
it("should return formated money when only from is provided", () => {
// Arrange
const fromMoney: IMoney = {
amount: 10,
currency: "eur",
};
// Act & Assert
expect(getMoneyRange(Locale.EN, intl, fromMoney)).toBe("from €10.00");
});
it("should return formated money when only to is provided", () => {
// Arrange
const toMoney: IMoney = {
amount: 10,
currency: "eur",
};
// Act & Assert
expect(getMoneyRange(Locale.EN, intl, undefined, toMoney)).toBe(
"to €10.00",
);
});
});

View file

@ -0,0 +1,43 @@
import { IMoney } from "@dashboard/utils/intl";
import { IntlShape } from "react-intl";
import { Locale } from "../Locale";
import { formatMoney, formatMoneyRange } from "../Money";
export const getMoneyRange = (
locale: Locale,
intl: IntlShape,
from?: IMoney,
to?: IMoney,
): string => {
if (from && to) {
return from.amount === to.amount
? formatMoney(from, locale)
: formatMoneyRange(from, to, locale);
}
if (from && !to) {
return intl.formatMessage(
{
id: "lW5uJO",
defaultMessage: "from {money}",
description: "money",
},
{
money: formatMoney(from, locale),
},
);
}
if (!from && to) {
return intl.formatMessage(
{
id: "hptDxW",
defaultMessage: "to {money}",
description: "money",
},
{
money: formatMoney(to, locale),
},
);
}
return "-";
};

View file

@ -0,0 +1,26 @@
import { Box, Sprinkles } from "@saleor/macaw-ui/next";
import React from "react";
export interface StatusDotProps {
status: "default" | "error";
}
const getStatusColor = (
status: StatusDotProps["status"],
): Sprinkles["backgroundColor"] => {
switch (status) {
case "error":
return "interactiveCriticalDefault";
default:
return "decorativeSurfacePlain2";
}
};
export const StatusDot: React.FC<StatusDotProps> = ({ status }) => (
<Box
width={4}
height={4}
borderRadius="50%"
backgroundColor={getStatusColor(status)}
/>
);

View file

@ -3,6 +3,7 @@ import { TableCell } from "@material-ui/core";
import {
Pagination,
PaginationProps as MacawPaginationProps,
PaginationRowNumberSelectLabels,
} from "@saleor/macaw-ui";
import React from "react";
import { useIntl } from "react-intl";
@ -27,6 +28,7 @@ export interface PaginationProps
prevHref?: string;
nextHref?: string;
disabled?: boolean;
labels?: PaginationRowNumberSelectLabels;
}
export const TablePagination: React.FC<PaginationProps> = ({
component,
@ -38,6 +40,7 @@ export const TablePagination: React.FC<PaginationProps> = ({
hasNextPage,
hasPreviousPage,
disabled,
labels,
...rest
}) => {
const intl = useIntl();
@ -50,7 +53,8 @@ export const TablePagination: React.FC<PaginationProps> = ({
hasNextPage={hasNextPage && !disabled}
hasPreviousPage={hasPreviousPage && !disabled}
labels={{
noOfRows: intl.formatMessage(commonMessages.noOfRows),
noOfRows:
labels?.noOfRows ?? intl.formatMessage(commonMessages.noOfRows),
}}
rowNumber={settings?.rowNumber}
onRowNumberUpdate={

View file

@ -6,4 +6,8 @@ export const commonTooltipMessages = defineMessages({
defaultMessage:
"Sorting by this column requires active filter: {filterName}",
},
noSortable: {
defaultMessage: "Sorting by this column is not available",
id: "AyQkmp",
},
});

View file

@ -0,0 +1,59 @@
import {
Box,
Button,
ConfigurationIcon,
Dropdown,
List,
Text,
} from "@saleor/macaw-ui/next";
import React from "react";
interface TopNavMenuItem {
label: string;
testId?: string;
onSelect: () => void;
}
interface TopNavMenuProps {
items: TopNavMenuItem[];
dataTestId?: string;
}
export const TopNavMenu: React.FC<TopNavMenuProps> = ({
items,
dataTestId,
}) => (
<Dropdown data-test-id={dataTestId}>
<Dropdown.Trigger>
<Button
icon={<ConfigurationIcon />}
variant="secondary"
data-test-id="show-more-button"
/>
</Dropdown.Trigger>
<Dropdown.Content align="end">
<Box>
<List
padding={5}
borderRadius={4}
boxShadow="overlay"
backgroundColor="surfaceNeutralPlain"
>
{items.map(item => (
<Dropdown.Item key={item.label}>
<List.Item
borderRadius={4}
paddingX={4}
paddingY={5}
onClick={item.onSelect}
data-test-id={item.testId}
>
<Text>{item.label}</Text>
</List.Item>
</Dropdown.Item>
))}
</List>
</Box>
</Dropdown.Content>
</Dropdown>
);

View file

@ -0,0 +1 @@
export * from "./TopNavMenu";

View file

@ -33,6 +33,7 @@ export const VALUES_PAGINATE_BY = 10;
export type ProductListColumns =
| "productType"
| "description"
| "availability"
| "price"
| "date";
@ -92,7 +93,7 @@ export const defaultListSettings: AppListViewSettings = {
rowNumber: PAGINATE_BY,
},
[ListViews.PRODUCT_LIST]: {
columns: ["availability", "price", "productType", "date"],
columns: ["availability", "description", "price", "productType", "date"],
rowNumber: PAGINATE_BY,
},
[ListViews.SALES_LIST]: {

View file

@ -19,11 +19,11 @@ const attachVariablesLink = new ApolloLink((operation, forward) =>
);
export const link = attachVariablesLink.concat(
(createUploadLink({
createUploadLink({
credentials: "include",
uri: getApiUrl(),
fetch: createFetch(),
}) as unknown) as ApolloLink, // type mismatch between apollo-upload-client and @apollo/cient
}) as unknown as ApolloLink, // type mismatch between apollo-upload-client and @apollo/cient
);
export const apolloClient = new ApolloClient({

View file

@ -12684,6 +12684,7 @@ export const ProductListDocument = gql`
node {
...ProductWithChannelListings
updatedAt
description
attributes @include(if: $hasSelectedAttributes) {
...ProductListAttribute
}

View file

@ -9085,7 +9085,7 @@ export type ProductListQueryVariables = Exact<{
}>;
export type ProductListQuery = { __typename: 'Query', products: { __typename: 'ProductCountableConnection', totalCount: number | null, edges: Array<{ __typename: 'ProductCountableEdge', node: { __typename: 'Product', updatedAt: any, id: string, name: string, attributes?: Array<{ __typename: 'SelectedAttribute', attribute: { __typename: 'Attribute', id: string }, values: Array<{ __typename: 'AttributeValue', id: string, name: string | null, slug: string | null, reference: string | null, boolean: boolean | null, date: any | null, dateTime: any | null, value: string | null, file: { __typename: 'File', url: string, contentType: string | null } | null }> }>, thumbnail: { __typename: 'Image', url: string } | null, productType: { __typename: 'ProductType', id: string, name: string, hasVariants: boolean }, channelListings: Array<{ __typename: 'ProductChannelListing', isPublished: boolean, publicationDate: any | null, isAvailableForPurchase: boolean | null, availableForPurchase: any | null, visibleInListings: boolean, pricing?: { __typename: 'ProductPricingInfo', priceRange: { __typename: 'TaxedMoneyRange', start: { __typename: 'TaxedMoney', net: { __typename: 'Money', amount: number, currency: string } } | null, stop: { __typename: 'TaxedMoney', net: { __typename: 'Money', amount: number, currency: string } } | null } | null } | null, channel: { __typename: 'Channel', id: string, name: string, currencyCode: string } }> | null } }>, pageInfo: { __typename: 'PageInfo', hasPreviousPage: boolean, hasNextPage: boolean, startCursor: string | null, endCursor: string | null } } | null };
export type ProductListQuery = { __typename: 'Query', products: { __typename: 'ProductCountableConnection', totalCount: number | null, edges: Array<{ __typename: 'ProductCountableEdge', node: { __typename: 'Product', updatedAt: any, description: any | null, id: string, name: string, attributes?: Array<{ __typename: 'SelectedAttribute', attribute: { __typename: 'Attribute', id: string }, values: Array<{ __typename: 'AttributeValue', id: string, name: string | null, slug: string | null, reference: string | null, boolean: boolean | null, date: any | null, dateTime: any | null, value: string | null, file: { __typename: 'File', url: string, contentType: string | null } | null }> }>, thumbnail: { __typename: 'Image', url: string } | null, productType: { __typename: 'ProductType', id: string, name: string, hasVariants: boolean }, channelListings: Array<{ __typename: 'ProductChannelListing', isPublished: boolean, publicationDate: any | null, isAvailableForPurchase: boolean | null, availableForPurchase: any | null, visibleInListings: boolean, pricing?: { __typename: 'ProductPricingInfo', priceRange: { __typename: 'TaxedMoneyRange', start: { __typename: 'TaxedMoney', net: { __typename: 'Money', amount: number, currency: string } } | null, stop: { __typename: 'TaxedMoney', net: { __typename: 'Money', amount: number, currency: string } } | null } | null } | null, channel: { __typename: 'Channel', id: string, name: string, currencyCode: string } }> | null } }>, pageInfo: { __typename: 'PageInfo', hasPreviousPage: boolean, hasNextPage: boolean, startCursor: string | null, endCursor: string | null } } | null };
export type ProductCountQueryVariables = Exact<{
filter?: InputMaybe<ProductFilterInput>;

View file

@ -151,6 +151,10 @@ export const commonMessages = defineMessages({
id: "RrCui3",
defaultMessage: "Summary",
},
type: {
defaultMessage: "Type",
id: "+U6ozc",
},
translationAttributes: {
id: "+xTpT1",
defaultMessage: "Attributes",
@ -203,6 +207,10 @@ export const commonMessages = defineMessages({
defaultMessage: "No. of rows",
description: "pagination",
},
product: {
defaultMessage: "Product",
id: "x/ZVlU",
},
});
export const errorMessages = defineMessages({

View file

@ -105,8 +105,7 @@ const props: OrderGrantRefundPageProps = {
id: "RnVsZmlsbG1lbnRMaW5lOjIw",
quantity: 1,
orderLine: {
id:
"T3JkZXJMaW5lOjIxOTM0YTBjLWQ2NGQtNDdlMS05ZTc4LTJmMjIyYzU5NjQ5ZA==",
id: "T3JkZXJMaW5lOjIxOTM0YTBjLWQ2NGQtNDdlMS05ZTc4LTJmMjIyYzU5NjQ5ZA==",
thumbnail: {
url: placeholderImage,
__typename: "Image",
@ -139,8 +138,7 @@ const props: OrderGrantRefundPageProps = {
id: "RnVsZmlsbG1lbnRMaW5lOjIx",
quantity: 1,
orderLine: {
id:
"T3JkZXJMaW5lOjIxOTM0YTBjLWQ2NGQtNDdlMS05ZTc4LTJmMjIyYzU5NjQ5ZA==",
id: "T3JkZXJMaW5lOjIxOTM0YTBjLWQ2NGQtNDdlMS05ZTc4LTJmMjIyYzU5NjQ5ZA==",
thumbnail: {
url: placeholderImage,
__typename: "Image",
@ -173,8 +171,7 @@ const props: OrderGrantRefundPageProps = {
id: "RnVsZmlsbG1lbnRMaW5lOjIy",
quantity: 2,
orderLine: {
id:
"T3JkZXJMaW5lOmU0N2UyN2IyLTQzZWYtNGY5OS05ZTU0LTkyZmZkZWE5MmY4OA==",
id: "T3JkZXJMaW5lOmU0N2UyN2IyLTQzZWYtNGY5OS05ZTU0LTkyZmZkZWE5MmY4OA==",
thumbnail: {
url: placeholderImage,
__typename: "Image",
@ -199,8 +196,7 @@ const props: OrderGrantRefundPageProps = {
id: "RnVsZmlsbG1lbnRMaW5lOjIz",
quantity: 1,
orderLine: {
id:
"T3JkZXJMaW5lOmZjOWU1YzFlLWFiMDItNDU5MS05YTRmLTA5YmJiN2U0NmZlMg==",
id: "T3JkZXJMaW5lOmZjOWU1YzFlLWFiMDItNDU5MS05YTRmLTA5YmJiN2U0NmZlMg==",
thumbnail: {
url: placeholderImage,
__typename: "Image",

View file

@ -37,18 +37,18 @@ export const ProductsCard: React.FC<ProductsCardProps> = ({
return null;
}
const getHandleAmountChange = (line: OrderLineGrantRefundFragment) => (
e: React.ChangeEvent<HTMLInputElement>,
) => {
const parsedValue = parseInt(e.target.value, 10);
const value = Number.isNaN(parsedValue) ? 0 : parsedValue;
const getHandleAmountChange =
(line: OrderLineGrantRefundFragment) =>
(e: React.ChangeEvent<HTMLInputElement>) => {
const parsedValue = parseInt(e.target.value, 10);
const value = Number.isNaN(parsedValue) ? 0 : parsedValue;
dispatch({
type: "setQuantity",
lineId: line.id,
amount: value,
});
};
dispatch({
type: "setQuantity",
lineId: line.id,
amount: value,
});
};
const handleSetMaxQuanity = () => {
dispatch({

View file

@ -36,10 +36,9 @@ type OrderManualTransactionDialogProps = {
dialogProps: DialogProps;
} & OrderManualTransactionFormProps;
export const OrderManualTransactionDialog: React.FC<OrderManualTransactionDialogProps> = ({
dialogProps,
...props
}) => {
export const OrderManualTransactionDialog: React.FC<
OrderManualTransactionDialogProps
> = ({ dialogProps, ...props }) => {
const intl = useIntl();
const classes = useStyles();

View file

@ -18,10 +18,9 @@ export interface OrderManualTransactionFormProps {
initialData?: Partial<OrderManualTransactionSubmitVariables>;
}
export const OrderManualTransactionForm: React.FC<OrderManualTransactionFormProps> = ({
children,
...props
}) => {
export const OrderManualTransactionForm: React.FC<
OrderManualTransactionFormProps
> = ({ children, ...props }) => {
const { submitState, initialData } = props;
const hookData = useManualRefund({ submitState, initialData });

View file

@ -3,15 +3,11 @@ import React from "react";
import { useManualTransactionContext } from "../context";
export const DescriptionField: React.FC<Omit<
TextFieldProps,
"onChange" | "value"
>> = ({ disabled, ...props }) => {
const {
submitState,
handleChangeDescription,
description,
} = useManualTransactionContext();
export const DescriptionField: React.FC<
Omit<TextFieldProps, "onChange" | "value">
> = ({ disabled, ...props }) => {
const { submitState, handleChangeDescription, description } =
useManualTransactionContext();
return (
<TextField

View file

@ -6,12 +6,8 @@ export const Form: React.FC<React.HTMLProps<HTMLFormElement>> = ({
children,
...props
}) => {
const {
amount,
description,
pspReference,
onAddTransaction,
} = useManualTransactionContext();
const { amount, description, pspReference, onAddTransaction } =
useManualTransactionContext();
return (
<form

View file

@ -3,16 +3,11 @@ import React from "react";
import { useManualTransactionContext } from "../context";
export const PriceInputField: React.FC<Omit<
PriceFieldProps,
"currencySymbol" | "onChange" | "value"
>> = ({ disabled, ...props }) => {
const {
currency,
submitState,
handleChangeAmount,
amount,
} = useManualTransactionContext();
export const PriceInputField: React.FC<
Omit<PriceFieldProps, "currencySymbol" | "onChange" | "value">
> = ({ disabled, ...props }) => {
const { currency, submitState, handleChangeAmount, amount } =
useManualTransactionContext();
return (
<PriceField

View file

@ -3,15 +3,11 @@ import React from "react";
import { useManualTransactionContext } from "../context";
export const PspReferenceField: React.FC<Omit<
TextFieldProps,
"onChange" | "value"
>> = ({ disabled, variant = "outlined", ...props }) => {
const {
submitState,
pspReference,
handleChangePspReference,
} = useManualTransactionContext();
export const PspReferenceField: React.FC<
Omit<TextFieldProps, "onChange" | "value">
> = ({ disabled, variant = "outlined", ...props }) => {
const { submitState, pspReference, handleChangePspReference } =
useManualTransactionContext();
return (
<TextField

View file

@ -5,10 +5,9 @@ import React from "react";
import { useManualTransactionContext } from "../context";
export const SubmitButton: React.FC<Omit<
ConfirmButtonProps,
"type" | "transitionState"
>> = ({ disabled, ...props }) => {
export const SubmitButton: React.FC<
Omit<ConfirmButtonProps, "type" | "transitionState">
> = ({ disabled, ...props }) => {
const { submitState, amount } = useManualTransactionContext();
return (

View file

@ -35,7 +35,9 @@ export const useManualRefund = ({
}
}, [submitState]);
const handleChangeDescription: React.ChangeEventHandler<HTMLInputElement> = e => {
const handleChangeDescription: React.ChangeEventHandler<
HTMLInputElement
> = e => {
setDescription(e.target.value);
};
@ -48,7 +50,9 @@ export const useManualRefund = ({
}
};
const handleChangePspReference: React.ChangeEventHandler<HTMLInputElement> = e => {
const handleChangePspReference: React.ChangeEventHandler<
HTMLInputElement
> = e => {
setPspReference(e.target.value);
};

View file

@ -23,7 +23,8 @@ interface OrderManualTransactionCombinedComponent {
PspReferenceField: typeof PspReferenceField;
}
export const OrderManualTransactionForm = MainComponent as OrderManualTransactionCombinedComponent;
export const OrderManualTransactionForm =
MainComponent as OrderManualTransactionCombinedComponent;
OrderManualTransactionForm.DescriptionField = DescriptionField;
OrderManualTransactionForm.ErrorText = ErrorText;
OrderManualTransactionForm.Form = Form;

View file

@ -59,9 +59,9 @@ export const ItemsCard: React.FC<OrderReturnRefundLinesCardProps> = ({
const classes = useItemCardStyles({});
const intl = useIntl();
const handleChangeQuantity = (id: string) => (
event: React.ChangeEvent<HTMLInputElement>,
) => onChangeQuantity(id, parseInt(event.target.value, 10));
const handleChangeQuantity =
(id: string) => (event: React.ChangeEvent<HTMLInputElement>) =>
onChangeQuantity(id, parseInt(event.target.value, 10));
const fulfillment = order?.fulfillments.find(getById(fulfilmentId));

View file

@ -11,7 +11,9 @@ import {
import { manualRefundMessages, refundPageMessages } from "../messages";
import { useManualRefundCardStyles } from "../styles";
export const ManualRefundCard: React.FC<OrderManualTransactionFormProps> = props => {
export const ManualRefundCard: React.FC<
OrderManualTransactionFormProps
> = props => {
const classes = useManualRefundCardStyles();
const intl = useIntl();

View file

@ -22,13 +22,9 @@ export interface OrderTransactionActionDialogProps extends DialogProps {
action: TransactionActionEnum;
}
export const OrderTransactionActionDialog: React.FC<OrderTransactionActionDialogProps> = ({
confirmButtonState,
open,
onClose,
onSubmit,
action,
}) => {
export const OrderTransactionActionDialog: React.FC<
OrderTransactionActionDialogProps
> = ({ confirmButtonState, open, onClose, onSubmit, action }) => {
const intl = useIntl();
const actionIntl = action

View file

@ -64,17 +64,15 @@ const OrderTransactionPayment: React.FC<OrderTransactionPaymentProps> = ({
__typename: "FakeTransaction",
};
const handleTransactionAction: OrderTransactionProps["onTransactionAction"] = (
_,
action,
) => {
if (action === TransactionActionEnum.CHARGE) {
onCapture();
}
if (action === TransactionActionEnum.VOID) {
onVoid();
}
};
const handleTransactionAction: OrderTransactionProps["onTransactionAction"] =
(_, action) => {
if (action === TransactionActionEnum.CHARGE) {
onCapture();
}
if (action === TransactionActionEnum.VOID) {
onVoid();
}
};
return (
<OrderTransaction

View file

@ -1,463 +0,0 @@
import { ChannelsAvailabilityDropdown } from "@dashboard/components/ChannelsAvailabilityDropdown";
import {
getChannelAvailabilityColor,
getChannelAvailabilityLabel,
} from "@dashboard/components/ChannelsAvailabilityDropdown/utils";
import Checkbox from "@dashboard/components/Checkbox";
import Date from "@dashboard/components/Date";
import MoneyRange from "@dashboard/components/MoneyRange";
import ResponsiveTable from "@dashboard/components/ResponsiveTable";
import Skeleton from "@dashboard/components/Skeleton";
import TableCellAvatar from "@dashboard/components/TableCellAvatar";
import TableCellHeader from "@dashboard/components/TableCellHeader";
import TableHead from "@dashboard/components/TableHead";
import { TablePaginationWithContext } from "@dashboard/components/TablePagination";
import TableRowLink from "@dashboard/components/TableRowLink";
import TooltipTableCellHeader from "@dashboard/components/TooltipTableCellHeader";
import { commonTooltipMessages } from "@dashboard/components/TooltipTableCellHeader/messages";
import { ProductListColumns } from "@dashboard/config";
import { GridAttributesQuery, ProductListQuery } from "@dashboard/graphql";
import { maybe, renderCollection } from "@dashboard/misc";
import {
getAttributeIdFromColumnValue,
isAttributeColumnValue,
} from "@dashboard/products/components/ProductListPage/utils";
import { ProductListUrlSortField, productUrl } from "@dashboard/products/urls";
import { canBeSorted } from "@dashboard/products/views/ProductList/sort";
import {
ChannelProps,
ListActions,
ListProps,
RelayToFlat,
SortPage,
} from "@dashboard/types";
import TDisplayColumn, {
DisplayColumnProps,
} from "@dashboard/utils/columns/DisplayColumn";
import { getArrowDirection } from "@dashboard/utils/sort";
import { TableBody, TableCell, TableFooter } from "@material-ui/core";
import { makeStyles, Pill } from "@saleor/macaw-ui";
import clsx from "clsx";
import React from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { columnsMessages } from "./messages";
import ProductListAttribute from "./ProductListAttribute";
const useStyles = makeStyles(
theme => ({
[theme.breakpoints.up("md")]: {
colName: {
minWidth: 300,
},
colPrice: {
width: 300,
},
colPublished: {
width: 200,
},
colType: {
width: 300,
},
colDate: {
width: 200,
},
},
colAttribute: {
width: 200,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
},
colFill: {
padding: 0,
width: "100%",
},
colName: {
wordBreak: "break-all",
paddingLeft: 0,
"&$colNameFixed": {
width: 300,
},
},
colAvatar: {
wordBreak: "break-all",
},
colNameFixed: {},
colNameWrapper: {
display: "block",
},
colPrice: {
textAlign: "right",
},
colPublished: {},
colType: {
wordBreak: "break-all",
},
link: {
cursor: "pointer",
},
table: {
tableLayout: "fixed",
},
tableContainer: {
overflowX: "scroll",
},
textLeft: {
textAlign: "left",
},
textRight: {
textAlign: "right",
},
}),
{ name: "ProductList" },
);
const DisplayColumn = TDisplayColumn as React.FunctionComponent<
DisplayColumnProps<ProductListColumns>
>;
interface ProductListProps
extends ListProps<ProductListColumns>,
ListActions,
SortPage<ProductListUrlSortField>,
ChannelProps {
activeAttributeSortId: string;
gridAttributes: RelayToFlat<GridAttributesQuery["grid"]>;
products: RelayToFlat<ProductListQuery["products"]>;
}
export const ProductList: React.FC<ProductListProps> = props => {
const {
activeAttributeSortId,
settings,
disabled,
isChecked,
gridAttributes,
products,
selected,
sort,
toggle,
toggleAll,
toolbar,
onUpdateListSettings,
onSort,
selectedChannelId,
filterDependency,
} = props;
const classes = useStyles(props);
const intl = useIntl();
const gridAttributesFromSettings = settings.columns.filter(
isAttributeColumnValue,
);
const numberOfColumns =
(products?.length === 0 ? 1 : 2) + settings.columns.length;
return (
<div className={classes.tableContainer}>
<ResponsiveTable className={classes.table}>
<colgroup>
{products?.length !== 0 && <col />}
<col className={classes.colName} />
<DisplayColumn column="productType" displayColumns={settings.columns}>
<col className={classes.colType} />
</DisplayColumn>
<DisplayColumn
column="availability"
displayColumns={settings.columns}
>
<col className={classes.colPublished} />
</DisplayColumn>
{gridAttributesFromSettings.map(gridAttribute => (
<col className={classes.colAttribute} key={gridAttribute} />
))}
<DisplayColumn column="date" displayColumns={settings.columns}>
<col className={classes.colDate} />
</DisplayColumn>
<DisplayColumn column="price" displayColumns={settings.columns}>
<col className={classes.colPrice} />
</DisplayColumn>
</colgroup>
<TableHead
colSpan={numberOfColumns}
selected={selected}
disabled={disabled}
items={products}
toggleAll={toggleAll}
toolbar={toolbar}
>
<TableCellHeader
data-test-id="col-name-header"
arrowPosition="right"
className={clsx(classes.colName, {
[classes.colNameFixed]: settings.columns.length > 4,
})}
direction={
sort.sort === ProductListUrlSortField.name
? getArrowDirection(sort.asc)
: undefined
}
onClick={() => onSort(ProductListUrlSortField.name)}
>
<FormattedMessage
id="VQLIXd"
defaultMessage="Name"
description="product"
/>
</TableCellHeader>
<DisplayColumn column="productType" displayColumns={settings.columns}>
<TableCellHeader
data-test-id="col-type-header"
className={classes.colType}
direction={
sort.sort === ProductListUrlSortField.productType
? getArrowDirection(sort.asc)
: undefined
}
onClick={() => onSort(ProductListUrlSortField.productType)}
>
<FormattedMessage {...columnsMessages.type} />
</TableCellHeader>
</DisplayColumn>
<DisplayColumn
column="availability"
displayColumns={settings.columns}
>
<TooltipTableCellHeader
data-test-id="col-availability-header"
className={classes.colPublished}
direction={
sort.sort === ProductListUrlSortField.status
? getArrowDirection(sort.asc)
: undefined
}
onClick={() => onSort(ProductListUrlSortField.status)}
disabled={
!canBeSorted(
ProductListUrlSortField.status,
!!selectedChannelId,
)
}
tooltip={intl.formatMessage(
commonTooltipMessages.noFilterSelected,
{ filterName: filterDependency.label },
)}
>
<FormattedMessage {...columnsMessages.availability} />
</TooltipTableCellHeader>
</DisplayColumn>
{gridAttributesFromSettings.map(gridAttributeFromSettings => {
const attributeId = getAttributeIdFromColumnValue(
gridAttributeFromSettings,
);
return (
<TableCellHeader
className={classes.colAttribute}
direction={
sort.sort === ProductListUrlSortField.attribute &&
attributeId === activeAttributeSortId
? getArrowDirection(sort.asc)
: undefined
}
onClick={() =>
onSort(ProductListUrlSortField.attribute, attributeId)
}
key={gridAttributeFromSettings}
>
{maybe<React.ReactNode>(
() =>
gridAttributes.find(
gridAttribute => attributeId === gridAttribute.id,
).name,
<Skeleton />,
)}
</TableCellHeader>
);
})}
<DisplayColumn column="date" displayColumns={settings.columns}>
<TableCellHeader
data-test-id="col-date-header"
className={classes.colDate}
direction={
sort.sort === ProductListUrlSortField.date
? getArrowDirection(sort.asc)
: undefined
}
onClick={() => onSort(ProductListUrlSortField.date)}
>
<FormattedMessage {...columnsMessages.updatedAt} />
</TableCellHeader>
</DisplayColumn>
<DisplayColumn column="price" displayColumns={settings.columns}>
<TooltipTableCellHeader
data-test-id="col-price-header"
className={classes.colPrice}
direction={
sort.sort === ProductListUrlSortField.price
? getArrowDirection(sort.asc)
: undefined
}
textAlign="right"
onClick={() => onSort(ProductListUrlSortField.price)}
disabled={
!canBeSorted(ProductListUrlSortField.price, !!selectedChannelId)
}
tooltip={intl.formatMessage(
commonTooltipMessages.noFilterSelected,
{ filterName: filterDependency.label },
)}
>
<FormattedMessage {...columnsMessages.price} />
</TooltipTableCellHeader>
</DisplayColumn>
</TableHead>
<TableFooter>
<TableRowLink>
<TablePaginationWithContext
colSpan={numberOfColumns}
settings={settings}
onUpdateListSettings={onUpdateListSettings}
/>
</TableRowLink>
</TableFooter>
<TableBody>
{renderCollection(
products,
product => {
const isSelected = product ? isChecked(product.id) : false;
const channel = product?.channelListings.find(
listing => listing.channel.id === selectedChannelId,
);
return (
<TableRowLink
selected={isSelected}
hover={!!product}
key={product ? product.id : "skeleton"}
href={product && productUrl(product.id)}
className={classes.link}
data-test-id={"id-" + (product ? product?.id : "skeleton")}
>
<TableCell padding="checkbox">
<Checkbox
checked={isSelected}
disabled={disabled}
disableClickPropagation
onChange={() => toggle(product.id)}
/>
</TableCell>
<TableCellAvatar
className={classes.colAvatar}
thumbnail={maybe(() => product.thumbnail.url)}
>
{product?.name ? (
<span data-test-id="name">{product.name}</span>
) : (
<Skeleton />
)}
</TableCellAvatar>
<DisplayColumn
column="productType"
displayColumns={settings.columns}
>
<TableCell
className={classes.colType}
data-test-id="product-type"
>
{product?.productType?.name || <Skeleton />}
</TableCell>
</DisplayColumn>
<DisplayColumn
column="availability"
displayColumns={settings.columns}
>
<TableCell
className={classes.colPublished}
data-test-id="availability"
data-test-availability={
!!product?.channelListings?.length
}
>
{(product &&
(channel ? (
<Pill
label={intl.formatMessage(
getChannelAvailabilityLabel(channel),
)}
color={getChannelAvailabilityColor(channel)}
/>
) : (
<ChannelsAvailabilityDropdown
channels={product?.channelListings}
/>
))) ?? <Skeleton />}
</TableCell>
</DisplayColumn>
{gridAttributesFromSettings.map(gridAttribute => (
<TableCell
className={classes.colAttribute}
key={gridAttribute}
data-test-id="attribute"
data-test-attribute={getAttributeIdFromColumnValue(
gridAttribute,
)}
>
<ProductListAttribute
attribute={gridAttribute}
productAttributes={product?.attributes}
/>
</TableCell>
))}
<DisplayColumn
column="date"
displayColumns={settings.columns}
>
<TableCell className={classes.colDate} data-test-id="date">
{product?.updatedAt ? (
<Date date={product.updatedAt} plain />
) : (
<Skeleton />
)}
</TableCell>
</DisplayColumn>
<DisplayColumn
column="price"
displayColumns={settings.columns}
>
<TableCell
className={classes.colPrice}
data-test-id="price"
>
{product?.channelListings ? (
<MoneyRange
from={channel?.pricing?.priceRange?.start?.net}
to={channel?.pricing?.priceRange?.stop?.net}
/>
) : (
<Skeleton />
)}
</TableCell>
</DisplayColumn>
</TableRowLink>
);
},
() => (
<TableRowLink>
<TableCell colSpan={numberOfColumns}>
<FormattedMessage
id="Q1Uzbb"
defaultMessage="No products found"
/>
</TableCell>
</TableRowLink>
),
)}
</TableBody>
</ResponsiveTable>
</div>
);
};
ProductList.displayName = "ProductList";
export default ProductList;

View file

@ -1,44 +0,0 @@
import Date, { DateTime } from "@dashboard/components/Date";
import Skeleton from "@dashboard/components/Skeleton";
import { ProductListAttributeFragment } from "@dashboard/graphql";
import React from "react";
import { getAttributeIdFromColumnValue } from "../ProductListPage/utils";
export interface ProductListAttributeProps {
attribute: string;
productAttributes: ProductListAttributeFragment[];
}
const ProductListAttribute: React.FC<ProductListAttributeProps> = ({
attribute: gridAttribute,
productAttributes,
}) => {
if (!productAttributes) {
return <Skeleton />;
}
const productAttribute = productAttributes.find(
attribute =>
attribute.attribute.id === getAttributeIdFromColumnValue(gridAttribute),
);
if (productAttribute) {
if (productAttribute.values.length) {
if (productAttribute.values[0].date) {
return <Date date={productAttribute.values[0].date} />;
}
if (productAttribute.values[0].dateTime) {
return <DateTime date={productAttribute.values[0].dateTime} />;
}
}
const textValue = productAttribute.values
.map(value => value.name)
.join(", ");
return <span title={textValue}>{textValue}</span>;
}
return <span>-</span>;
};
export default ProductListAttribute;

View file

@ -1 +0,0 @@
export { default } from "./ProductList";

View file

@ -0,0 +1,277 @@
import ColumnPicker from "@dashboard/components/ColumnPicker";
import Datagrid from "@dashboard/components/Datagrid/Datagrid";
import {
DatagridChangeStateContext,
useDatagridChangeState,
} from "@dashboard/components/Datagrid/hooks/useDatagridChange";
import { TablePaginationWithContext } from "@dashboard/components/TablePagination";
import { commonTooltipMessages } from "@dashboard/components/TooltipTableCellHeader/messages";
import { ProductListColumns } from "@dashboard/config";
import {
GridAttributesQuery,
ProductListQuery,
SearchAvailableInGridAttributesQuery,
} from "@dashboard/graphql";
import useLocale from "@dashboard/hooks/useLocale";
import { buttonMessages } from "@dashboard/intl";
import { ProductListUrlSortField } from "@dashboard/products/urls";
import { canBeSorted } from "@dashboard/products/views/ProductList/sort";
import { useSearchProductTypes } from "@dashboard/searches/useProductTypeSearch";
import {
ChannelProps,
FetchMoreProps,
ListProps,
PageListProps,
RelayToFlat,
SortPage,
} from "@dashboard/types";
import { addAtIndex, removeAtIndex } from "@dashboard/utils/lists";
import { GridColumn, Item } from "@glideapps/glide-data-grid";
import { Button } from "@saleor/macaw-ui";
import { Box } from "@saleor/macaw-ui/next";
import React, { useCallback, useMemo } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { isAttributeColumnValue } from "../ProductListPage/utils";
import { useColumnPickerColumns } from "./hooks/useColumnPickerColumns";
import { useDatagridColumns } from "./hooks/useDatagridColumns";
import { messages } from "./messages";
import {
createGetCellContent,
getColumnMetadata,
getProductRowsLength,
} from "./utils";
interface ProductListDatagridProps
extends ListProps<ProductListColumns>,
PageListProps<ProductListColumns>,
SortPage<ProductListUrlSortField>,
FetchMoreProps,
ChannelProps {
activeAttributeSortId: string;
gridAttributes: RelayToFlat<GridAttributesQuery["grid"]>;
products: RelayToFlat<ProductListQuery["products"]>;
onRowClick: (id: string) => void;
columnQuery: string;
availableInGridAttributes: RelayToFlat<
SearchAvailableInGridAttributesQuery["availableInGrid"]
>;
onColumnQueryChange: (query: string) => void;
isAttributeLoading?: boolean;
}
export const ProductListDatagrid: React.FC<ProductListDatagridProps> = ({
products,
onRowClick,
disabled,
settings,
onUpdateListSettings,
selectedChannelId,
onSort,
sort,
loading,
gridAttributes,
hasMore,
isAttributeLoading,
onFetchMore,
columnQuery,
defaultSettings,
availableInGridAttributes,
onColumnQueryChange,
activeAttributeSortId,
filterDependency,
}) => {
const intl = useIntl();
const searchProductType = useSearchProductTypes();
const datagrid = useDatagridChangeState();
const { locale } = useLocale();
const gridAttributesFromSettings = useMemo(
() => settings.columns.filter(isAttributeColumnValue),
[settings.columns],
);
const { columns, setColumns } = useDatagridColumns({
activeAttributeSortId,
gridAttributes,
gridAttributesFromSettings,
settings,
sort,
});
const handleColumnMoved = useCallback(
(startIndex: number, endIndex: number): void => {
// Keep empty column always at beginning
if (startIndex === 0) {
return setColumns(prevColumns => [...prevColumns]);
}
// Keep empty column always at beginning
if (endIndex === 0) {
return setColumns(old =>
addAtIndex(
old[startIndex],
removeAtIndex(old, startIndex),
endIndex + 1,
),
);
}
setColumns(old =>
addAtIndex(old[startIndex], removeAtIndex(old, startIndex), endIndex),
);
},
[setColumns],
);
const handleColumnResize = useCallback(
(column: GridColumn, newSize: number) => {
if (column.id === "empty") {
return;
}
setColumns(prevColumns =>
prevColumns.map(prevColumn =>
prevColumn.id === column.id
? { ...prevColumn, width: newSize }
: prevColumn,
),
);
},
[setColumns],
);
const columnPickerColumns = useColumnPickerColumns(
gridAttributes,
availableInGridAttributes,
settings,
defaultSettings.columns,
);
const getCellContent = useMemo(
() =>
createGetCellContent({
columns,
products,
intl,
getProductTypes: searchProductType,
locale,
gridAttributes,
gridAttributesFromSettings,
selectedChannelId,
loading,
}),
[
columns,
gridAttributes,
gridAttributesFromSettings,
intl,
loading,
locale,
products,
searchProductType,
selectedChannelId,
],
);
const handleHeaderClicked = useCallback(
(col: number) => {
const { columnName, columnId } = getColumnMetadata(columns[col].id);
if (canBeSorted(columnName, !!selectedChannelId)) {
onSort(columnName, columnId);
}
},
[columns, onSort, selectedChannelId],
);
const handleRowClick = useCallback(
([_, row]: Item) => {
const rowData = products[row];
onRowClick(rowData.id);
},
[onRowClick, products],
);
const handleGetColumnTooltipContent = useCallback(
(colIndex: number): string => {
const { columnName } = getColumnMetadata(columns[colIndex].id);
// Sortable column or empty
if (
canBeSorted(columnName, !!selectedChannelId) ||
columns[colIndex].id === "empty"
) {
return "";
}
// No sortable column
if (!Object.keys(ProductListUrlSortField).includes(columnName)) {
return intl.formatMessage(commonTooltipMessages.noSortable);
}
// Sortable but requrie selected channel
return intl.formatMessage(commonTooltipMessages.noFilterSelected, {
filterName: filterDependency.label,
});
},
[columns, filterDependency.label, intl, selectedChannelId],
);
const handleColumnChange = useCallback(
(picked: ProductListColumns[]) => {
onUpdateListSettings("columns", picked);
},
[onUpdateListSettings],
);
return (
<Box __marginTop={-1}>
<DatagridChangeStateContext.Provider value={datagrid}>
<Datagrid
readonly
rowMarkers="none"
columnSelect="single"
freezeColumns={2}
onColumnMoved={handleColumnMoved}
onColumnResize={handleColumnResize}
verticalBorder={col => (col > 1 ? true : false)}
getColumnTooltipContent={handleGetColumnTooltipContent}
availableColumns={columns}
onHeaderClicked={handleHeaderClicked}
emptyText={intl.formatMessage(messages.emptyText)}
getCellContent={getCellContent}
getCellError={() => false}
menuItems={() => []}
rows={getProductRowsLength(disabled, products)}
selectionActions={(indexes, { removeRows }) => (
<Button variant="tertiary" onClick={() => removeRows(indexes)}>
<FormattedMessage {...buttonMessages.delete} />
</Button>
)}
fullScreenTitle={intl.formatMessage(messages.products)}
onRowClick={handleRowClick}
renderColumnPicker={defaultProps => (
<ColumnPicker
{...defaultProps}
{...columnPickerColumns}
hasMore={hasMore}
loading={isAttributeLoading}
onFetchMore={onFetchMore}
query={columnQuery}
onQueryChange={onColumnQueryChange}
onSave={handleColumnChange}
/>
)}
/>
<Box paddingX={9}>
<TablePaginationWithContext
component="div"
colSpan={(products?.length === 0 ? 1 : 2) + settings.columns.length}
settings={settings}
disabled={disabled}
onUpdateListSettings={onUpdateListSettings}
/>
</Box>
</DatagridChangeStateContext.Provider>
</Box>
);
};

View file

@ -0,0 +1,170 @@
import { ProductListColumns } from "@dashboard/config";
import {
GridAttributesQuery,
SearchAvailableInGridAttributesQuery,
} from "@dashboard/graphql";
import { ListSettings, RelayToFlat } from "@dashboard/types";
import { renderHook } from "@testing-library/react-hooks";
import { useColumnPickerColumns } from "./useColumnPickerColumns";
jest.mock("react-intl", () => ({
useIntl: jest.fn(() => ({
formatMessage: jest.fn(x => x.defaultMessage),
})),
defineMessages: jest.fn(x => x),
}));
describe("useColumnPickerColumns", () => {
const staticColumns = [
{
label: "Availability",
value: "availability" as ProductListColumns,
},
{
label: "Price",
value: "price" as ProductListColumns,
},
{
label: "Description",
value: "description" as ProductListColumns,
},
{
label: "Type",
value: "productType" as ProductListColumns,
},
{
label: "Last updated",
value: "date" as ProductListColumns,
},
];
it("should return static columns when attributes are empty and settings contains all columns", () => {
// Arrange
const settings = {
columns: [
"availability",
"description",
"price",
"productType",
"date",
] as ListSettings<ProductListColumns>["columns"],
rowNumber: 20,
};
const defaultSettings = settings.columns;
// Act
const { result } = renderHook(() =>
useColumnPickerColumns([], [], settings, defaultSettings),
);
// Assert
expect(result.current).toEqual({
initialColumns: [...staticColumns],
availableColumns: [...staticColumns],
defaultColumns: [...defaultSettings],
});
});
it("should return columns selected in settings", () => {
// Arrange
const settings = {
columns: [
"availability",
"date",
] as ListSettings<ProductListColumns>["columns"],
rowNumber: 20,
};
const defaultSettings = settings.columns;
// Act
const { result } = renderHook(() =>
useColumnPickerColumns([], [], settings, defaultSettings),
);
// Assert
expect(result.current).toEqual({
initialColumns: [staticColumns[0], staticColumns[4]],
availableColumns: [...staticColumns],
defaultColumns: [...defaultSettings],
});
});
it("should return selected in setting with attributes", () => {
// Arrange
const settings = {
columns: [
"availability",
"date",
] as ListSettings<ProductListColumns>["columns"],
rowNumber: 20,
};
const selectedAttibutes = [
{
__typename: "Attribute",
id: "1",
name: "Attr1",
},
{
__typename: "Attribute",
id: "2",
name: "Attr2",
},
] as RelayToFlat<GridAttributesQuery["grid"]>;
const availableAttributesToSelect = [
{
__typename: "Attribute",
id: "op1",
name: "AttrOption1",
},
{
__typename: "Attribute",
id: "op2",
name: "AttrOption2",
},
{
__typename: "Attribute",
id: "op3",
name: "AttrOption3",
},
] as RelayToFlat<SearchAvailableInGridAttributesQuery["availableInGrid"]>;
const defaultSettings = settings.columns;
// Act
const { result } = renderHook(() =>
useColumnPickerColumns(
selectedAttibutes,
availableAttributesToSelect,
settings,
defaultSettings,
),
);
// Assert
expect(result.current).toEqual({
initialColumns: [
staticColumns[0],
staticColumns[4],
{
label: "Attr1",
value: "attribute:1",
},
{ label: "Attr2", value: "attribute:2" },
],
availableColumns: [
...staticColumns,
{
label: "AttrOption1",
value: "attribute:op1",
},
{ label: "AttrOption2", value: "attribute:op2" },
{ label: "AttrOption3", value: "attribute:op3" },
],
defaultColumns: [...defaultSettings],
});
});
});

View file

@ -0,0 +1,79 @@
import { MultiAutocompleteChoiceType } from "@dashboard/components/MultiAutocompleteSelectField";
import { ProductListColumns } from "@dashboard/config";
import {
GridAttributesQuery,
SearchAvailableInGridAttributesQuery,
} from "@dashboard/graphql";
import { commonMessages } from "@dashboard/intl";
import { ListSettings, RelayToFlat } from "@dashboard/types";
import { useMemo } from "react";
import { useIntl } from "react-intl";
import { getAttributeColumnValue } from "../../ProductListPage/utils";
import { columnsMessages } from "../messages";
export const useColumnPickerColumns = (
gridAttributes: RelayToFlat<GridAttributesQuery["grid"]>,
availableInGridAttributes: RelayToFlat<
SearchAvailableInGridAttributesQuery["availableInGrid"]
>,
settings: ListSettings<ProductListColumns>,
defaultColumns: ProductListColumns[],
) => {
const intl = useIntl();
const staticColumns = useMemo(
() => [
{
label: intl.formatMessage(columnsMessages.availability),
value: "availability" as ProductListColumns,
},
{
label: intl.formatMessage(columnsMessages.price),
value: "price" as ProductListColumns,
},
{
label: intl.formatMessage(commonMessages.description),
value: "description" as ProductListColumns,
},
{
label: intl.formatMessage(columnsMessages.type),
value: "productType" as ProductListColumns,
},
{
label: intl.formatMessage(columnsMessages.updatedAt),
value: "date" as ProductListColumns,
},
],
[intl],
);
const initialColumns = useMemo(() => {
const selectedStaticColumns = staticColumns.filter(column =>
(settings.columns || []).includes(column.value),
);
const selectedAttributeColumns = gridAttributes.map(attribute => ({
label: attribute.name,
value: getAttributeColumnValue(attribute.id),
}));
return [...selectedStaticColumns, ...selectedAttributeColumns];
}, [gridAttributes, settings.columns, staticColumns]);
const availableColumns: MultiAutocompleteChoiceType[] = [
...staticColumns,
...availableInGridAttributes.map(
attribute =>
({
label: attribute.name,
value: getAttributeColumnValue(attribute.id),
} as MultiAutocompleteChoiceType),
),
];
return {
availableColumns,
initialColumns,
defaultColumns,
};
};

View file

@ -0,0 +1,117 @@
import { useEmptyColumn } from "@dashboard/components/Datagrid/hooks/useEmptyColumn";
import { AvailableColumn } from "@dashboard/components/Datagrid/types";
import { ProductListColumns } from "@dashboard/config";
import { GridAttributesQuery } from "@dashboard/graphql";
import { ProductListUrlSortField } from "@dashboard/products/urls";
import { ListSettings, RelayToFlat, Sort } from "@dashboard/types";
import { getColumnSortDirectionIcon } from "@dashboard/utils/columns/getColumnSortDirectionIcon";
import { useEffect, useRef, useState } from "react";
import { useIntl } from "react-intl";
import { getColumns, toAttributeColumnData } from "../utils";
interface UseDatagridColumnsProps {
activeAttributeSortId: string;
gridAttributes: RelayToFlat<GridAttributesQuery["grid"]>;
gridAttributesFromSettings: ProductListColumns[];
sort: Sort<ProductListUrlSortField>;
settings: ListSettings<ProductListColumns>;
}
export const useDatagridColumns = ({
sort,
gridAttributes,
gridAttributesFromSettings,
activeAttributeSortId,
settings,
}: UseDatagridColumnsProps) => {
const intl = useIntl();
const emptyColumn = useEmptyColumn();
const initialColumns = useRef(
getColumns({
intl,
sort,
gridAttributes,
gridAttributesFromSettings,
activeAttributeSortId,
emptyColumn,
}),
);
const [columns, setColumns] = useState<AvailableColumn[]>([
initialColumns.current[0],
initialColumns.current[1],
...initialColumns.current.filter(col =>
settings.columns.includes(col.id as ProductListColumns),
),
]);
useEffect(() => {
const attributeColumns = gridAttributesFromSettings.map(
toAttributeColumnData(gridAttributes, activeAttributeSortId, sort),
);
setColumns(prevColumns => [
...prevColumns
.filter(byColumnsInSettingsOrStaticColumns(settings))
.map(toCurrentColumnData(sort, attributeColumns)),
...settings.columns
.filter(byNewAddedColumns(prevColumns))
.map(
toNewAddedColumData(
[...initialColumns.current, ...attributeColumns],
sort,
),
),
]);
}, [
activeAttributeSortId,
gridAttributes,
gridAttributesFromSettings,
settings,
sort,
]);
return { columns, setColumns };
};
function byNewAddedColumns(currentColumns: AvailableColumn[]) {
return (column: ProductListColumns) =>
!currentColumns.find(c => c.id === column);
}
function byColumnsInSettingsOrStaticColumns(
settings: ListSettings<ProductListColumns>,
) {
return (column: AvailableColumn) =>
settings.columns.includes(column.id as ProductListColumns) ||
["empty", "name"].includes(column.id);
}
function toCurrentColumnData(
sort: Sort<ProductListUrlSortField>,
attributeColumns: AvailableColumn[],
) {
return (column: AvailableColumn) => {
// Take newest attibutes data from attributeColumns
if (column.id.startsWith("attribute")) {
return attributeColumns.find(ac => ac.id === column.id);
}
return {
...column,
icon: getColumnSortDirectionIcon(sort, column.id),
};
};
}
function toNewAddedColumData(
columnSource: AvailableColumn[],
sort: Sort<ProductListUrlSortField>,
) {
return (column: ProductListColumns) => ({
...columnSource.find(ac => ac.id === column),
icon: getColumnSortDirectionIcon(sort, column as ProductListUrlSortField),
});
}

View file

@ -0,0 +1 @@
export * from "./ProductListDatagrid";

View file

@ -1,20 +1,21 @@
import { defineMessages } from "react-intl";
export const messages = defineMessages({
published: {
id: "Sd0Ppm",
defaultMessage: "Published on {date}",
description: "product publication date",
emptyText: {
id: "Q1Uzbb",
defaultMessage: "No products found",
},
unpublished: {
id: "qpQ0uB",
defaultMessage: "Unpublished",
description: "product publication date",
addProduct: {
id: "tiY7bx",
defaultMessage: "Add new product",
},
willBePublished: {
id: "r0hgpM",
defaultMessage: "Becomes published on {date}",
description: "product publication date",
editProduct: {
defaultMessage: "Edit Product",
id: "Q4m1CG",
},
products: {
defaultMessage: "Products",
id: "7NFfmz",
},
});

View file

@ -0,0 +1,377 @@
import { messages } from "@dashboard/components/ChannelsAvailabilityDropdown/messages";
import { getChannelAvailabilityLabel } from "@dashboard/components/ChannelsAvailabilityDropdown/utils";
import {
dropdownCell,
loadingCell,
readonlyTextCell,
thumbnailCell,
} from "@dashboard/components/Datagrid/customCells/cells";
import {
DropdownChoice,
emptyDropdownCellValue,
} from "@dashboard/components/Datagrid/customCells/DropdownCell";
import { ThumbnailCellProps } from "@dashboard/components/Datagrid/customCells/ThumbnailCell";
import { GetCellContentOpts } from "@dashboard/components/Datagrid/Datagrid";
import { AvailableColumn } from "@dashboard/components/Datagrid/types";
import { Locale } from "@dashboard/components/Locale";
import { getMoneyRange } from "@dashboard/components/MoneyRange";
import { ProductListColumns } from "@dashboard/config";
import { GridAttributesQuery, ProductListQuery } from "@dashboard/graphql";
import { commonMessages } from "@dashboard/intl";
import { ProductListUrlSortField } from "@dashboard/products/urls";
import { RelayToFlat, Sort } from "@dashboard/types";
import { Item } from "@glideapps/glide-data-grid";
import moment from "moment-timezone";
import { IntlShape } from "react-intl";
import { getAttributeIdFromColumnValue } from "../ProductListPage/utils";
import { columnsMessages } from "./messages";
interface GetColumnsProps {
intl: IntlShape;
sort: Sort<ProductListUrlSortField>;
gridAttributes: RelayToFlat<GridAttributesQuery["grid"]>;
gridAttributesFromSettings: ProductListColumns[];
activeAttributeSortId: string;
emptyColumn: AvailableColumn;
}
export function getColumns({
intl,
sort,
gridAttributes,
gridAttributesFromSettings,
activeAttributeSortId,
emptyColumn,
}: GetColumnsProps): AvailableColumn[] {
return [
emptyColumn,
{
id: "name",
title: intl.formatMessage(commonMessages.product),
width: 300,
icon: getColumnSortIconName(sort, ProductListUrlSortField.name),
},
{
id: "productType",
title: intl.formatMessage(columnsMessages.type),
width: 200,
icon: getColumnSortIconName(sort, ProductListUrlSortField.productType),
},
{
id: "description",
title: intl.formatMessage(commonMessages.description),
width: 400,
},
{
id: "availability",
title: intl.formatMessage(columnsMessages.availability),
width: 250,
icon: getColumnSortIconName(sort, ProductListUrlSortField.availability),
},
{
id: "date",
title: intl.formatMessage(columnsMessages.updatedAt),
width: 250,
icon: getColumnSortIconName(sort, ProductListUrlSortField.date),
},
{
id: "price",
title: intl.formatMessage(columnsMessages.price),
width: 250,
icon: getColumnSortIconName(sort, ProductListUrlSortField.price),
},
...gridAttributesFromSettings.map(
toAttributeColumnData(gridAttributes, activeAttributeSortId, sort),
),
];
}
export function toAttributeColumnData(
gridAttributes: RelayToFlat<GridAttributesQuery["grid"]>,
activeAttributeSortId: string,
sort: Sort<ProductListUrlSortField>,
) {
return (attribute: ProductListColumns) => {
const attributeId = getAttributeIdFromColumnValue(attribute);
const title =
gridAttributes.find(gridAttribute => attributeId === gridAttribute.id)
?.name ?? "";
return {
id: attribute,
title,
width: 200,
icon:
attributeId === activeAttributeSortId &&
getColumnSortIconName(sort, ProductListUrlSortField.attribute),
};
};
}
function getColumnSortIconName(
{ sort, asc }: Sort<ProductListUrlSortField>,
columnName: ProductListUrlSortField,
) {
if (columnName === sort) {
if (asc) {
return "arrowUp";
} else {
return "arrowDown";
}
}
return undefined;
}
interface GetCellContentProps {
columns: AvailableColumn[];
products: RelayToFlat<ProductListQuery["products"]>;
intl: IntlShape;
getProductTypes: (query: string) => Promise<DropdownChoice[]>;
locale: Locale;
gridAttributes: RelayToFlat<GridAttributesQuery["grid"]>;
gridAttributesFromSettings: ProductListColumns[];
selectedChannelId?: string;
loading?: boolean;
}
export function createGetCellContent({
columns,
getProductTypes,
intl,
locale,
products,
selectedChannelId,
loading,
}: GetCellContentProps) {
return (
[column, row]: Item,
{ changes, getChangeIndex, added, removed }: GetCellContentOpts,
) => {
if (column === -1) {
return readonlyTextCell("");
}
if (loading) {
return loadingCell();
}
const columnId = columns[column]?.id;
if (!columnId) {
return readonlyTextCell("");
}
const change = changes.current[getChangeIndex(columnId, row)]?.data;
const rowData = added.includes(row)
? undefined
: products[row + removed.filter(r => r <= row).length];
const channel = rowData?.channelListings?.find(
listing => listing.channel.id === selectedChannelId,
);
switch (columnId) {
case "productType":
return getProductTypeCellContent(change, rowData, getProductTypes);
case "availability":
return getAvailabilityCellContent(rowData, intl, channel);
case "description":
return getDescriptionCellContent(columnId, change, rowData);
case "name":
return getNameCellContent(change, rowData);
case "price":
return getPriceCellContent(intl, locale, channel);
case "date":
return getUpdatedAtrCellContent(rowData, locale);
}
if (columnId.startsWith("attribute")) {
return getAttributeCellContent(columnId, rowData);
}
const value = change ?? rowData?.[columnId] ?? "";
return readonlyTextCell(value || "");
};
}
function getProductTypeCellContent(
change: { value: DropdownChoice },
rowData: RelayToFlat<ProductListQuery["products"]>[number],
getProductTypes: (query: string) => Promise<DropdownChoice[]>,
) {
const value = change?.value ?? getRowDataValue(rowData, change?.value);
return dropdownCell(
value,
{
allowCustomValues: false,
emptyOption: false,
update: (text: string) =>
getProductTypes(value.label !== text ? text : ""),
},
{
cursor: "pointer",
},
);
}
function getRowDataValue(
rowData?: RelayToFlat<ProductListQuery["products"]>[number],
changeValue?: DropdownChoice,
): DropdownChoice {
if (changeValue === null || !rowData) {
return emptyDropdownCellValue;
}
return {
label: rowData.productType?.name,
value: rowData.productType?.id,
};
}
function getAvailabilityCellContent(
rowData: RelayToFlat<ProductListQuery["products"]>[number],
intl: IntlShape,
selectedChannnel?: RelayToFlat<
ProductListQuery["products"]
>[number]["channelListings"][number],
) {
if (!!selectedChannnel) {
return readonlyTextCell(
intl.formatMessage(getChannelAvailabilityLabel(selectedChannnel)),
);
}
return readonlyTextCell(
rowData?.channelListings?.length
? intl.formatMessage(messages.dropdownLabel, {
channelCount: rowData?.channelListings?.length,
})
: intl.formatMessage(messages.noChannels),
);
}
function getDescriptionCellContent(
columnId: string,
change: boolean,
rowData: RelayToFlat<ProductListQuery["products"]>[number],
) {
const value = change ?? rowData?.[columnId] ?? "";
if (!value) {
return readonlyTextCell("");
}
const parsed = JSON.parse(value);
if (parsed) {
const descriptionFirstParagraph = parsed.blocks.find(
block => block.type === "paragraph",
);
if (descriptionFirstParagraph) {
return readonlyTextCell(descriptionFirstParagraph.data.text);
}
}
return readonlyTextCell(value || "");
}
function getNameCellContent(
change: ThumbnailCellProps,
rowData: RelayToFlat<ProductListQuery["products"]>[number],
) {
const name = change?.name ?? rowData?.name ?? "";
return thumbnailCell(name, rowData?.thumbnail?.url ?? "", {
cursor: "pointer",
});
}
function getPriceCellContent(
intl: IntlShape,
locale: Locale,
selectedChannnel?: RelayToFlat<
ProductListQuery["products"]
>[number]["channelListings"][number],
) {
const from = selectedChannnel?.pricing?.priceRange?.start?.net;
const to = selectedChannnel?.pricing?.priceRange?.stop?.net;
return readonlyTextCell(getMoneyRange(locale, intl, from, to));
}
function getUpdatedAtrCellContent(
rowData: RelayToFlat<ProductListQuery["products"]>[number],
locale: Locale,
) {
if (!rowData) {
return readonlyTextCell("");
}
return readonlyTextCell(
moment(rowData.updatedAt).locale(locale).format("lll"),
);
}
function getAttributeCellContent(
columnId: string,
rowData: RelayToFlat<ProductListQuery["products"]>[number],
) {
const attributeId = getAttributeIdFromColumnValue(columnId);
const productAttribute = rowData?.attributes.find(
attribute => attribute.attribute.id === attributeId,
);
if (productAttribute) {
if (productAttribute.values.length) {
if (productAttribute.values[0].date) {
return readonlyTextCell(productAttribute.values[0].date);
}
if (productAttribute.values[0].dateTime) {
return readonlyTextCell(productAttribute.values[0].dateTime);
}
}
const textValue = productAttribute.values
.map(value => value.name)
.join(", ");
return readonlyTextCell(textValue);
}
return readonlyTextCell("");
}
export function getColumnMetadata(column: string) {
if (column.includes(":")) {
const [columnName, columnId] = column.split(":");
return {
columnName: columnName as ProductListUrlSortField,
columnId,
};
}
return {
columnName: column as ProductListUrlSortField,
};
}
export function getProductRowsLength(
disabled: boolean,
product?: RelayToFlat<ProductListQuery["products"]>,
) {
if (product?.length) {
return product.length;
}
if (disabled) {
return 1;
}
return 0;
}

View file

@ -34,11 +34,12 @@ const props: ProductListPageProps = {
...sortPageProps.sort,
sort: ProductListUrlSortField.name,
},
channels: [],
columnQuery: "",
availableInGridAttributes: [],
onColumnQueryChange: () => undefined,
},
activeAttributeSortId: undefined,
availableInGridAttributes: attributes,
columnQuery: "",
onColumnQueryChange: () => undefined,
currencySymbol: "USD",
defaultSettings: defaultListSettings[ListViews.PRODUCT_LIST],
filterOpts: productListFilterOpts,

View file

@ -4,16 +4,13 @@ import {
mapToMenuItemsForProductOverviewActions,
useExtensions,
} from "@dashboard/apps/hooks/useExtensions";
import { LimitsInfo } from "@dashboard/components/AppLayout/LimitsInfo";
import { FilterBar } from "@dashboard/components/AppLayout/FilterBar";
import { TopNav } from "@dashboard/components/AppLayout/TopNav";
import { ButtonWithSelect } from "@dashboard/components/ButtonWithSelect";
import CardMenu from "@dashboard/components/CardMenu";
import ColumnPicker from "@dashboard/components/ColumnPicker";
import { ButtonWithDropdown } from "@dashboard/components/ButtonWithDropdown";
import { getByName } from "@dashboard/components/Filter/utils";
import FilterBar from "@dashboard/components/FilterBar";
import { ListPageLayout } from "@dashboard/components/Layouts";
import LimitReachedAlert from "@dashboard/components/LimitReachedAlert";
import { MultiAutocompleteChoiceType } from "@dashboard/components/MultiAutocompleteSelectField";
import { TopNavMenu } from "@dashboard/components/TopNavMenu";
import { ProductListColumns } from "@dashboard/config";
import {
GridAttributesQuery,
@ -21,6 +18,8 @@ import {
RefreshLimitsQuery,
SearchAvailableInGridAttributesQuery,
} from "@dashboard/graphql";
import useLocalStorage from "@dashboard/hooks/useLocalStorage";
import useNavigator from "@dashboard/hooks/useNavigator";
import { sectionNames } from "@dashboard/intl";
import {
ChannelProps,
@ -33,19 +32,19 @@ import {
} from "@dashboard/types";
import { hasLimits, isLimitReached } from "@dashboard/utils/limits";
import { Card } from "@material-ui/core";
import { makeStyles } from "@saleor/macaw-ui";
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import React from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { ProductListUrlSortField } from "../../urls";
import ProductList from "../ProductList";
import { columnsMessages } from "../ProductList/messages";
import { ProductListUrlSortField, productUrl } from "../../urls";
import { ProductListDatagrid } from "../ProductListDatagrid";
import { ProductListTiles } from "../ProductListTiles/ProductListTiles";
import { ProductListViewSwitch } from "../ProductListViewSwitch";
import {
createFilterStructure,
ProductFilterKeys,
ProductListFilterOpts,
} from "./filters";
import { getAttributeColumnValue } from "./utils";
export interface ProductListPageProps
extends PageListProps<ProductListColumns>,
@ -69,30 +68,13 @@ export interface ProductListPageProps
onColumnQueryChange: (query: string) => void;
}
const useStyles = makeStyles(
theme => ({
columnPicker: {
marginRight: theme.spacing(3),
[theme.breakpoints.down("xs")]: {
"& > button": {
width: "100%",
},
},
},
settings: {
[theme.breakpoints.up("sm")]: {
marginRight: theme.spacing(2),
},
},
}),
{ name: "ProductListPage" },
);
export type ProductListViewType = "datagrid" | "tile";
const DEFAULT_PRODUCT_LIST_VIEW_TYPE: ProductListViewType = "datagrid";
export const ProductListPage: React.FC<ProductListPageProps> = props => {
const {
columnQuery,
currencySymbol,
currentTab,
defaultSettings,
gridAttributes,
limits,
@ -102,75 +84,25 @@ export const ProductListPage: React.FC<ProductListPageProps> = props => {
initialSearch,
loading,
settings,
tabs,
onAdd,
onAll,
onColumnQueryChange,
onExport,
onFetchMore,
onFilterChange,
onFilterAttributeFocus,
onSearchChange,
onTabChange,
onTabDelete,
onTabSave,
onUpdateListSettings,
selectedChannelId,
selectedProductIds,
activeAttributeSortId,
...listProps
} = props;
const intl = useIntl();
const classes = useStyles(props);
const staticColumns = [
{
label: intl.formatMessage(columnsMessages.availability),
value: "availability" as ProductListColumns,
},
{
label: intl.formatMessage(columnsMessages.price),
value: "price" as ProductListColumns,
},
{
label: intl.formatMessage(columnsMessages.type),
value: "productType" as ProductListColumns,
},
{
label: intl.formatMessage(columnsMessages.updatedAt),
value: "date" as ProductListColumns,
},
];
const initialColumnsChoices = React.useMemo(() => {
const selectedStaticColumns = staticColumns.filter(column =>
(settings.columns || []).includes(column.value),
);
const selectedAttributeColumns = gridAttributes.map(attribute => ({
label: attribute.name,
value: getAttributeColumnValue(attribute.id),
}));
return [...selectedStaticColumns, ...selectedAttributeColumns];
}, [gridAttributes, settings.columns]);
const handleSave = (columns: ProductListColumns[]) =>
onUpdateListSettings("columns", columns);
const navigate = useNavigator();
const filterStructure = createFilterStructure(intl, filterOpts);
const filterDependency = filterStructure.find(getByName("channel"));
const availableColumns: MultiAutocompleteChoiceType[] = [
...staticColumns,
...availableInGridAttributes.map(
attribute =>
({
label: attribute.name,
value: getAttributeColumnValue(attribute.id),
} as MultiAutocompleteChoiceType),
),
];
const limitReached = isLimitReached(limits, "productVariants");
const { PRODUCT_OVERVIEW_CREATE, PRODUCT_OVERVIEW_MORE_ACTIONS } =
useExtensions(extensionMountPoints.PRODUCT_LIST);
@ -181,65 +113,69 @@ export const ProductListPage: React.FC<ProductListPageProps> = props => {
);
const extensionCreateButtonItems = mapToMenuItems(PRODUCT_OVERVIEW_CREATE);
const [storedProductListViewType, setProductListViewType] =
useLocalStorage<ProductListViewType>(
"productListViewType",
DEFAULT_PRODUCT_LIST_VIEW_TYPE,
);
const isDatagridView = storedProductListViewType === "datagrid";
return (
<ListPageLayout>
<TopNav title={intl.formatMessage(sectionNames.products)}>
<CardMenu
className={classes.settings}
menuItems={[
{
label: intl.formatMessage({
id: "7FL+WZ",
defaultMessage: "Export Products",
description: "export products to csv file, button",
}),
onSelect: onExport,
testId: "export",
},
...extensionMenuItems,
]}
data-test-id="menu"
/>
<ColumnPicker
className={classes.columnPicker}
availableColumns={availableColumns}
initialColumns={initialColumnsChoices}
defaultColumns={defaultSettings.columns}
hasMore={hasMore}
loading={loading}
query={columnQuery}
onQueryChange={onColumnQueryChange}
onFetchMore={onFetchMore}
onSave={handleSave}
IconButtonProps={{ variant: "secondary" }}
/>
<ButtonWithSelect
options={extensionCreateButtonItems}
data-test-id="add-product"
disabled={limitReached}
onClick={onAdd}
>
<FormattedMessage
id="JFmOfi"
defaultMessage="Create Product"
description="button"
/>
</ButtonWithSelect>
{hasLimits(limits, "productVariants") && (
<LimitsInfo
text={intl.formatMessage(
<TopNav withoutBorder title={intl.formatMessage(sectionNames.products)}>
<Box display="flex" alignItems="center" gap={5}>
{hasLimits(limits, "productVariants") && (
<Text variant="caption">
{intl.formatMessage(
{
id: "Kw0jHS",
defaultMessage: "{count}/{max} SKUs used",
description: "created products counter",
},
{
count: limits.currentUsage.productVariants,
max: limits.allowedUsage.productVariants,
},
)}
</Text>
)}
<TopNavMenu
dataTestId="menu"
items={[
{
id: "Kw0jHS",
defaultMessage: "{count}/{max} SKUs used",
description: "created products counter",
label: intl.formatMessage({
id: "7FL+WZ",
defaultMessage: "Export Products",
description: "export products to csv file, button",
}),
onSelect: onExport,
testId: "export",
},
{
count: limits.currentUsage.productVariants,
max: limits.allowedUsage.productVariants,
},
)}
...extensionMenuItems,
]}
/>
)}
{extensionCreateButtonItems.length > 0 ? (
<ButtonWithDropdown
onClick={onAdd}
testId={"add-product"}
options={extensionCreateButtonItems}
>
<FormattedMessage
id="JFmOfi"
defaultMessage="Create Product"
description="button"
/>
</ButtonWithDropdown>
) : (
<Button data-test-id="add-product" onClick={onAdd}>
<FormattedMessage
id="JFmOfi"
defaultMessage="Create Product"
description="button"
/>
</Button>
)}
</Box>
</TopNav>
{limitReached && (
<LimitReachedAlert
@ -256,37 +192,66 @@ export const ProductListPage: React.FC<ProductListPageProps> = props => {
</LimitReachedAlert>
)}
<Card>
<FilterBar
currencySymbol={currencySymbol}
currentTab={currentTab}
initialSearch={initialSearch}
onAll={onAll}
onFilterChange={onFilterChange}
onFilterAttributeFocus={onFilterAttributeFocus}
onSearchChange={onSearchChange}
onTabChange={onTabChange}
onTabDelete={onTabDelete}
onTabSave={onTabSave}
tabs={tabs}
allTabLabel={intl.formatMessage({
id: "aFLtLk",
defaultMessage: "All Products",
description: "tab name",
})}
filterStructure={filterStructure}
searchPlaceholder={intl.formatMessage({
id: "kIvvax",
defaultMessage: "Search Products...",
})}
/>
<ProductList
{...listProps}
gridAttributes={gridAttributes}
settings={settings}
selectedChannelId={selectedChannelId}
onUpdateListSettings={onUpdateListSettings}
filterDependency={filterDependency}
/>
<Box
display="flex"
flexDirection="column"
width="100%"
alignItems="stretch"
justifyContent="space-between"
>
<FilterBar
currencySymbol={currencySymbol}
initialSearch={initialSearch}
onFilterChange={onFilterChange}
onFilterAttributeFocus={onFilterAttributeFocus}
onSearchChange={onSearchChange}
filterStructure={filterStructure}
searchPlaceholder={intl.formatMessage({
id: "kIvvax",
defaultMessage: "Search Products...",
})}
actions={
<ProductListViewSwitch
defaultValue={storedProductListViewType}
setProductListViewType={setProductListViewType}
/>
}
/>
</Box>
{isDatagridView ? (
<ProductListDatagrid
{...listProps}
filterDependency={filterDependency}
activeAttributeSortId={activeAttributeSortId}
columnQuery={columnQuery}
defaultSettings={defaultSettings}
availableInGridAttributes={availableInGridAttributes}
isAttributeLoading={loading}
loading={listProps.disabled}
hasMore={hasMore}
gridAttributes={gridAttributes}
onColumnQueryChange={onColumnQueryChange}
onFetchMore={onFetchMore}
products={listProps.products}
settings={settings}
selectedChannelId={selectedChannelId}
onUpdateListSettings={onUpdateListSettings}
onRowClick={id => {
navigate(productUrl(id));
}}
/>
) : (
<ProductListTiles
{...listProps}
settings={settings}
loading={listProps.disabled}
onUpdateListSettings={onUpdateListSettings}
products={listProps.products}
onTileClick={id => {
navigate(productUrl(id));
}}
/>
)}
</Card>
</ListPageLayout>
);

View file

@ -0,0 +1,84 @@
import { TablePaginationWithContext } from "@dashboard/components/TablePagination";
import { ProductListColumns } from "@dashboard/config";
import { ProductListQuery } from "@dashboard/graphql";
import { ListProps, RelayToFlat } from "@dashboard/types";
import { CircularProgress } from "@material-ui/core";
import { Box, Text } from "@saleor/macaw-ui/next";
import React, { useCallback } from "react";
import { useIntl } from "react-intl";
import { messages } from "../ProductListDatagrid/messages";
import { ProductTile } from "../ProductTile/ProductTile";
export interface ProductListTilesProps extends ListProps<ProductListColumns> {
products: RelayToFlat<ProductListQuery["products"]> | undefined;
loading?: boolean;
onTileClick: (id: string) => void;
}
export const ProductListTiles: React.FC<ProductListTilesProps> = ({
products,
onTileClick,
settings,
disabled,
loading,
onUpdateListSettings,
}) => {
const intl = useIntl();
const renderContent = useCallback(() => {
if (loading) {
return (
<Box display="flex" justifyContent="center" height="100%" marginY={12}>
<CircularProgress />
</Box>
);
}
if (products?.length > 0) {
return (
<Box
display="grid"
gridTemplateColumns={{ mobile: 3, tablet: 5, desktop: 6 }}
gap={9}
padding={9}
>
{products.map(product => (
<ProductTile
key={product.id}
product={product}
onClick={() => onTileClick(product.id)}
/>
))}
</Box>
);
}
return (
<Box padding={9} textAlign="center">
<Text size="small">{intl.formatMessage(messages.emptyText)}</Text>
</Box>
);
}, [intl, loading, onTileClick, products]);
return (
<>
{renderContent()}
<Box paddingX={9}>
<TablePaginationWithContext
component="div"
settings={settings}
disabled={disabled}
labels={{
noOfRows: intl.formatMessage({
id: "9B2mOB",
defaultMessage: "No. of products",
description: "tile view pagination label",
}),
}}
onUpdateListSettings={onUpdateListSettings}
/>
</Box>
</>
);
};

Some files were not shown because too many files have changed in this diff Show more