diff --git a/cypress/e2e/navigation.js b/cypress/e2e/navigation.js index 0bf5408d1..b323de7c9 100644 --- a/cypress/e2e/navigation.js +++ b/cypress/e2e/navigation.js @@ -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); + }); }, ); diff --git a/cypress/e2e/products/images.js b/cypress/e2e/products/images.js index f5c515fea..c5d7ae498 100644 --- a/cypress/e2e/products/images.js +++ b/cypress/e2e/products/images.js @@ -2,10 +2,9 @@ /// 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"] }, diff --git a/cypress/elements/account/index.js b/cypress/elements/account/index.js index ec52952fe..383902adb 100644 --- a/cypress/elements/account/index.js +++ b/cypress/elements/account/index.js @@ -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"; diff --git a/cypress/elements/catalog/index.js b/cypress/elements/catalog/index.js index e66e70522..9d6e1e082 100644 --- a/cypress/elements/catalog/index.js +++ b/cypress/elements/catalog/index.js @@ -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, }; diff --git a/cypress/elements/catalog/products/index.js b/cypress/elements/catalog/products/index.js new file mode 100644 index 000000000..78aa608f3 --- /dev/null +++ b/cypress/elements/catalog/products/index.js @@ -0,0 +1,4 @@ +import { PRODUCT_DETAILS } from "./product-details"; +import { PRODUCTS_LIST } from "./products-list"; + +export { PRODUCT_DETAILS, PRODUCTS_LIST }; diff --git a/cypress/elements/catalog/products/products-list.js b/cypress/elements/catalog/products/products-list.js index 06f484287..4e2ba8298 100644 --- a/cypress/elements/catalog/products/products-list.js +++ b/cypress/elements/catalog/products/products-list.js @@ -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']", diff --git a/cypress/elements/index.js b/cypress/elements/index.js index e5d97e41e..d7c78cdc6 100644 --- a/cypress/elements/index.js +++ b/cypress/elements/index.js @@ -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, diff --git a/cypress/elements/shared/index.js b/cypress/elements/shared/index.js index 02e373d2e..3546f49ba 100644 --- a/cypress/elements/shared/index.js +++ b/cypress/elements/shared/index.js @@ -1,4 +1,4 @@ -import { SHARED_ELEMENTS } from "./sharedElements"; import { ADDRESS_SELECTORS } from "./addressForm"; +import { SHARED_ELEMENTS } from "./sharedElements"; export { SHARED_ELEMENTS, ADDRESS_SELECTORS }; diff --git a/cypress/support/customCommands/basicOperations/index.js b/cypress/support/customCommands/basicOperations/index.js index e9b2efe81..39a7c37d4 100644 --- a/cypress/support/customCommands/basicOperations/index.js +++ b/cypress/support/customCommands/basicOperations/index.js @@ -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); +}); diff --git a/cypress/support/customCommands/sharedElementsOperations/canvas.js b/cypress/support/customCommands/sharedElementsOperations/canvas.js new file mode 100644 index 000000000..921a317c0 --- /dev/null +++ b/cypress/support/customCommands/sharedElementsOperations/canvas.js @@ -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`); + }); +} diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index d9f0b5474..4d3b59477 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -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" diff --git a/package-lock.json b/package-lock.json index 295c7f53b..4783e2d17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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, diff --git a/package.json b/package.json index 75ad8ec29..9773fa2b4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/AppLayout/FilterBar/Filter.tsx b/src/components/AppLayout/FilterBar/Filter.tsx new file mode 100644 index 000000000..b09f1593c --- /dev/null +++ b/src/components/AppLayout/FilterBar/Filter.tsx @@ -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 { + currencySymbol?: string; + errorMessages?: FilterErrorMessages; + menu: IFilter; + onFilterAdd: (filter: Array>) => void; + onFilterAttributeFocus?: (id?: string) => void; +} + +export const Filter = ({ + currencySymbol, + menu, + onFilterAdd, + onFilterAttributeFocus, + errorMessages, +}: FilterProps) => { + const anchor = React.useRef(); + const [isFilterMenuOpened, setFilterMenuOpened] = useState(false); + const [filterErrors, setFilterErrors] = useState>({}); + 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 ( + { + if ((event.target as HTMLElement).getAttribute("role") !== "option") { + setFilterMenuOpened(false); + } + }} + mouseEvent="onMouseUp" + > +
+ setFilterMenuOpened(!isFilterMenuOpened)} + selectedFilterAmount={selectedFilterAmount} + /> + + {() => ( + + + + )} + +
+
+ ); +}; + +Filter.displayName = "Filter"; diff --git a/src/components/AppLayout/FilterBar/FilterBar.tsx b/src/components/AppLayout/FilterBar/FilterBar.tsx new file mode 100644 index 000000000..c9ba9b64a --- /dev/null +++ b/src/components/AppLayout/FilterBar/FilterBar.tsx @@ -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 + extends FilterProps, + SearchPageProps { + searchPlaceholder: string; + errorMessages?: FilterErrorMessages; + filterStructure: IFilter; + actions?: ReactNode; +} + +export const FilterBar: React.FC = ({ + currencySymbol, + filterStructure, + initialSearch, + searchPlaceholder, + onSearchChange, + onFilterChange, + onFilterAttributeFocus, + errorMessages, + actions, +}: FilterBarProps) => ( + <> + + + + + + + + + {actions} + + + +); +FilterBar.displayName = "FilterBar"; diff --git a/src/components/AppLayout/FilterBar/FilterButton.tsx b/src/components/AppLayout/FilterBar/FilterButton.tsx new file mode 100644 index 000000000..1bcbc4582 --- /dev/null +++ b/src/components/AppLayout/FilterBar/FilterButton.tsx @@ -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; + selectedFilterAmount: number; +} + +export const FilterButton = ({ + isFilterActive, + onClick, + selectedFilterAmount, +}: FilterButtonProps) => ( + + + {isFilterActive && ( + <> + + {selectedFilterAmount} + + )} + +); diff --git a/src/components/AppLayout/FilterBar/SearchInput.tsx b/src/components/AppLayout/FilterBar/SearchInput.tsx new file mode 100644 index 000000000..ec7cc6ec5 --- /dev/null +++ b/src/components/AppLayout/FilterBar/SearchInput.tsx @@ -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 = props => { + const { initialSearch, onSearchChange, placeholder } = props; + + const [search, setSearch] = React.useState(initialSearch); + React.useEffect(() => setSearch(initialSearch), [initialSearch]); + + return ( + + {debounceSearchChange => { + const handleSearchChange = (event: React.ChangeEvent) => { + const value = event.target.value; + setSearch(value); + debounceSearchChange(value); + }; + + return ( + + + + ); + }} + + ); +}; + +SearchInput.displayName = "SearchInput"; +export default SearchInput; diff --git a/src/components/AppLayout/FilterBar/index.ts b/src/components/AppLayout/FilterBar/index.ts new file mode 100644 index 000000000..1176cf805 --- /dev/null +++ b/src/components/AppLayout/FilterBar/index.ts @@ -0,0 +1 @@ +export * from "./FilterBar"; diff --git a/src/components/AppLayout/FilterBar/utils.test.ts b/src/components/AppLayout/FilterBar/utils.test.ts new file mode 100644 index 000000000..548f64aeb --- /dev/null +++ b/src/components/AppLayout/FilterBar/utils.test.ts @@ -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; + + const data = [ + { name: "test1", active: false }, + { name: "test2", active: false }, + ] as Array>; + + // 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; + + const data = [ + { name: "test1", active: true }, + { name: "test2", active: false }, + { name: "test3", active: true }, + ] as Array>; + + // Act + const result = getSelectedFilterAmount(menu, data); + + // Assert + expect(result).toBe(2); + }); +}); diff --git a/src/components/AppLayout/FilterBar/utils.ts b/src/components/AppLayout/FilterBar/utils.ts new file mode 100644 index 000000000..ad2d7d168 --- /dev/null +++ b/src/components/AppLayout/FilterBar/utils.ts @@ -0,0 +1,17 @@ +import { FilterElement, IFilter } from "@dashboard/components/Filter"; + +export const getSelectedFilterAmount = ( + menu: IFilter, + data: Array>, +) => + menu.reduce((acc, filterElement) => { + const dataFilterElement = data.find( + ({ name }) => name === filterElement.name, + ); + + if (!dataFilterElement) { + return acc; + } + + return acc + (dataFilterElement.active ? 1 : 0); + }, 0); diff --git a/src/components/AppLayout/LimitsInfo.tsx b/src/components/AppLayout/LimitsInfo.tsx index cbdd317b1..301c4e2b9 100644 --- a/src/components/AppLayout/LimitsInfo.tsx +++ b/src/components/AppLayout/LimitsInfo.tsx @@ -5,6 +5,9 @@ interface LimitsInfoProps { text: string; } +/** + * @deprecated use `Text` instead + */ export const LimitsInfo: React.FC = ({ text }) => ( {text} diff --git a/src/components/AppLayout/TopNav/TopNav.tsx b/src/components/AppLayout/TopNav/TopNav.tsx index a08ad3feb..e998c5c8e 100644 --- a/src/components/AppLayout/TopNav/TopNav.tsx +++ b/src/components/AppLayout/TopNav/TopNav.tsx @@ -9,20 +9,22 @@ import { TopNavWrapper } from "./TopNavWrapper"; interface TopNavProps { title: string | React.ReactNode; href?: string; + withoutBorder?: boolean; } export const TopNav: React.FC> = ({ title, href, + withoutBorder = false, children, }) => { const { availableChannels, channel, isPickerActive, setChannel } = useAppChannel(false); return ( - + {href && } - + {title} diff --git a/src/components/AppLayout/TopNav/TopNavLink.tsx b/src/components/AppLayout/TopNav/TopNavLink.tsx index 5aae561ca..94bf2b97a 100644 --- a/src/components/AppLayout/TopNav/TopNavLink.tsx +++ b/src/components/AppLayout/TopNav/TopNavLink.tsx @@ -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" }) => ( - + + + + + + {options.map(item => ( + + + {item.label} + + + ))} + + + + +); diff --git a/src/components/ButtonWithDropdown/index.ts b/src/components/ButtonWithDropdown/index.ts new file mode 100644 index 000000000..167f44470 --- /dev/null +++ b/src/components/ButtonWithDropdown/index.ts @@ -0,0 +1 @@ +export * from "./ButtonWithDropdown"; diff --git a/src/components/ButtonWithSelect/ButtonWithSelect.tsx b/src/components/ButtonWithSelect/ButtonWithSelect.tsx index fa10800ec..53c9cb7b2 100644 --- a/src/components/ButtonWithSelect/ButtonWithSelect.tsx +++ b/src/components/ButtonWithSelect/ButtonWithSelect.tsx @@ -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 = ({ options, children, diff --git a/src/components/CardMenu/CardMenu.tsx b/src/components/CardMenu/CardMenu.tsx index 90aaf8d86..4a2978d08 100644 --- a/src/components/CardMenu/CardMenu.tsx +++ b/src/components/CardMenu/CardMenu.tsx @@ -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 = props => { const { className, diff --git a/src/components/ColumnPicker/ColumnPicker.tsx b/src/components/ColumnPicker/ColumnPicker.tsx index 45784b406..f7dbf2b23 100644 --- a/src/components/ColumnPicker/ColumnPicker.tsx +++ b/src/components/ColumnPicker/ColumnPicker.tsx @@ -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 = 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 = event => { selectedColumns.current = event.target.value; @@ -112,7 +111,7 @@ const ColumnPicker: React.FC = props => { state={isExpanded ? "active" : "default"} onClick={() => setExpansionState(prevState => !prevState)} > - + ; + changes: MutableRefObject; 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, + ) => 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 = ({ @@ -75,28 +105,41 @@ export const Datagrid: React.FC = ({ 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(); + const { themeValues } = useTheme(); + const datagridTheme = useDatagridTheme(readonly); + const editor = useRef(); + 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(); + const [hoverRow, setHoverRow] = useState(undefined); + + usePortalClasses({ className: classes.portal }); + usePreventHistoryBack(scroller); const { added, @@ -108,83 +151,132 @@ export const Datagrid: React.FC = ({ 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(); + 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( + (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 = ({ [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 ( = ({ className={fullScreenClasses.fullScreenContainer} > -
- - {isOpen ? ( - - ) : ( - + {headerTitle && ( +
+ + {isOpen ? ( + + ) : ( + + )} + + {addButtonLabel && ( + + {addButtonLabel} + )} - - - {addButtonLabel} - -
+
+ )} {rowsTotal > 0 ? ( <> @@ -243,28 +356,43 @@ export const Datagrid: React.FC = ({ )}
+ = ({
-
- undefined} - onQueryChange={picker.setQuery} - query={picker.query} - /> +
+ {renderColumnPicker + ? renderColumnPicker(defualtColumnPickerProps) + : null}
{hasColumnGroups && (
)} - {Array(rowsTotal) - .fill(0) - .map((_, index) => ( - = rowsTotal - added.length} - /> - ))} + {hasMenuItem && + Array(rowsTotal) + .fill(0) + .map((_, index) => ( + = rowsTotal - added.length} + /> + ))}
} rowMarkerWidth={48} /> + {/* FIXME: https://github.com/glideapps/glide-data-grid/issues/505 */} {hasColumnGroups && (
@@ -325,10 +463,22 @@ export const Datagrid: React.FC = ({
) : ( - {emptyText} + + {emptyText} + )} + ); }; diff --git a/src/components/Datagrid/FullScreenContainer.tsx b/src/components/Datagrid/components/FullScreenContainer.tsx similarity index 96% rename from src/components/Datagrid/FullScreenContainer.tsx rename to src/components/Datagrid/components/FullScreenContainer.tsx index a40717aed..39e2759c9 100644 --- a/src/components/Datagrid/FullScreenContainer.tsx +++ b/src/components/Datagrid/components/FullScreenContainer.tsx @@ -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"); diff --git a/src/components/Datagrid/Header.tsx b/src/components/Datagrid/components/Header.tsx similarity index 98% rename from src/components/Datagrid/Header.tsx rename to src/components/Datagrid/components/Header.tsx index 5f49bbeee..6a3818b57 100644 --- a/src/components/Datagrid/Header.tsx +++ b/src/components/Datagrid/components/Header.tsx @@ -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 => ({ diff --git a/src/components/Datagrid/RowActions.test.tsx b/src/components/Datagrid/components/RowActions.test.tsx similarity index 100% rename from src/components/Datagrid/RowActions.test.tsx rename to src/components/Datagrid/components/RowActions.test.tsx diff --git a/src/components/Datagrid/RowActions.tsx b/src/components/Datagrid/components/RowActions.tsx similarity index 92% rename from src/components/Datagrid/RowActions.tsx rename to src/components/Datagrid/components/RowActions.tsx index a206ecf55..14c7776c8 100644 --- a/src/components/Datagrid/RowActions.tsx +++ b/src/components/Datagrid/components/RowActions.tsx @@ -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[]; diff --git a/src/components/Datagrid/components/TooltipContainer.tsx b/src/components/Datagrid/components/TooltipContainer.tsx new file mode 100644 index 000000000..44c988035 --- /dev/null +++ b/src/components/Datagrid/components/TooltipContainer.tsx @@ -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 = ({ + 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 */} + + + {/* This box is used for positioning tootip in middle */} + + {/* TODO: use tooltip from new macaw when it will be ready */} + + + + + + ); +}; diff --git a/src/components/Datagrid/DropdownCell.tsx b/src/components/Datagrid/customCells/DropdownCell.tsx similarity index 96% rename from src/components/Datagrid/DropdownCell.tsx rename to src/components/Datagrid/customCells/DropdownCell.tsx index 7d86e9368..1442aecf1 100644 --- a/src/components/Datagrid/DropdownCell.tsx +++ b/src/components/Datagrid/customCells/DropdownCell.tsx @@ -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; export type DropdownCellContentProps = Pick< diff --git a/src/components/Datagrid/MoneyCell.tsx b/src/components/Datagrid/customCells/MoneyCell.tsx similarity index 97% rename from src/components/Datagrid/MoneyCell.tsx rename to src/components/Datagrid/customCells/MoneyCell.tsx index 2cee560ad..dde2292f8 100644 --- a/src/components/Datagrid/MoneyCell.tsx +++ b/src/components/Datagrid/customCells/MoneyCell.tsx @@ -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"; diff --git a/src/components/Datagrid/NumberCell.tsx b/src/components/Datagrid/customCells/NumberCell.tsx similarity index 97% rename from src/components/Datagrid/NumberCell.tsx rename to src/components/Datagrid/customCells/NumberCell.tsx index f06244f4b..bb8ec8f0b 100644 --- a/src/components/Datagrid/NumberCell.tsx +++ b/src/components/Datagrid/customCells/NumberCell.tsx @@ -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 { diff --git a/src/components/Datagrid/customCells/ThumbnailCell.tsx b/src/components/Datagrid/customCells/ThumbnailCell.tsx new file mode 100644 index 000000000..bfd1147de --- /dev/null +++ b/src/components/Datagrid/customCells/ThumbnailCell.tsx @@ -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; + +export const thumbnailCellRenderer: CustomCellRenderer = { + 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 ( + + 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(); +} diff --git a/src/components/Datagrid/cells.ts b/src/components/Datagrid/customCells/cells.ts similarity index 62% rename from src/components/Datagrid/cells.ts rename to src/components/Datagrid/customCells/cells.ts index 5d5f81a90..140a8b547 100644 --- a/src/components/Datagrid/cells.ts +++ b/src/components/Datagrid/customCells/cells.ts @@ -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 } ), + opts?: Partial, ): 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, +): ThumbnailCell { + return { + ...common, + ...opts, + kind: GridCellKind.Custom, + copyData: name ?? "", + data: { + kind: "thumbnail-cell", + image, + name, + }, + }; +} diff --git a/src/components/Datagrid/useCells.ts b/src/components/Datagrid/customCells/useCells.ts similarity index 86% rename from src/components/Datagrid/useCells.ts rename to src/components/Datagrid/customCells/useCells.ts index f622a07f6..62507bb43 100644 --- a/src/components/Datagrid/useCells.ts +++ b/src/components/Datagrid/customCells/useCells.ts @@ -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], ); diff --git a/src/components/Datagrid/headerIcons.ts b/src/components/Datagrid/headerIcons.ts new file mode 100644 index 000000000..a3dfedef2 --- /dev/null +++ b/src/components/Datagrid/headerIcons.ts @@ -0,0 +1,18 @@ +import { SpriteMap } from "@glideapps/glide-data-grid"; + +export const headerIcons: SpriteMap = { + arrowUp: + p => ` + + `, + arrowDown: + p => ` + + `, +}; diff --git a/src/components/Datagrid/useColumns.ts b/src/components/Datagrid/hooks/useColumnsDefault.ts similarity index 87% rename from src/components/Datagrid/useColumns.ts rename to src/components/Datagrid/hooks/useColumnsDefault.ts index ba7091ea6..6c6e150b1 100644 --- a/src/components/Datagrid/useColumns.ts +++ b/src/components/Datagrid/hooks/useColumnsDefault.ts @@ -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; diff --git a/src/components/Datagrid/useDatagridChange.test.tsx b/src/components/Datagrid/hooks/useDatagridChange.test.tsx similarity index 98% rename from src/components/Datagrid/useDatagridChange.test.tsx rename to src/components/Datagrid/hooks/useDatagridChange.test.tsx index 670691624..7da3c2d60 100644 --- a/src/components/Datagrid/useDatagridChange.test.tsx +++ b/src/components/Datagrid/hooks/useDatagridChange.test.tsx @@ -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, diff --git a/src/components/Datagrid/useDatagridChange.ts b/src/components/Datagrid/hooks/useDatagridChange.ts similarity index 90% rename from src/components/Datagrid/useDatagridChange.ts rename to src/components/Datagrid/hooks/useDatagridChange.ts index 45dad536d..ee4954518 100644 --- a/src/components/Datagrid/useDatagridChange.ts +++ b/src/components/Datagrid/hooks/useDatagridChange.ts @@ -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>; changes: MutableRefObject; + clear: () => void; } export function useDatagridChangeState(): UseDatagridChangeState { const [added, setAdded] = useState([]); const [removed, setRemoved] = useState([]); const changes = useRef([]); + const clear = () => { + changes.current = []; + setAdded([]); + setRemoved([]); + }; + return { + clear, added, setAdded, removed, @@ -47,9 +55,8 @@ export function useDatagridChangeState(): UseDatagridChangeState { }; } -export const DatagridChangeStateContext = createContext( - undefined, -); +export const DatagridChangeStateContext = + createContext(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( diff --git a/src/components/Datagrid/useDelayedState.ts b/src/components/Datagrid/hooks/useDelayedState.ts similarity index 100% rename from src/components/Datagrid/useDelayedState.ts rename to src/components/Datagrid/hooks/useDelayedState.ts diff --git a/src/components/Datagrid/hooks/useEmptyColumn.ts b/src/components/Datagrid/hooks/useEmptyColumn.ts new file mode 100644 index 000000000..581c08e31 --- /dev/null +++ b/src/components/Datagrid/hooks/useEmptyColumn.ts @@ -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, + }, + }; +}; diff --git a/src/components/Datagrid/useFullScreenMode.ts b/src/components/Datagrid/hooks/useFullScreenMode.ts similarity index 100% rename from src/components/Datagrid/useFullScreenMode.ts rename to src/components/Datagrid/hooks/useFullScreenMode.ts diff --git a/src/components/Datagrid/usePortalClasses.ts b/src/components/Datagrid/hooks/usePortalClasses.ts similarity index 100% rename from src/components/Datagrid/usePortalClasses.ts rename to src/components/Datagrid/hooks/usePortalClasses.ts diff --git a/src/components/Datagrid/usePressEscKey.tsx b/src/components/Datagrid/hooks/usePressEscKey.tsx similarity index 100% rename from src/components/Datagrid/usePressEscKey.tsx rename to src/components/Datagrid/hooks/usePressEscKey.tsx diff --git a/src/components/Datagrid/hooks/useScrollRight.ts b/src/components/Datagrid/hooks/useScrollRight.ts new file mode 100644 index 000000000..73b30a86f --- /dev/null +++ b/src/components/Datagrid/hooks/useScrollRight.ts @@ -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 }; +}; diff --git a/src/components/Datagrid/hooks/useTooltipContainer.ts b/src/components/Datagrid/hooks/useTooltipContainer.ts new file mode 100644 index 000000000..763d7bb42 --- /dev/null +++ b/src/components/Datagrid/hooks/useTooltipContainer.ts @@ -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 }; +}; diff --git a/src/components/Datagrid/styles.ts b/src/components/Datagrid/styles.ts index 1349df57c..50ee6b517 100644 --- a/src/components/Datagrid/styles.ts +++ b/src/components/Datagrid/styles.ts @@ -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>( { name: "Datagrid-fullscreen" }, ); -export function useDatagridTheme() { - const theme = useTheme(); +export function useDatagridTheme( + readonly?: boolean, + hasHeaderClickable?: boolean, +) { + const { themeValues } = useTheme(); const datagridTheme = useMemo( (): Partial => ({ - 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; diff --git a/src/components/Datagrid/types.ts b/src/components/Datagrid/types.ts index 9ae45df0e..5c5de24de 100644 --- a/src/components/Datagrid/types.ts +++ b/src/components/Datagrid/types.ts @@ -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; } diff --git a/src/components/Datagrid/utils.ts b/src/components/Datagrid/utils.ts new file mode 100644 index 000000000..b7e8f2f71 --- /dev/null +++ b/src/components/Datagrid/utils.ts @@ -0,0 +1,11 @@ +import { ColumnPickerProps } from "../ColumnPicker"; + +export const getDefultColumnPickerProps = ( + className: string, +): Partial => ({ + IconButtonProps: { + className, + variant: "ghost", + hoverOutline: false, + }, +}); diff --git a/src/components/FilterBar/FilterBar.tsx b/src/components/FilterBar/FilterBar.tsx index 72a2cf46f..3df7c0bd0 100644 --- a/src/components/FilterBar/FilterBar.tsx +++ b/src/components/FilterBar/FilterBar.tsx @@ -15,12 +15,14 @@ export interface FilterBarProps SearchBarProps { errorMessages?: FilterErrorMessages; filterStructure: IFilter; + 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), diff --git a/src/components/Form/useExitFormDialog.ts b/src/components/Form/useExitFormDialog.ts index 9fac51fb5..1e8cca680 100644 --- a/src/components/Form/useExitFormDialog.ts +++ b/src/components/Form/useExitFormDialog.ts @@ -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) { diff --git a/src/components/Money/index.ts b/src/components/Money/index.ts index 38ef5b2dd..df2734983 100644 --- a/src/components/Money/index.ts +++ b/src/components/Money/index.ts @@ -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; diff --git a/src/components/MoneyRange/MoneyRange.tsx b/src/components/MoneyRange/MoneyRange.tsx index 7849b6fca..0843040c5 100644 --- a/src/components/MoneyRange/MoneyRange.tsx +++ b/src/components/MoneyRange/MoneyRange.tsx @@ -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 = ({ from, to }) => { return ( - {({ 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)} ); }; diff --git a/src/components/MoneyRange/index.ts b/src/components/MoneyRange/index.ts index 84826e016..957d9d016 100644 --- a/src/components/MoneyRange/index.ts +++ b/src/components/MoneyRange/index.ts @@ -1,2 +1,3 @@ export { default } from "./MoneyRange"; export * from "./MoneyRange"; +export { getMoneyRange } from "./utils"; diff --git a/src/components/MoneyRange/utils.test.ts b/src/components/MoneyRange/utils.test.ts new file mode 100644 index 000000000..8b67bbe0d --- /dev/null +++ b/src/components/MoneyRange/utils.test.ts @@ -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", + ); + }); +}); diff --git a/src/components/MoneyRange/utils.ts b/src/components/MoneyRange/utils.ts new file mode 100644 index 000000000..20be669bb --- /dev/null +++ b/src/components/MoneyRange/utils.ts @@ -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 "-"; +}; diff --git a/src/components/StatusDot/StatusDot.tsx b/src/components/StatusDot/StatusDot.tsx new file mode 100644 index 000000000..701ce80cc --- /dev/null +++ b/src/components/StatusDot/StatusDot.tsx @@ -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 = ({ status }) => ( + +); diff --git a/src/components/TablePagination/TablePagination.tsx b/src/components/TablePagination/TablePagination.tsx index d3caf5d50..0d103b253 100644 --- a/src/components/TablePagination/TablePagination.tsx +++ b/src/components/TablePagination/TablePagination.tsx @@ -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 = ({ component, @@ -38,6 +40,7 @@ export const TablePagination: React.FC = ({ hasNextPage, hasPreviousPage, disabled, + labels, ...rest }) => { const intl = useIntl(); @@ -50,7 +53,8 @@ export const TablePagination: React.FC = ({ hasNextPage={hasNextPage && !disabled} hasPreviousPage={hasPreviousPage && !disabled} labels={{ - noOfRows: intl.formatMessage(commonMessages.noOfRows), + noOfRows: + labels?.noOfRows ?? intl.formatMessage(commonMessages.noOfRows), }} rowNumber={settings?.rowNumber} onRowNumberUpdate={ diff --git a/src/components/TooltipTableCellHeader/messages.ts b/src/components/TooltipTableCellHeader/messages.ts index cb94543fd..330e56f5f 100644 --- a/src/components/TooltipTableCellHeader/messages.ts +++ b/src/components/TooltipTableCellHeader/messages.ts @@ -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", + }, }); diff --git a/src/components/TopNavMenu/TopNavMenu.tsx b/src/components/TopNavMenu/TopNavMenu.tsx new file mode 100644 index 000000000..5052e8c3a --- /dev/null +++ b/src/components/TopNavMenu/TopNavMenu.tsx @@ -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 = ({ + items, + dataTestId, +}) => ( + + + + )} + fullScreenTitle={intl.formatMessage(messages.products)} + onRowClick={handleRowClick} + renderColumnPicker={defaultProps => ( + + )} + /> + + + + + + + ); +}; diff --git a/src/products/components/ProductListDatagrid/hooks/useColumnPickerColumns.test.ts b/src/products/components/ProductListDatagrid/hooks/useColumnPickerColumns.test.ts new file mode 100644 index 000000000..85649d07f --- /dev/null +++ b/src/products/components/ProductListDatagrid/hooks/useColumnPickerColumns.test.ts @@ -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["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["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["columns"], + rowNumber: 20, + }; + const selectedAttibutes = [ + { + __typename: "Attribute", + id: "1", + name: "Attr1", + }, + { + __typename: "Attribute", + id: "2", + name: "Attr2", + }, + ] as RelayToFlat; + + const availableAttributesToSelect = [ + { + __typename: "Attribute", + id: "op1", + name: "AttrOption1", + }, + { + __typename: "Attribute", + id: "op2", + name: "AttrOption2", + }, + { + __typename: "Attribute", + id: "op3", + name: "AttrOption3", + }, + ] as RelayToFlat; + + 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], + }); + }); +}); diff --git a/src/products/components/ProductListDatagrid/hooks/useColumnPickerColumns.ts b/src/products/components/ProductListDatagrid/hooks/useColumnPickerColumns.ts new file mode 100644 index 000000000..1a7f5bcd6 --- /dev/null +++ b/src/products/components/ProductListDatagrid/hooks/useColumnPickerColumns.ts @@ -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, + availableInGridAttributes: RelayToFlat< + SearchAvailableInGridAttributesQuery["availableInGrid"] + >, + settings: ListSettings, + 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, + }; +}; diff --git a/src/products/components/ProductListDatagrid/hooks/useDatagridColumns.ts b/src/products/components/ProductListDatagrid/hooks/useDatagridColumns.ts new file mode 100644 index 000000000..4b92c292c --- /dev/null +++ b/src/products/components/ProductListDatagrid/hooks/useDatagridColumns.ts @@ -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; + gridAttributesFromSettings: ProductListColumns[]; + sort: Sort; + settings: ListSettings; +} + +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([ + 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, +) { + return (column: AvailableColumn) => + settings.columns.includes(column.id as ProductListColumns) || + ["empty", "name"].includes(column.id); +} + +function toCurrentColumnData( + sort: Sort, + 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, +) { + return (column: ProductListColumns) => ({ + ...columnSource.find(ac => ac.id === column), + icon: getColumnSortDirectionIcon(sort, column as ProductListUrlSortField), + }); +} diff --git a/src/products/components/ProductListDatagrid/index.ts b/src/products/components/ProductListDatagrid/index.ts new file mode 100644 index 000000000..025710054 --- /dev/null +++ b/src/products/components/ProductListDatagrid/index.ts @@ -0,0 +1 @@ +export * from "./ProductListDatagrid"; diff --git a/src/products/components/ProductList/messages.ts b/src/products/components/ProductListDatagrid/messages.ts similarity index 60% rename from src/products/components/ProductList/messages.ts rename to src/products/components/ProductListDatagrid/messages.ts index 2fe570276..711c2ca9c 100644 --- a/src/products/components/ProductList/messages.ts +++ b/src/products/components/ProductListDatagrid/messages.ts @@ -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", }, }); diff --git a/src/products/components/ProductListDatagrid/utils.ts b/src/products/components/ProductListDatagrid/utils.ts new file mode 100644 index 000000000..3809e5117 --- /dev/null +++ b/src/products/components/ProductListDatagrid/utils.ts @@ -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; + gridAttributes: RelayToFlat; + 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, + activeAttributeSortId: string, + sort: Sort, +) { + 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, + columnName: ProductListUrlSortField, +) { + if (columnName === sort) { + if (asc) { + return "arrowUp"; + } else { + return "arrowDown"; + } + } + + return undefined; +} + +interface GetCellContentProps { + columns: AvailableColumn[]; + products: RelayToFlat; + intl: IntlShape; + getProductTypes: (query: string) => Promise; + locale: Locale; + gridAttributes: RelayToFlat; + 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[number], + getProductTypes: (query: string) => Promise, +) { + 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[number], + changeValue?: DropdownChoice, +): DropdownChoice { + if (changeValue === null || !rowData) { + return emptyDropdownCellValue; + } + + return { + label: rowData.productType?.name, + value: rowData.productType?.id, + }; +} + +function getAvailabilityCellContent( + rowData: RelayToFlat[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[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[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[number], + locale: Locale, +) { + if (!rowData) { + return readonlyTextCell(""); + } + + return readonlyTextCell( + moment(rowData.updatedAt).locale(locale).format("lll"), + ); +} + +function getAttributeCellContent( + columnId: string, + rowData: RelayToFlat[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, +) { + if (product?.length) { + return product.length; + } + + if (disabled) { + return 1; + } + + return 0; +} diff --git a/src/products/components/ProductListPage/ProductListPage.stories.tsx b/src/products/components/ProductListPage/ProductListPage.stories.tsx index 23e170920..ab3876453 100644 --- a/src/products/components/ProductListPage/ProductListPage.stories.tsx +++ b/src/products/components/ProductListPage/ProductListPage.stories.tsx @@ -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, diff --git a/src/products/components/ProductListPage/ProductListPage.tsx b/src/products/components/ProductListPage/ProductListPage.tsx index 5698d1ce9..e23998338 100644 --- a/src/products/components/ProductListPage/ProductListPage.tsx +++ b/src/products/components/ProductListPage/ProductListPage.tsx @@ -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, @@ -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 = props => { const { columnQuery, currencySymbol, - currentTab, defaultSettings, gridAttributes, limits, @@ -102,75 +84,25 @@ export const ProductListPage: React.FC = 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 = props => { ); const extensionCreateButtonItems = mapToMenuItems(PRODUCT_OVERVIEW_CREATE); + const [storedProductListViewType, setProductListViewType] = + useLocalStorage( + "productListViewType", + DEFAULT_PRODUCT_LIST_VIEW_TYPE, + ); + const isDatagridView = storedProductListViewType === "datagrid"; + return ( - - - - - - - {hasLimits(limits, "productVariants") && ( - + + {hasLimits(limits, "productVariants") && ( + + {intl.formatMessage( + { + id: "Kw0jHS", + defaultMessage: "{count}/{max} SKUs used", + description: "created products counter", + }, + { + count: limits.currentUsage.productVariants, + max: limits.allowedUsage.productVariants, + }, + )} + + )} + - )} + {extensionCreateButtonItems.length > 0 ? ( + + + + ) : ( + + )} + {limitReached && ( = props => { )} - - + + + } + /> + + {isDatagridView ? ( + { + navigate(productUrl(id)); + }} + /> + ) : ( + { + navigate(productUrl(id)); + }} + /> + )} ); diff --git a/src/products/components/ProductListTiles/ProductListTiles.tsx b/src/products/components/ProductListTiles/ProductListTiles.tsx new file mode 100644 index 000000000..b5f1abaed --- /dev/null +++ b/src/products/components/ProductListTiles/ProductListTiles.tsx @@ -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 { + products: RelayToFlat | undefined; + loading?: boolean; + onTileClick: (id: string) => void; +} + +export const ProductListTiles: React.FC = ({ + products, + onTileClick, + settings, + disabled, + loading, + onUpdateListSettings, +}) => { + const intl = useIntl(); + + const renderContent = useCallback(() => { + if (loading) { + return ( + + + + ); + } + + if (products?.length > 0) { + return ( + + {products.map(product => ( + onTileClick(product.id)} + /> + ))} + + ); + } + + return ( + + {intl.formatMessage(messages.emptyText)} + + ); + }, [intl, loading, onTileClick, products]); + + return ( + <> + {renderContent()} + + + + + ); +}; diff --git a/src/products/components/ProductListViewSwitch/ProductListViewSwitch.tsx b/src/products/components/ProductListViewSwitch/ProductListViewSwitch.tsx new file mode 100644 index 000000000..15d5ee109 --- /dev/null +++ b/src/products/components/ProductListViewSwitch/ProductListViewSwitch.tsx @@ -0,0 +1,28 @@ +import { Switch, ViewListIcon, ViewTilesIcon } from "@saleor/macaw-ui/next"; +import React from "react"; + +import { ProductListViewType } from "../ProductListPage"; + +interface ProductListViewSwitchProps { + defaultValue: ProductListViewType; + setProductListViewType: (value: ProductListViewType) => void; +} + +export const ProductListViewSwitch = ({ + defaultValue, + setProductListViewType, +}: ProductListViewSwitchProps) => ( + { + setProductListViewType(value as ProductListViewType); + }} + > + + + + + + + +); diff --git a/src/products/components/ProductListViewSwitch/index.ts b/src/products/components/ProductListViewSwitch/index.ts new file mode 100644 index 000000000..a3d497957 --- /dev/null +++ b/src/products/components/ProductListViewSwitch/index.ts @@ -0,0 +1 @@ +export * from "./ProductListViewSwitch"; diff --git a/src/products/components/ProductTile/ProductTile.test.tsx b/src/products/components/ProductTile/ProductTile.test.tsx new file mode 100644 index 000000000..9f7ccba95 --- /dev/null +++ b/src/products/components/ProductTile/ProductTile.test.tsx @@ -0,0 +1,59 @@ +import { products } from "@dashboard/products/fixtures"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React from "react"; + +import { ProductTile } from "./ProductTile"; + +describe("ProductTile", () => { + const PLACEHOLDER_URL = + "https://master.staging.saleor.cloud/media/thumbnails/products/saleordemoproduct_fd_juice_02_thumbnail_256.png"; + const mockProduct = (withThumbnail: boolean) => + withThumbnail + ? products(PLACEHOLDER_URL)[0] + : { ...products(null)[0], thumbnail: null }; + + it("renders correctly with thumbnail", () => { + // Arrange + const product = mockProduct(true); + + // Act + render(); + + // Assert + const img = screen.getByAltText(product.name); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute("src", PLACEHOLDER_URL); + expect(screen.getByText(product.productType.name)).toBeInTheDocument(); + expect(screen.getByText(product.name)).toBeInTheDocument(); + }); + + it("renders correctly without thumbnail", () => { + // Arrange + const product = mockProduct(false); + + // Act + render(); + + // Assert + expect( + screen.getByTestId(`placeholder-svg-${product.id}`), + ).toBeInTheDocument(); + expect(screen.getByText(product.productType.name)).toBeInTheDocument(); + expect(screen.getByText(product.name)).toBeInTheDocument(); + }); + + it("fires onClick when clicked", async () => { + // Arrange + const product = mockProduct(null); + const onClick = jest.fn(); + const user = userEvent.setup(); + + // Act + render(); + await user.click(screen.getByTestId(`product-tile-${product.id}`)); + + // Assert + expect(onClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/products/components/ProductTile/ProductTile.tsx b/src/products/components/ProductTile/ProductTile.tsx new file mode 100644 index 000000000..34f3af35c --- /dev/null +++ b/src/products/components/ProductTile/ProductTile.tsx @@ -0,0 +1,80 @@ +import { StatusDot } from "@dashboard/components/StatusDot/StatusDot"; +import { ProductListQuery } from "@dashboard/graphql"; +import { RelayToFlat } from "@dashboard/types"; +import { Box, ProductsIcons, sprinkles, Text } from "@saleor/macaw-ui/next"; +import React from "react"; + +import { getTileStatus } from "./utils"; + +export interface ProductTileProps { + product: RelayToFlat[0]; + onClick: () => void; +} + +const commonThumbnailProps = { + borderColor: "neutralHighlight", + borderStyle: "solid", + borderWidth: 1, + marginBottom: 8, + borderRadius: 3, + aspectRatio: "1 / 1", +} as const; + +export const ProductTile: React.FC = ({ + product, + onClick, +}) => ( + + {product.thumbnail ? ( + + ) : ( + + + + + + )} + + {product.productType.name} + + + + {product.name} + + + + + + +); diff --git a/src/products/components/ProductTile/utils.ts b/src/products/components/ProductTile/utils.ts new file mode 100644 index 000000000..3132ff37d --- /dev/null +++ b/src/products/components/ProductTile/utils.ts @@ -0,0 +1,9 @@ +import { StatusDotProps } from "@dashboard/components/StatusDot/StatusDot"; +import { ProductFragment } from "@dashboard/graphql"; + +export const getTileStatus = ( + productChannelListings: ProductFragment["channelListings"], +): StatusDotProps["status"] => + productChannelListings.some(channel => channel.isPublished) + ? "default" + : "error"; diff --git a/src/products/components/ProductUpdatePage/form.tsx b/src/products/components/ProductUpdatePage/form.tsx index 5e815a73d..ded0f8de1 100644 --- a/src/products/components/ProductUpdatePage/form.tsx +++ b/src/products/components/ProductUpdatePage/form.tsx @@ -17,7 +17,7 @@ import { DatagridChangeOpts, DatagridChangeStateContext, useDatagridChangeState, -} from "@dashboard/components/Datagrid/useDatagridChange"; +} from "@dashboard/components/Datagrid/hooks/useDatagridChange"; import { useExitFormDialog } from "@dashboard/components/Form/useExitFormDialog"; import { ProductFragment } from "@dashboard/graphql"; import useForm from "@dashboard/hooks/useForm"; diff --git a/src/products/components/ProductUpdatePage/types.ts b/src/products/components/ProductUpdatePage/types.ts index ae2add376..d5e947a14 100644 --- a/src/products/components/ProductUpdatePage/types.ts +++ b/src/products/components/ProductUpdatePage/types.ts @@ -4,7 +4,7 @@ import { ChannelOpts } from "@dashboard/components/ChannelsAvailabilityCard/type import { DatagridChangeOpts, UseDatagridChangeState, -} from "@dashboard/components/Datagrid/useDatagridChange"; +} from "@dashboard/components/Datagrid/hooks/useDatagridChange"; import { MetadataFormData } from "@dashboard/components/Metadata"; import { MultiAutocompleteChoiceType } from "@dashboard/components/MultiAutocompleteSelectField"; import { SingleAutocompleteChoiceType } from "@dashboard/components/SingleAutocompleteSelectField"; diff --git a/src/products/components/ProductUpdatePage/utils.ts b/src/products/components/ProductUpdatePage/utils.ts index b657d75be..fdcd0c6ba 100644 --- a/src/products/components/ProductUpdatePage/utils.ts +++ b/src/products/components/ProductUpdatePage/utils.ts @@ -1,7 +1,7 @@ import { DatagridChange, DatagridChangeOpts, -} from "@dashboard/components/Datagrid/useDatagridChange"; +} from "@dashboard/components/Datagrid/hooks/useDatagridChange"; import { Locale } from "@dashboard/components/Locale"; import { ProductFragment } from "@dashboard/graphql"; diff --git a/src/products/components/ProductVariants/ProductVariants.tsx b/src/products/components/ProductVariants/ProductVariants.tsx index 3c3ad971d..92cd91f50 100644 --- a/src/products/components/ProductVariants/ProductVariants.tsx +++ b/src/products/components/ProductVariants/ProductVariants.tsx @@ -1,8 +1,10 @@ import { ChannelData } from "@dashboard/channels/utils"; +import ColumnPicker from "@dashboard/components/ColumnPicker"; import Datagrid, { GetCellContentOpts, } from "@dashboard/components/Datagrid/Datagrid"; -import { DatagridChangeOpts } from "@dashboard/components/Datagrid/useDatagridChange"; +import { useColumnsDefault } from "@dashboard/components/Datagrid/hooks/useColumnsDefault"; +import { DatagridChangeOpts } from "@dashboard/components/Datagrid/hooks/useDatagridChange"; import { Choice } from "@dashboard/components/SingleSelectField"; import { AttributeInputTypeEnum, @@ -53,7 +55,7 @@ export const ProductVariants: React.FC = ({ const intl = useIntl(); // const limitReached = isLimitReached(limits, "productVariants"); - const columns = React.useMemo( + const variantDefaultColumns = React.useMemo( () => variantAttributes && warehouses && channels ? [ @@ -79,6 +81,17 @@ export const ProductVariants: React.FC = ({ [variantAttributes, warehouses, channels], ); + const { + availableColumnsChoices, + columnChoices, + columns, + defaultColumns, + onColumnMoved, + onColumnResize, + onColumnsChange, + picker, + } = useColumnsDefault(variantDefaultColumns); + const getCellContent = React.useCallback( ([column, row]: Item, opts: GetCellContentOpts) => getData({ @@ -131,6 +144,22 @@ export const ProductVariants: React.FC = ({ )} + onColumnResize={onColumnResize} + onColumnMoved={onColumnMoved} + renderColumnPicker={defaultProps => ( + undefined} + onQueryChange={picker.setQuery} + query={picker.query} + /> + )} title={intl.formatMessage(messages.title)} fullScreenTitle={intl.formatMessage(messages.fullScreenTitle, { name: productName, diff --git a/src/products/components/ProductVariants/utils.tsx b/src/products/components/ProductVariants/utils.tsx index e9a7c66df..da3b4eb6f 100644 --- a/src/products/components/ProductVariants/utils.tsx +++ b/src/products/components/ProductVariants/utils.tsx @@ -5,11 +5,11 @@ import { moneyCell, numberCell, textCell, -} from "@dashboard/components/Datagrid/cells"; -import { emptyDropdownCellValue } from "@dashboard/components/Datagrid/DropdownCell"; -import { numberCellEmptyValue } from "@dashboard/components/Datagrid/NumberCell"; +} from "@dashboard/components/Datagrid/customCells/cells"; +import { emptyDropdownCellValue } from "@dashboard/components/Datagrid/customCells/DropdownCell"; +import { numberCellEmptyValue } from "@dashboard/components/Datagrid/customCells/NumberCell"; +import { DatagridChange } from "@dashboard/components/Datagrid/hooks/useDatagridChange"; import { AvailableColumn } from "@dashboard/components/Datagrid/types"; -import { DatagridChange } from "@dashboard/components/Datagrid/useDatagridChange"; import { Choice } from "@dashboard/components/SingleSelectField"; import { ProductDetailsVariantFragment, diff --git a/src/products/fixtures.ts b/src/products/fixtures.ts index 2a1bee218..d5227432a 100644 --- a/src/products/fixtures.ts +++ b/src/products/fixtures.ts @@ -1010,6 +1010,8 @@ export const products = ( ): RelayToFlat => [ { __typename: "Product", + description: + "Fell straight from the tree, on to Newton's head, then into the bottle. The autumn taste of English apples. Brought to you by gravity.", updatedAt: "2020-06-22T13:52:05.094636+00:00", attributes: [], channelListings: [ @@ -1103,6 +1105,8 @@ export const products = ( __typename: "Product", updatedAt: "2020-06-22T13:52:05.094636+00:00", attributes: [], + description: + "Fell straight from the tree, on to Newton's head, then into the bottle. The autumn taste of English apples. Brought to you by gravity.", channelListings: [ { __typename: "ProductChannelListing", @@ -1194,6 +1198,8 @@ export const products = ( __typename: "Product", updatedAt: "2020-06-22T13:52:05.094636+00:00", attributes: [], + description: + "Fell straight from the tree, on to Newton's head, then into the bottle. The autumn taste of English apples. Brought to you by gravity.", channelListings: [ { __typename: "ProductChannelListing", @@ -1284,6 +1290,8 @@ export const products = ( { __typename: "Product", updatedAt: "2020-06-22T13:52:05.094636+00:00", + description: + "Fell straight from the tree, on to Newton's head, then into the bottle. The autumn taste of English apples. Brought to you by gravity.", attributes: [ { __typename: "SelectedAttribute", @@ -1397,6 +1405,8 @@ export const products = ( { __typename: "Product", updatedAt: "2020-06-22T13:52:05.094636+00:00", + description: + "Fell straight from the tree, on to Newton's head, then into the bottle. The autumn taste of English apples. Brought to you by gravity.", attributes: [ { __typename: "SelectedAttribute", @@ -1510,6 +1520,8 @@ export const products = ( { __typename: "Product", updatedAt: "2020-06-22T13:52:05.094636+00:00", + description: + "Fell straight from the tree, on to Newton's head, then into the bottle. The autumn taste of English apples. Brought to you by gravity.", attributes: [ { __typename: "SelectedAttribute", @@ -1624,6 +1636,8 @@ export const products = ( { __typename: "Product", updatedAt: "2020-06-22T13:52:05.094636+00:00", + description: + "Fell straight from the tree, on to Newton's head, then into the bottle. The autumn taste of English apples. Brought to you by gravity.", attributes: [ { __typename: "SelectedAttribute", @@ -1737,6 +1751,8 @@ export const products = ( { __typename: "Product", updatedAt: "2020-06-22T13:52:05.094636+00:00", + description: + "Fell straight from the tree, on to Newton's head, then into the bottle. The autumn taste of English apples. Brought to you by gravity.", attributes: [ { __typename: "SelectedAttribute", @@ -1850,6 +1866,8 @@ export const products = ( { __typename: "Product", updatedAt: "2020-06-22T13:52:05.094636+00:00", + description: + "Fell straight from the tree, on to Newton's head, then into the bottle. The autumn taste of English apples. Brought to you by gravity.", attributes: [ { __typename: "SelectedAttribute", @@ -1962,6 +1980,8 @@ export const products = ( }, { __typename: "Product", + description: + "Fell straight from the tree, on to Newton's head, then into the bottle. The autumn taste of English apples. Brought to you by gravity.", updatedAt: "2020-06-22T13:52:05.094636+00:00", attributes: [ { @@ -2076,6 +2096,8 @@ export const products = ( { __typename: "Product", updatedAt: "2020-06-22T13:52:05.094636+00:00", + description: + "Fell straight from the tree, on to Newton's head, then into the bottle. The autumn taste of English apples. Brought to you by gravity.", attributes: [ { __typename: "SelectedAttribute", @@ -2189,6 +2211,8 @@ export const products = ( { __typename: "Product", updatedAt: "2020-06-22T13:52:05.094636+00:00", + description: + "Fell straight from the tree, on to Newton's head, then into the bottle. The autumn taste of English apples. Brought to you by gravity.", attributes: [ { __typename: "SelectedAttribute", @@ -2302,6 +2326,8 @@ export const products = ( { __typename: "Product", updatedAt: "2020-06-22T13:52:05.094636+00:00", + description: + "Fell straight from the tree, on to Newton's head, then into the bottle. The autumn taste of English apples. Brought to you by gravity.", attributes: [ { __typename: "SelectedAttribute", @@ -2415,6 +2441,8 @@ export const products = ( { __typename: "Product", updatedAt: "2020-06-22T13:52:05.094636+00:00", + description: + "Fell straight from the tree, on to Newton's head, then into the bottle. The autumn taste of English apples. Brought to you by gravity.", attributes: [ { __typename: "SelectedAttribute", @@ -2528,6 +2556,8 @@ export const products = ( { __typename: "Product", updatedAt: "2020-06-22T13:52:05.094636+00:00", + description: + "Fell straight from the tree, on to Newton's head, then into the bottle. The autumn taste of English apples. Brought to you by gravity.", attributes: [ { __typename: "SelectedAttribute", @@ -2641,6 +2671,8 @@ export const products = ( { __typename: "Product", updatedAt: "2020-06-22T13:52:05.094636+00:00", + description: + "Fell straight from the tree, on to Newton's head, then into the bottle. The autumn taste of English apples. Brought to you by gravity.", attributes: [ { __typename: "SelectedAttribute", @@ -2754,6 +2786,8 @@ export const products = ( { __typename: "Product", updatedAt: "2020-06-22T13:52:05.094636+00:00", + description: + "Fell straight from the tree, on to Newton's head, then into the bottle. The autumn taste of English apples. Brought to you by gravity.", attributes: [ { __typename: "SelectedAttribute", @@ -2867,6 +2901,8 @@ export const products = ( { __typename: "Product", updatedAt: "2020-06-22T13:52:05.094636+00:00", + description: + "Fell straight from the tree, on to Newton's head, then into the bottle. The autumn taste of English apples. Brought to you by gravity.", attributes: [ { __typename: "SelectedAttribute", @@ -2980,6 +3016,8 @@ export const products = ( { __typename: "Product", updatedAt: "2020-06-22T13:52:05.094636+00:00", + description: + "Fell straight from the tree, on to Newton's head, then into the bottle. The autumn taste of English apples. Brought to you by gravity.", attributes: [ { __typename: "SelectedAttribute", @@ -3093,6 +3131,8 @@ export const products = ( { __typename: "Product", updatedAt: "2020-06-22T13:52:05.094636+00:00", + description: + "Fell straight from the tree, on to Newton's head, then into the bottle. The autumn taste of English apples. Brought to you by gravity.", attributes: [ { __typename: "SelectedAttribute", diff --git a/src/products/queries.ts b/src/products/queries.ts index 450fc8bd0..aad6a8f3b 100644 --- a/src/products/queries.ts +++ b/src/products/queries.ts @@ -79,6 +79,7 @@ export const productListQuery = gql` node { ...ProductWithChannelListings updatedAt + description attributes @include(if: $hasSelectedAttributes) { ...ProductListAttribute } diff --git a/src/products/urls.ts b/src/products/urls.ts index ea974cb58..6667d00ca 100644 --- a/src/products/urls.ts +++ b/src/products/urls.ts @@ -49,21 +49,20 @@ export const ProductListUrlFiltersAsDictWithMultipleValues = { numericAttributes: "numeric-attributes", stringAttributes: "string-attributes", } as const; -export type ProductListUrlFiltersAsDictWithMultipleValues = typeof ProductListUrlFiltersAsDictWithMultipleValues[keyof typeof ProductListUrlFiltersAsDictWithMultipleValues]; +export type ProductListUrlFiltersAsDictWithMultipleValues = + (typeof ProductListUrlFiltersAsDictWithMultipleValues)[keyof typeof ProductListUrlFiltersAsDictWithMultipleValues]; export enum ProductListUrlFiltersWithKeyValueValues { metadata = "metadata", } export type ProductListUrlFilters = Filters & FiltersWithMultipleValues & FiltersWithKeyValueValues & - FiltersAsDictWithMultipleValues< - ProductListUrlFiltersAsDictWithMultipleValues - >; + FiltersAsDictWithMultipleValues; export enum ProductListUrlSortField { attribute = "attribute", name = "name", productType = "productType", - status = "status", + availability = "availability", price = "price", rank = "rank", date = "date", @@ -102,10 +101,8 @@ export const productUrl = (id: string, params?: ProductUrlQueryParams) => export const productVariantEditPath = (productId: string, variantId: string) => urlJoin(productSection, productId, "variant", variantId); export type ProductVariantEditUrlDialog = "remove" | "assign-attribute-value"; -export type ProductVariantEditUrlQueryParams = Dialog< - ProductVariantEditUrlDialog -> & - SingleAction; +export type ProductVariantEditUrlQueryParams = + Dialog & SingleAction; export const productVariantEditUrl = ( productId: string, variantId: string, @@ -119,10 +116,8 @@ export const productVariantEditUrl = ( stringifyQs(params); export type ProductVariantAddUrlDialog = "assign-attribute-value"; -export type ProductVariantAddUrlQueryParams = Dialog< - ProductVariantAddUrlDialog -> & - SingleAction; +export type ProductVariantAddUrlQueryParams = + Dialog & SingleAction; export const productVariantAddPath = (productId: string) => urlJoin(productSection, productId, "variant/add"); export const productVariantAddUrl = ( diff --git a/src/products/views/ProductList/sort.ts b/src/products/views/ProductList/sort.ts index 0fbe42cec..2bc9630dd 100644 --- a/src/products/views/ProductList/sort.ts +++ b/src/products/views/ProductList/sort.ts @@ -14,12 +14,12 @@ export function canBeSorted( switch (sort) { case ProductListUrlSortField.name: case ProductListUrlSortField.productType: + case ProductListUrlSortField.date: case ProductListUrlSortField.attribute: case ProductListUrlSortField.rank: - case ProductListUrlSortField.date: return true; case ProductListUrlSortField.price: - case ProductListUrlSortField.status: + case ProductListUrlSortField.availability: return isChannelSelected; default: return false; @@ -36,7 +36,7 @@ export function getSortQueryField( return ProductOrderField.PRICE; case ProductListUrlSortField.productType: return ProductOrderField.TYPE; - case ProductListUrlSortField.status: + case ProductListUrlSortField.availability: return ProductOrderField.PUBLISHED; case ProductListUrlSortField.rank: return ProductOrderField.RANK; @@ -56,6 +56,7 @@ export function getSortQueryVariables( } const direction = getOrderDirection(params.asc); + if (params.sort === ProductListUrlSortField.attribute) { return { attributeId: params.attributeId, diff --git a/src/products/views/ProductUpdate/handlers/data/attributes.test.ts b/src/products/views/ProductUpdate/handlers/data/attributes.test.ts index 8da15ec60..f2f23bd7f 100644 --- a/src/products/views/ProductUpdate/handlers/data/attributes.test.ts +++ b/src/products/views/ProductUpdate/handlers/data/attributes.test.ts @@ -1,4 +1,4 @@ -import { DatagridChange } from "@dashboard/components/Datagrid/useDatagridChange"; +import { DatagridChange } from "@dashboard/components/Datagrid/hooks/useDatagridChange"; import { getAttributeData } from "./attributes"; diff --git a/src/products/views/ProductUpdate/handlers/data/attributes.ts b/src/products/views/ProductUpdate/handlers/data/attributes.ts index c7d5b4083..c58e5d9a2 100644 --- a/src/products/views/ProductUpdate/handlers/data/attributes.ts +++ b/src/products/views/ProductUpdate/handlers/data/attributes.ts @@ -1,4 +1,4 @@ -import { DatagridChange } from "@dashboard/components/Datagrid/useDatagridChange"; +import { DatagridChange } from "@dashboard/components/Datagrid/hooks/useDatagridChange"; import { getColumnAttribute, isCurrentRow, diff --git a/src/products/views/ProductUpdate/handlers/data/channel.test.ts b/src/products/views/ProductUpdate/handlers/data/channel.test.ts index 24b2d8e8a..0f9077d7a 100644 --- a/src/products/views/ProductUpdate/handlers/data/channel.test.ts +++ b/src/products/views/ProductUpdate/handlers/data/channel.test.ts @@ -1,4 +1,4 @@ -import { DatagridChangeOpts } from "@dashboard/components/Datagrid/useDatagridChange"; +import { DatagridChangeOpts } from "@dashboard/components/Datagrid/hooks/useDatagridChange"; import { ProductFragment } from "@dashboard/graphql"; import { diff --git a/src/products/views/ProductUpdate/handlers/data/channel.ts b/src/products/views/ProductUpdate/handlers/data/channel.ts index 13bdb9f86..6262d707f 100644 --- a/src/products/views/ProductUpdate/handlers/data/channel.ts +++ b/src/products/views/ProductUpdate/handlers/data/channel.ts @@ -1,7 +1,7 @@ import { DatagridChange, DatagridChangeOpts, -} from "@dashboard/components/Datagrid/useDatagridChange"; +} from "@dashboard/components/Datagrid/hooks/useDatagridChange"; import { ProductFragment, ProductVariantChannelListingAddInput, diff --git a/src/products/views/ProductUpdate/handlers/data/name.test.ts b/src/products/views/ProductUpdate/handlers/data/name.test.ts index c1bfa97b6..177c26387 100644 --- a/src/products/views/ProductUpdate/handlers/data/name.test.ts +++ b/src/products/views/ProductUpdate/handlers/data/name.test.ts @@ -1,4 +1,4 @@ -import { DatagridChange } from "@dashboard/components/Datagrid/useDatagridChange"; +import { DatagridChange } from "@dashboard/components/Datagrid/hooks/useDatagridChange"; import { getNameData } from "./name"; diff --git a/src/products/views/ProductUpdate/handlers/data/name.ts b/src/products/views/ProductUpdate/handlers/data/name.ts index 91b3dad06..bcf2895f8 100644 --- a/src/products/views/ProductUpdate/handlers/data/name.ts +++ b/src/products/views/ProductUpdate/handlers/data/name.ts @@ -1,4 +1,4 @@ -import { DatagridChange } from "@dashboard/components/Datagrid/useDatagridChange"; +import { DatagridChange } from "@dashboard/components/Datagrid/hooks/useDatagridChange"; import { isCurrentRow } from "@dashboard/products/utils/datagrid"; export function getNameData( diff --git a/src/products/views/ProductUpdate/handlers/data/sku.test.ts b/src/products/views/ProductUpdate/handlers/data/sku.test.ts index 5c4973a57..b8d45b000 100644 --- a/src/products/views/ProductUpdate/handlers/data/sku.test.ts +++ b/src/products/views/ProductUpdate/handlers/data/sku.test.ts @@ -1,4 +1,4 @@ -import { DatagridChange } from "@dashboard/components/Datagrid/useDatagridChange"; +import { DatagridChange } from "@dashboard/components/Datagrid/hooks/useDatagridChange"; import { getSkuData } from "./sku"; diff --git a/src/products/views/ProductUpdate/handlers/data/sku.ts b/src/products/views/ProductUpdate/handlers/data/sku.ts index 2aa9d9fbf..969d5796c 100644 --- a/src/products/views/ProductUpdate/handlers/data/sku.ts +++ b/src/products/views/ProductUpdate/handlers/data/sku.ts @@ -1,4 +1,4 @@ -import { DatagridChange } from "@dashboard/components/Datagrid/useDatagridChange"; +import { DatagridChange } from "@dashboard/components/Datagrid/hooks/useDatagridChange"; import { isCurrentRow } from "@dashboard/products/utils/datagrid"; export function getSkuData( diff --git a/src/products/views/ProductUpdate/handlers/data/stock.ts b/src/products/views/ProductUpdate/handlers/data/stock.ts index bc406b4c6..cf00f4ebd 100644 --- a/src/products/views/ProductUpdate/handlers/data/stock.ts +++ b/src/products/views/ProductUpdate/handlers/data/stock.ts @@ -1,5 +1,5 @@ -import { numberCellEmptyValue } from "@dashboard/components/Datagrid/NumberCell"; -import { DatagridChange } from "@dashboard/components/Datagrid/useDatagridChange"; +import { numberCellEmptyValue } from "@dashboard/components/Datagrid/customCells/NumberCell"; +import { DatagridChange } from "@dashboard/components/Datagrid/hooks/useDatagridChange"; import { ProductFragment, ProductVariantStocksUpdateInput, diff --git a/src/products/views/ProductUpdate/handlers/data/stocks.test.ts b/src/products/views/ProductUpdate/handlers/data/stocks.test.ts index e42913aa4..c1ddb8935 100644 --- a/src/products/views/ProductUpdate/handlers/data/stocks.test.ts +++ b/src/products/views/ProductUpdate/handlers/data/stocks.test.ts @@ -1,5 +1,5 @@ -import { numberCellEmptyValue } from "@dashboard/components/Datagrid/NumberCell"; -import { DatagridChange } from "@dashboard/components/Datagrid/useDatagridChange"; +import { numberCellEmptyValue } from "@dashboard/components/Datagrid/customCells/NumberCell"; +import { DatagridChange } from "@dashboard/components/Datagrid/hooks/useDatagridChange"; import { ProductFragment } from "@dashboard/graphql"; import { getStockData, getVaraintUpdateStockData } from "./stock"; diff --git a/src/products/views/ProductUpdate/handlers/utils.test.ts b/src/products/views/ProductUpdate/handlers/utils.test.ts index e0f389bc7..da84fd128 100644 --- a/src/products/views/ProductUpdate/handlers/utils.test.ts +++ b/src/products/views/ProductUpdate/handlers/utils.test.ts @@ -1,4 +1,4 @@ -import { DatagridChangeOpts } from "@dashboard/components/Datagrid/useDatagridChange"; +import { DatagridChangeOpts } from "@dashboard/components/Datagrid/hooks/useDatagridChange"; import { ProductFragment } from "@dashboard/graphql"; import { ProductUpdateSubmitData } from "@dashboard/products/components/ProductUpdatePage/types"; diff --git a/src/products/views/ProductUpdate/handlers/utils.ts b/src/products/views/ProductUpdate/handlers/utils.ts index 5e252f254..dd4899c18 100644 --- a/src/products/views/ProductUpdate/handlers/utils.ts +++ b/src/products/views/ProductUpdate/handlers/utils.ts @@ -1,7 +1,7 @@ import { FetchResult } from "@apollo/client"; import { getAttributesAfterFileAttributesUpdate } from "@dashboard/attributes/utils/data"; import { prepareAttributesInput } from "@dashboard/attributes/utils/handlers"; -import { DatagridChangeOpts } from "@dashboard/components/Datagrid/useDatagridChange"; +import { DatagridChangeOpts } from "@dashboard/components/Datagrid/hooks/useDatagridChange"; import { VALUES_PAGINATE_BY } from "@dashboard/config"; import { FileUploadMutation, diff --git a/src/searches/useProductTypeSearch.ts b/src/searches/useProductTypeSearch.ts index 10b2ecd82..f37d4257e 100644 --- a/src/searches/useProductTypeSearch.ts +++ b/src/searches/useProductTypeSearch.ts @@ -1,10 +1,11 @@ -import { gql } from "@apollo/client"; +import { gql, useApolloClient } from "@apollo/client"; import { SearchProductTypesDocument, SearchProductTypesQuery, SearchProductTypesQueryVariables, } from "@dashboard/graphql"; import makeTopLevelSearch from "@dashboard/hooks/makeTopLevelSearch"; +import { mapEdgesToItems } from "@dashboard/utils/maps"; export const searchProductTypes = gql` query SearchProductTypes($after: String, $first: Int!, $query: String!) { @@ -26,6 +27,26 @@ export const searchProductTypes = gql` } `; +export function useSearchProductTypes() { + const client = useApolloClient(); + + return (query: string) => + client + .query({ + query: SearchProductTypesDocument, + variables: { + first: 10, + query, + }, + }) + .then(({ data }) => + mapEdgesToItems(data.search).map(({ name, id }) => ({ + label: name, + value: id, + })), + ); +} + export default makeTopLevelSearch< SearchProductTypesQuery, SearchProductTypesQueryVariables diff --git a/src/utils/columns/getColumnSortDirectionIcon.ts b/src/utils/columns/getColumnSortDirectionIcon.ts new file mode 100644 index 000000000..8e59fcf30 --- /dev/null +++ b/src/utils/columns/getColumnSortDirectionIcon.ts @@ -0,0 +1,16 @@ +import { Sort } from "@dashboard/types"; + +export function getColumnSortDirectionIcon( + { sort, asc }: Sort, + columnName: T, +) { + if (columnName === sort) { + if (asc) { + return "arrowUp"; + } else { + return "arrowDown"; + } + } + + return undefined; +} diff --git a/src/utils/handlers/dialogActionHandlers.ts b/src/utils/handlers/dialogActionHandlers.ts index 86a847cb4..6d91b3543 100644 --- a/src/utils/handlers/dialogActionHandlers.ts +++ b/src/utils/handlers/dialogActionHandlers.ts @@ -5,19 +5,19 @@ type Url> = (params: T) => string; export type OpenModalFunction< TAction extends string, - TParams extends Dialog + TParams extends Dialog, > = (action: TAction, newParams?: TParams) => void; export type CloseModalFunction = () => void; type CreateCloseModal< TAction extends string, - TParams extends Dialog + TParams extends Dialog, > = [OpenModalFunction, CloseModalFunction]; function createDialogActionHandlers< TAction extends string, - TParams extends Dialog & BulkAction & SingleAction + TParams extends Dialog & BulkAction & SingleAction, >( navigate: UseNavigatorResult, url: Url,