🏷️ Rework product list view (#3296)

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

View file

@ -18,6 +18,7 @@ import {
PERMISSION_GROUP_LIST_SELECTORS, PERMISSION_GROUP_LIST_SELECTORS,
PLUGINS_LIST_SELECTORS, PLUGINS_LIST_SELECTORS,
PRODUCT_TYPES_LIST_SELECTORS, PRODUCT_TYPES_LIST_SELECTORS,
PRODUCTS_LIST,
SALES_SELECTORS, SALES_SELECTORS,
SHIPPING_ZONES_LIST_SELECTORS, SHIPPING_ZONES_LIST_SELECTORS,
STAFF_MEMBERS_LIST_SELECTORS, STAFF_MEMBERS_LIST_SELECTORS,
@ -25,6 +26,7 @@ import {
} from "../elements/"; } from "../elements/";
import { PERMISSIONS_OPTIONS } from "../fixtures/permissionsUsers"; import { PERMISSIONS_OPTIONS } from "../fixtures/permissionsUsers";
import { urlList } from "../fixtures/urlList"; import { urlList } from "../fixtures/urlList";
import { ensureCanvasStatic } from "../support/customCommands/sharedElementsOperations/canvas";
import { import {
expectConfigurationAvailableSectionsNumber, expectConfigurationAvailableSectionsNumber,
expectConfigurationSectionsToBeVisible, 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"); cy.get(MENU_SELECTORS.CATALOG.collections).should("be.visible");
expectMainMenuAvailableSections(5); expectMainMenuAvailableSections(5);
}); });
ensureCanvasStatic(PRODUCTS_LIST.dataGridTable).then(() => {
cy.assertCanvasRowsNumber(PRODUCTS_LIST.dataGridTable, 21);
});
}, },
); );

View file

@ -2,10 +2,9 @@
/// <reference types="../../support"/> /// <reference types="../../support"/>
import { PRODUCT_DETAILS } from "../../elements/catalog/products/product-details"; 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 { SHARED_ELEMENTS } from "../../elements/shared/sharedElements";
import { demoProductsNames } from "../../fixtures/products"; import { demoProductsNames } from "../../fixtures/products";
import { productDetailsUrl, urlList } from "../../fixtures/urlList"; import { productDetailsUrl } from "../../fixtures/urlList";
import { getFirstProducts } from "../../support/api/requests/Product"; import { getFirstProducts } from "../../support/api/requests/Product";
import { deleteCollectionsStartsWith } from "../../support/api/utils/catalog/collectionsUtils"; import { deleteCollectionsStartsWith } from "../../support/api/utils/catalog/collectionsUtils";
import { import {
@ -19,39 +18,6 @@ describe("Tests for images", () => {
cy.clearSessionData().loginUserViaRequest(); 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( it(
"Should display product image", "Should display product image",
{ tags: ["@products", "@allEnv", "@stable"] }, { tags: ["@products", "@allEnv", "@stable"] },

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

78
package-lock.json generated
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,7 +3,7 @@ import React from "react";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import { LocaleConsumer } from "../Locale"; import { LocaleConsumer } from "../Locale";
import { formatMoney, formatMoneyRange } from "../Money"; import { getMoneyRange } from "./utils";
export interface MoneyRangeProps { export interface MoneyRangeProps {
from?: IMoney; from?: IMoney;
@ -15,38 +15,7 @@ export const MoneyRange: React.FC<MoneyRangeProps> = ({ from, to }) => {
return ( return (
<LocaleConsumer> <LocaleConsumer>
{({ locale }) => { {({ locale }) => getMoneyRange(locale, intl, from, to)}
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 "-";
}}
</LocaleConsumer> </LocaleConsumer>
); );
}; };

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -37,9 +37,9 @@ export const ProductsCard: React.FC<ProductsCardProps> = ({
return null; return null;
} }
const getHandleAmountChange = (line: OrderLineGrantRefundFragment) => ( const getHandleAmountChange =
e: React.ChangeEvent<HTMLInputElement>, (line: OrderLineGrantRefundFragment) =>
) => { (e: React.ChangeEvent<HTMLInputElement>) => {
const parsedValue = parseInt(e.target.value, 10); const parsedValue = parseInt(e.target.value, 10);
const value = Number.isNaN(parsedValue) ? 0 : parsedValue; const value = Number.isNaN(parsedValue) ? 0 : parsedValue;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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