New datagrid view for product variant management (#2162)

* New datagrid view for product variant management (#2040)

* Split simple and product with variants updates

* Simplify code

* Change selectedVariantsIds to availableVariants

selectedVariantsIds suggested that these variants *were just* selected

* Add datagrid component

Add datagrid component

Add actions column

Add column picker

Add ts-ignore comment

Remove unused import

Fix after rebase

Add story

Fix column picking

Fix column dragging

Add column resizing

Extract components to own files

Apply styles from class

Improve data storage

Extract state to hook

Expose handlers

Fix styles

Fix header overflow

Add select all option

Add cell types

Fix types

Add number type cell

Add min to stocks

Fix types

Generate columns from shop data

Add number type editor

Fix tests

Update src/components/Datagrid/Datagrid.tsx

Co-authored-by: Jonatan Witoszek <jonatanwitoszek@gmail.com>

Reconstruct data after column toggling

Minor fixes

Add missing id

Remove unused import

Fix container width

Keep changes in state

Drop onChange usage

Use glide datagrid

* Add column picker

* Memoize callbacks

* Add right toolbar

* Make toolbar as prop

* Clean up code

* Fix styling

* Lint files

* Save variant data in datagrid (#2161)

* Allow rows to be deleted

* Add row adding

* Clean up code

* Improve mutations

* Add stock saving

* Add sku and attribute saving

* Display notification after all mutations

* Fix types

* Save channels in datagrid (#2197)

* wip

* Merge simple and variantable handlers

* Move channel setting to form

* Link availability card to form

* Extract listing management to hook

* Update only touched channels

* Improve channel listing managemend

* Display channel data in datagrid

* Save variant channels

* Save changes in channel listings

* Group columns

* Fix types

* Display touched fields

* Return errors from hook

* Display errors in cells

* Improve naming

* Improve naming

* Use reliable called/loading state

* Update src/components/Datagrid/cells.ts

Co-authored-by: Michał Droń <dron.official@yahoo.com>

* Apply fixes from review

Co-authored-by: Michał Droń <dron.official@yahoo.com>

* Fix type error

* Trigger CI

* Trigger CI

* Save added and deleted variants (#2237)

* Save new variants

* Update channel data

* Remove bulk variant delete dialog

* Do not fetch products from api after save

* Extract types

* Reset errors after api response

* Display error when creating variants

* Fix variant reordering

* Remove commented code

* Fix imports

* Edit attributes in datagrid (#2305)

* Add basic dropdown cell

* Fix clickaway

* Add choice customization

* Add callback to fetch choices

* Add attriute value search

* wip

* Pass getSuggestions cb using props

* Fix row adding

* Fix choice creation

* Add styles to datagrid (#2315)

* Improve cell styling

* Prevent navigation when scrolling horizontally

* Fix text editor styles

* Remove unused variable

* Update whole row after editing cell

* Translate column names

* Improve empty message

* Highlight newly created rows

* Add name editing

* Add disabled context menu to added rows

* Add column groups

* Translate column groups names

* Save edited name

* Fix missing column id error

* Add card to datagrid

* Improve shadow on scroll

* Improve fonts

* Fix toolbar placement

* Update api type definitions

* Fix copypasting from excel

* Fix editor font weight

* Improve dark theme

* Improve cell coloring

* Drop styling from story

* Add docs to datagrid (#2339)

* Add docs

* Add summary

* Datagrid fix bugs (#2341)

* Pass refetch to form to avoid double calls

* Add missing prop

* Remove paddings (#2352)

* remove paddings

* remove paddings

* remove paddings

* remove vertical scroll

* update tests

* styles, currency

* Fix variants tests in datagrid (#2365)

* refactor variant test after adding datagrid

* adding assertion to check if datagrid table is visible

* adding check for datagrid

* fix flaky tests in variants

* fix flaky tests in variants

* small changes

* amedments

* update styles

* refactoring, handle prices

* update snapshots

* messages

* snap

* scrolling handle, css for safari

* snap, random mock

* update overflow

* history back hook

* fix cross-price changing, scroll optimization

* trigger ci

* channel checking when adding new variant

Co-authored-by: Michał Droń <dron.official@yahoo.com>
Co-authored-by: Patryk Andrzejewski <vox3r69@gmail.com>
Co-authored-by: Ewa Czerniak <ewa.czerniak@saleor.io>
This commit is contained in:
Dominik Żegleń 2022-10-18 11:10:15 +02:00 committed by GitHub
parent b9c3f720ff
commit 31e0c11fea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
89 changed files with 14462 additions and 10239 deletions

View file

@ -3,6 +3,9 @@
import faker from "faker";
import { PRODUCT_DETAILS } from "../../elements/catalog/products/product-details";
import { VARIANTS_SELECTORS } from "../../elements/catalog/products/variants-selectors";
import { BUTTON_SELECTORS } from "../../elements/shared/button-selectors";
import { urlList } from "../../fixtures/urlList";
import { ONE_PERMISSION_USERS } from "../../fixtures/users";
import { createChannel } from "../../support/api/requests/Channels";
@ -12,11 +15,7 @@ import {
} from "../../support/api/requests/Product";
import * as productUtils from "../../support/api/utils/products/productsUtils";
import { getProductVariants } from "../../support/api/utils/storeFront/storeFrontProductUtils";
import {
createVariant,
variantsShouldBeVisible,
} from "../../support/pages/catalog/products/VariantsPage";
import { selectChannelInHeader } from "../../support/pages/channelsPage";
import { createVariant } from "../../support/pages/catalog/products/VariantsPage";
describe("As an admin I should be able to create variant", () => {
const startsWith = "CyCreateVariants-";
@ -43,7 +42,7 @@ describe("As an admin I should be able to create variant", () => {
defaultChannel = resp.defaultChannel;
warehouse = resp.warehouse;
createChannel({ isActive: true, name, currencyCode: "USD" });
createChannel({ isActive: true, name, currencyCode: "PLN" });
})
.then(resp => (newChannel = resp));
});
@ -71,6 +70,7 @@ describe("As an admin I should be able to create variant", () => {
})
.then(resp => {
createdProduct = resp;
updateChannelInProduct({
productId: createdProduct.id,
channelId: defaultChannel.id,
@ -79,32 +79,52 @@ describe("As an admin I should be able to create variant", () => {
productId: createdProduct.id,
channelId: newChannel.id,
});
cy.visit(`${urlList.products}${createdProduct.id}`);
cy.visit(`${urlList.products}${createdProduct.id}`)
.waitForProgressBarToNotBeVisible()
.get(PRODUCT_DETAILS.addVariantButton)
.should("exist")
.click()
.get(PRODUCT_DETAILS.dataGridTable)
.should("be.visible")
.get(PRODUCT_DETAILS.firstRowDataGrid)
.click({ force: true })
.type(name)
.get(BUTTON_SELECTORS.confirm)
.click()
.confirmationMessageShouldAppear()
.reload()
.waitForProgressBarToNotBeVisible()
.get(PRODUCT_DETAILS.dataGridTable)
.should("be.visible")
.wait(1000)
.get(BUTTON_SELECTORS.showMoreButton)
.click()
.get(PRODUCT_DETAILS.editVariant)
.click()
.get(VARIANTS_SELECTORS.manageChannels)
.click()
.get(VARIANTS_SELECTORS.allChannels)
.check()
.get(BUTTON_SELECTORS.submit)
.click();
createVariant({
channelName: [defaultChannel.name, newChannel.name],
sku: name,
price,
attributeName: attributeValues[0],
});
selectChannelInHeader(defaultChannel.name);
variantsShouldBeVisible({ name, price });
getProductVariants(createdProduct.id, defaultChannel.slug);
})
.then(([variant]) => {
expect(variant).to.have.property("name", attributeValues[0]);
expect(variant).to.have.property("price", price);
selectChannelInHeader(newChannel.name);
variantsShouldBeVisible({ name, price });
getProductVariants(createdProduct.id, defaultChannel.slug);
})
.then(([variant]) => {
expect(variant).to.have.property("name", attributeValues[0]);
expect(variant).to.have.property("name", name);
expect(variant).to.have.property("price", price);
expect(variant).to.have.property("currency", "USD");
getProductVariants(createdProduct.id, newChannel.slug);
})
.then(([variant]) => {
expect(variant).to.have.property("name", attributeValues[0]);
expect(variant).to.have.property("name", name);
expect(variant).to.have.property("price", price);
expect(variant).to.have.property("currency", "PLN");
});
},
);
@ -130,26 +150,34 @@ describe("As an admin I should be able to create variant", () => {
})
.then(({ product: productResp }) => {
createdProduct = productResp;
cy.visit(`${urlList.products}${createdProduct.id}`);
cy.visit(`${urlList.products}${createdProduct.id}`)
.get(BUTTON_SELECTORS.showMoreButton)
.click()
.get(PRODUCT_DETAILS.editVariant)
.click()
.get(PRODUCT_DETAILS.addVariantButton)
.click()
.then(() => {
createVariant({
sku: secondVariantSku,
attributeName: variants[1].name,
price: variants[1].price,
channelName: defaultChannel.name,
});
})
.then(() => {
selectChannelInHeader(defaultChannel.name);
variantsShouldBeVisible({
name: variants[1].name,
price: variants[1].price,
});
getProductVariants(createdProduct.id, defaultChannel.slug);
})
.then(([firstVariant, secondVariant]) => {
expect(firstVariant).to.have.property("price", variants[0].price);
expect(secondVariant).to.have.property("name", variants[1].name);
expect(secondVariant).to.have.property("price", variants[1].price);
expect(firstVariant).to.have.property("name", "value");
expect(firstVariant).to.have.property("currency", "USD");
expect(secondVariant).to.have.property("name", "value2");
expect(secondVariant).to.have.property(
"price",
variants[1].price,
);
expect(secondVariant).to.have.property("currency", "USD");
});
});
},
);

View file

@ -25,4 +25,7 @@ export const PRODUCT_DETAILS = {
uploadSavedImagesButton: '[data-test-id="upload-images"]',
uploadMediaUrlButton: '[data-test-id="upload-media-url"]',
saveUploadUrlButton: '[data-test-id="upload-url-button"]',
editVariant: '[id="menu-list-grow"]',
firstRowDataGrid: "[data-testid='glide-cell-1-0']",
dataGridTable: "[data-testid='data-grid-canvas']",
};

View file

@ -17,4 +17,6 @@ export const VARIANTS_SELECTORS = {
globalThresholdInput: "[name='globalThreshold']",
stockInput: "[data-test-id='stock-input']",
selectOption: "[data-test-id='multi-autocomplete-select-option']",
manageChannels: "[data-testid='manage-channels-button']",
allChannels: "[name='allChannels']",
};

View file

@ -25,6 +25,7 @@ export const getProductVariants = (productId, channelSlug) => {
id: element.id,
name: element.name,
price: element.pricing.price.gross.amount,
currency: element.pricing.price.gross.currency,
}));
});
};

View file

@ -19,7 +19,6 @@ export function createVariant({
costPrice = price,
quantity = 10,
}) {
cy.get(PRODUCT_DETAILS.addVariantButton).click();
fillUpVariantDetails({
attributeName,
sku,

View file

@ -7339,6 +7339,26 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "slugs",
"description": null,
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "channel",
"description": "Specifies the channel by which the data should be filtered. \n\nDEPRECATED: this field will be removed in Saleor 4.0. Use root-level channel argument instead.",
@ -9110,13 +9130,9 @@
"name": "id",
"description": "ID of the selected attribute.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
@ -11456,6 +11472,26 @@
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "slugs",
"description": null,
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"interfaces": null,
@ -14105,6 +14141,22 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "taxExemption",
"description": "Returns True if checkout has to be exempt from taxes.\n\nAdded in Saleor 3.8.\n\nNote: this API is currently in Feature Preview and can be subject to changes at later point.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "token",
"description": "The checkout's token.",
@ -16202,6 +16254,26 @@
"defaultValue": "false",
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "metadata",
"description": "Fields required to update the object's metadata.\n\nAdded in Saleor 3.8.",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "MetadataInput",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"interfaces": null,
@ -18661,6 +18733,26 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "slugs",
"description": null,
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "channel",
"description": "Specifies the channel by which the data should be filtered. \n\nDEPRECATED: this field will be removed in Saleor 4.0. Use root-level channel argument instead.",
@ -40554,6 +40646,26 @@
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "slugs",
"description": null,
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"interfaces": null,
@ -43211,6 +43323,51 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "taxExemptionManage",
"description": "Exempt checkout or order from charging the taxes. When tax exemption is enabled, taxes won't be charged for the checkout or order. Taxes may still be calculated in cases when product prices are entered with the tax included and the net price needs to be known.\n\nAdded in Saleor 3.8.\n\nNote: this API is currently in Feature Preview and can be subject to changes at later point. \n\nRequires one of the following permissions: MANAGE_TAXES.",
"args": [
{
"name": "id",
"description": "ID of the Checkout or Order object.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "taxExemption",
"description": "Determines if a taxes should be exempt.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "OBJECT",
"name": "TaxExemptionManage",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "staffNotificationRecipientCreate",
"description": "Creates a new staff notification recipient. \n\nRequires one of the following permissions: MANAGE_SETTINGS.",
@ -50833,6 +50990,26 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "metadata",
"description": "Fields required to update the checkout metadata.\n\nAdded in Saleor 3.8.",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "MetadataInput",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "paymentData",
"description": "Client-side generated data required to finalize the payment.",
@ -51776,6 +51953,46 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "metadata",
"description": "Fields required to update the checkout metadata.\n\nAdded in Saleor 3.8.",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "MetadataInput",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "privateMetadata",
"description": "Fields required to update the checkout private metadata.\n\nAdded in Saleor 3.8.",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "MetadataInput",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "removeCheckout",
"description": "Determines if checkout should be removed after creating an order. Default true.",
@ -55925,6 +56142,22 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "taxExemption",
"description": "Returns True if order has to be exempt from taxes.\n\nAdded in Saleor 3.8.\n\nNote: this API is currently in Feature Preview and can be subject to changes at later point.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "transactions",
"description": "List of transactions for the order. Requires one of the following permissions: MANAGE_ORDERS, HANDLE_PAYMENTS.\n\nAdded in Saleor 3.4.\n\nNote: this API is currently in Feature Preview and can be subject to changes at later point.",
@ -63961,6 +64194,26 @@
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "slugs",
"description": null,
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"interfaces": null,
@ -65607,6 +65860,26 @@
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "slugs",
"description": null,
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"interfaces": null,
@ -68400,6 +68673,30 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "MANAGE_CHECKOUTS",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "HANDLE_CHECKOUTS",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "HANDLE_TAXES",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "MANAGE_TAXES",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "MANAGE_CHANNELS",
"description": null,
@ -68483,24 +68780,6 @@
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "MANAGE_CHECKOUTS",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "HANDLE_CHECKOUTS",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "HANDLE_TAXES",
"description": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
@ -73206,6 +73485,26 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "slugs",
"description": null,
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "channel",
"description": "Specifies the channel by which the data should be filtered. \n\nDEPRECATED: this field will be removed in Saleor 4.0. Use root-level channel argument instead.",
@ -75847,6 +76146,26 @@
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "slugs",
"description": null,
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"interfaces": null,
@ -81858,7 +82177,7 @@
},
{
"name": "filter",
"description": "Filtering options for menus.",
"description": "Filtering options for menus. \n\n`slug`: This field will be removed in Saleor 4.0. Use `slugs` instead.",
"type": {
"kind": "INPUT_OBJECT",
"name": "MenuFilterInput",
@ -94219,6 +94538,139 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "TaxExemptionManage",
"description": "Exempt checkout or order from charging the taxes. When tax exemption is enabled, taxes won't be charged for the checkout or order. Taxes may still be calculated in cases when product prices are entered with the tax included and the net price needs to be known.\n\nAdded in Saleor 3.8.\n\nNote: this API is currently in Feature Preview and can be subject to changes at later point. \n\nRequires one of the following permissions: MANAGE_TAXES.",
"fields": [
{
"name": "taxableObject",
"description": null,
"args": [],
"type": {
"kind": "UNION",
"name": "TaxSourceObject",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "errors",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "TaxExemptionManageError",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "TaxExemptionManageError",
"description": null,
"fields": [
{
"name": "field",
"description": "Name of a field that caused the error. A value of `null` indicates that the error isn't associated with a particular field.",
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "message",
"description": "The error message.",
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "code",
"description": "The error code.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "TaxExemptionManageErrorCode",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "TaxExemptionManageErrorCode",
"description": "An enumeration.",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "GRAPHQL_ERROR",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "INVALID",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "NOT_FOUND",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "NOT_EDITABLE_ORDER",
"description": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
},
{
"kind": "UNION",
"name": "TaxSourceLine",
@ -103027,6 +103479,26 @@
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "slugs",
"description": null,
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"interfaces": null,

View file

@ -443,6 +443,10 @@
"context": "order draft total price",
"string": "Total"
},
"1WbTJ5": {
"context": "product variants, title",
"string": "Variants"
},
"1div9r": {
"string": "Search Attribute"
},
@ -453,10 +457,6 @@
"1gzck6": {
"string": "{firstName} {lastName}"
},
"1kdQdO": {
"context": "section header",
"string": "Variants"
},
"1lk/oS": {
"context": "button",
"string": "Add postal code range"
@ -480,10 +480,6 @@
"context": "header",
"string": "Translation Voucher \"{voucherName}\" - {languageCode}"
},
"1w06LC": {
"context": "variants count label",
"string": "{variantsCount} variants"
},
"1wyZpQ": {
"string": "These passwords are too similar"
},
@ -647,10 +643,6 @@
"context": "filter range separator",
"string": "and"
},
"37U5su": {
"context": "all variants label",
"string": "All variants"
},
"38dS1A": {
"context": "code ending with label",
"string": "Code ending with {last4CodeChars}"
@ -681,10 +673,6 @@
"context": "navigator command mode description",
"string": "Search Command"
},
"3VyHbJ": {
"context": "button",
"string": "Create variant"
},
"3Z8972": {
"context": "product",
"string": "Stock quantity"
@ -834,6 +822,10 @@
"4gZl/n": {
"string": "See <emphasis>DEMO STOREFRONT</emphasis>"
},
"4hl9rS": {
"context": "variant price in channel",
"string": "Price"
},
"4k9rMQ": {
"context": "variant attribute checkbox",
"string": "Variant Selection"
@ -1004,10 +996,6 @@
"context": "weight",
"string": "{fromValue} {fromUnit} - {toValue} {toUnit}"
},
"6+sMz4": {
"context": "product variant inventory",
"string": "Unavailable in all locations"
},
"62T585": {
"context": "button",
"string": "{languageName} - {languageCode}"
@ -1191,10 +1179,6 @@
"7l5Bh9": {
"string": "{counter,plural,one{Are you sure you want to delete this product?} other{Are you sure you want to delete {displayQuantity} products?}}"
},
"7mK2vs": {
"context": "product variant inventory",
"string": "Unavailable"
},
"7nKXni": {
"context": "option description",
"string": "This product will act as a payment method"
@ -1223,10 +1207,6 @@
"context": "section header",
"string": "User Status"
},
"80FeaT": {
"context": "product variant preorder threshold",
"string": "{globalThreshold} Global threshold"
},
"86pLaG": {
"context": "WarehouseSettings public stock label",
"string": "Public Stock"
@ -1343,10 +1323,6 @@
"9OtpHt": {
"string": "Order line deleted"
},
"9PmyrU": {
"context": "product variant inventory",
"string": "Not stocked"
},
"9RCuN3": {
"string": "Payment successfully captured"
},
@ -1640,6 +1616,10 @@
"context": "sale status",
"string": "Scheduled"
},
"BbP+k3": {
"context": "variant availability in channel",
"string": "Available"
},
"Be+J13": {
"string": "Configurable"
},
@ -2135,10 +2115,6 @@
"context": "option title",
"string": "Prioritize warehouses by sorting order"
},
"FSinkL": {
"context": "variant stock status",
"string": "Available inventory at:"
},
"FWWliu": {
"context": "ExitFormPrompt title",
"string": "You have unsaved changes"
@ -2552,6 +2528,9 @@
"context": "button",
"string": "Create attribute"
},
"IH47ID": {
"string": "Use button above to add new product variants"
},
"IHtOda": {
"context": "input label",
"string": "Selected columns"
@ -2660,6 +2639,9 @@
"context": "section header",
"string": "Attribute Values"
},
"J4E+jp": {
"string": "Variant name"
},
"J7mFhU": {
"context": "currency code select",
"string": "{code} - {countries}"
@ -2814,10 +2796,6 @@
"context": "product type is digital or physical",
"string": "Type"
},
"JtZ71e": {
"context": "filtering option",
"string": "All Warehouses"
},
"Jwuu4X": {
"context": "select product informations to be exported",
"string": "Information exported:"
@ -3324,10 +3302,6 @@
"context": "header",
"string": "{webhookName} Details"
},
"OTek3r": {
"context": "product variant name",
"string": "Variant"
},
"OVOU1z": {
"context": "section header",
"string": "Metadata"
@ -4045,6 +4019,9 @@
"U2mOqA": {
"string": "No vouchers found"
},
"U5Da30": {
"string": "Warehouses"
},
"U5aVd8": {
"context": "product",
"string": "Shippable"
@ -4156,6 +4133,10 @@
"context": "label",
"string": "Link"
},
"Us9cA1": {
"context": "profit margin",
"string": "Margin"
},
"Utq1fE": {
"context": "customer gift cards card title",
"string": "Gift Cards"
@ -4377,9 +4358,6 @@
"context": "order refund amount",
"string": "Shipment Cost"
},
"WHkx+F": {
"string": "Price cannot be lower than 0"
},
"WMGoqz": {
"context": "used by filter label",
"string": "Used by"
@ -6021,10 +5999,6 @@
"context": "sales section name",
"string": "Sales"
},
"kL3C+K": {
"context": "product variant inventory status",
"string": "Inventory"
},
"kN6SLs": {
"string": "Min Value"
},
@ -6333,10 +6307,6 @@
"n+Gwbu": {
"string": "Discounts"
},
"n02c9W": {
"context": "product variant price",
"string": "Price"
},
"n0RwMK": {
"string": "Define types of products you sell"
},
@ -6559,10 +6529,6 @@
"context": "payment status",
"string": "Payment"
},
"p/EWEZ": {
"context": "channels variants availability dialog title",
"string": "Manage Channels"
},
"p/Fd7s": {
"context": "unassign products from shipping rate and save, button",
"string": "Unassign and save"
@ -6744,10 +6710,6 @@
"context": "dialog header",
"string": "Delete Order Drafts"
},
"qbqMpk": {
"context": "product variant preorder threshold",
"string": "In preorder"
},
"qddy2Z": {
"context": "order history message",
"string": "Invoice was sent to customer by {sentBy}"
@ -6820,9 +6782,6 @@
"context": "Status label when object is unpublished in a channel",
"string": "Unpublished"
},
"rIJbNC": {
"string": "Use variants for products that come in a variety of versions for example different sizes or colors"
},
"rJ3lkW": {
"context": "VariantDetailsChannelsAvailabilityCard item subtitle published",
"string": "Published since {publicationDate}"
@ -7249,10 +7208,6 @@
"context": "order payment",
"string": "Preauthorized amount"
},
"uVssds": {
"context": "product variant inventory",
"string": "{stockQuantity,plural,one{{stockQuantity} available} other{{stockQuantity} available}}"
},
"ubasgL": {
"context": "order history message",
"string": "Order was confirmed"
@ -7533,10 +7488,6 @@
"wWTUrM": {
"string": "No activities found"
},
"wWYYBR": {
"context": "product variant inventory",
"string": "{numLocations,plural,one{{numAvailable} available at {numLocations} location} other{{numAvailable} available at {numLocations} locations}}"
},
"wgA48T": {
"context": "country selection",
"string": "Countries A to Z"
@ -7725,10 +7676,6 @@
"context": "dialog header",
"string": "Unpublish Pages"
},
"yHaQWG": {
"context": "variants selected label",
"string": "{variantsAmount} variants selected"
},
"yHeZRQ": {
"string": "Product Export Failed"
},

134
package-lock.json generated
View file

@ -2584,6 +2584,15 @@
}
}
},
"@glideapps/glide-data-grid": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@glideapps/glide-data-grid/-/glide-data-grid-5.0.0.tgz",
"integrity": "sha512-0uSXKPiPUoQsIJ6MxgCAzVVXsSyo6gBAuiuoQR81TM9JXuzyzHtgcHXamD2j+1TumxWIU1UhXhpDRaLMtlLhMA==",
"requires": {
"canvas-hypertxt": "^0.0.3",
"react-number-format": "^4.4.1"
}
},
"@graphql-codegen/add": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@graphql-codegen/add/-/add-3.1.1.tgz",
@ -5165,6 +5174,21 @@
"integrity": "sha512-LMMJLAXAwk1kVMy7fxTRFK6OdouvoseqJbmVUygJb2EcfuT84nC9OAtvGEL4vsVLUcnzEV400+F9t5OKa77FGQ==",
"requires": {
"@react-editor-js/core": "2.0.6"
},
"dependencies": {
"immer": {
"version": "9.0.14",
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.14.tgz",
"integrity": "sha512-ubBeqQutOSLIFCUBN03jGeOS6a3DoYlSYwYJTa+gSKEZKU5redJIqkIdZ3JVv/4RZpfcXdAWH5zCNLWPRv2WDw=="
},
"redux": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.0.tgz",
"integrity": "sha512-oSBmcKKIuIR4ME29/AeNUnl5L+hvBq7OaJWzaptTQJAntaPvxIJqfnjbaEiCzzaIz+XmVILfqAM3Ob0aXLPfjA==",
"requires": {
"@babel/runtime": "^7.9.2"
}
}
}
},
"@react-editor-js/core": {
@ -10302,6 +10326,11 @@
"integrity": "sha512-OE5UE4+nBOro8Dyvv0lfx+SRtfVIOM9uhKqFmJeUbGriqhhStgp1A0OyBpgy3OUF8AhYCT+PVwPC1gMl2ZcQMA==",
"dev": true
},
"canvas-hypertxt": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/canvas-hypertxt/-/canvas-hypertxt-0.0.3.tgz",
"integrity": "sha512-Wmi0bfM+T6BAwFg3wZ1q3vXmltAcO/zPPfHSblJkOeuO9fwhSDMCIbaBLok71PONoOQHYnsk4CQ/GS1Ai7+OnQ=="
},
"capital-case": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/capital-case/-/capital-case-1.0.4.tgz",
@ -11875,6 +11904,12 @@
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
"dev": true
},
"cssfontparser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/cssfontparser/-/cssfontparser-1.2.1.tgz",
"integrity": "sha512-6tun4LoZnj7VN6YeegOVb67KBX/7JJsqvj+pv3ZA7F878/eN33AbGa5b/S/wXxS/tcp8nc40xRUrsPlxIyNUPg==",
"dev": true
},
"csso": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/csso/-/csso-4.2.0.tgz",
@ -16825,6 +16860,12 @@
"har-schema": "^2.0.0"
}
},
"harmony-reflect": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/harmony-reflect/-/harmony-reflect-1.6.2.tgz",
"integrity": "sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g==",
"dev": true
},
"has": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
@ -17713,6 +17754,15 @@
"postcss": "^7.0.14"
}
},
"identity-obj-proxy": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz",
"integrity": "sha512-00n6YnVHKrinT9t0d9+5yZC6UBNJANpYEQvL2LlX6Ab9lnmxzIRcEmTPuyGScvl1+jKuCICX1Z0Ab1pPKKdikA==",
"dev": true,
"requires": {
"harmony-reflect": "^1.4.6"
}
},
"ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@ -18769,6 +18819,16 @@
}
}
},
"jest-canvas-mock": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/jest-canvas-mock/-/jest-canvas-mock-2.4.0.tgz",
"integrity": "sha512-mmMpZzpmLzn5vepIaHk5HoH3Ka4WykbSoLuG/EKoJd0x0ID/t+INo1l8ByfcUJuDM+RIsL4QDg/gDnBbrj2/IQ==",
"dev": true,
"requires": {
"cssfontparser": "^1.2.1",
"moo-color": "^1.0.2"
}
},
"jest-changed-files": {
"version": "24.9.0",
"resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-24.9.0.tgz",
@ -19377,6 +19437,12 @@
"@jest/types": "^24.9.0"
}
},
"jest-mock-random": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/jest-mock-random/-/jest-mock-random-1.1.1.tgz",
"integrity": "sha512-eLduwHIZeepHCHWTgx6MGr8k4CWxTG39SN76tszzgCjX8GNu6M/05WcXMci73z1GUVqYQAsw6inz6ndgr3Gmqw==",
"dev": true
},
"jest-pnp-resolver": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz",
@ -21803,6 +21869,11 @@
"unquote": "^1.1.0"
}
},
"marked": {
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/marked/-/marked-4.0.17.tgz",
"integrity": "sha512-Wfk0ATOK5iPxM4ptrORkFemqroz0ZDxp5MWfYA7H/F+wO17NRWV5Ypxi6p3g2Xmw2bKeiYOl6oVnLHKxBA0VhA=="
},
"math-random": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/math-random/-/math-random-1.0.4.tgz",
@ -22696,6 +22767,15 @@
"integrity": "sha512-I1mnb5xn4fO80BH9BLcF0yLypy2UKl+Cb01Fu0hJRkJjlCRtxZMWkTdAtDd5ZqCOxtCkhmRwyI57vWT+1iZ67w==",
"dev": true
},
"moo-color": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/moo-color/-/moo-color-1.0.3.tgz",
"integrity": "sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==",
"dev": true,
"requires": {
"color-name": "^1.1.4"
}
},
"morgan": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz",
@ -25476,13 +25556,29 @@
}
},
"react-draggable": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.3.tgz",
"integrity": "sha512-jV4TE59MBuWm7gb6Ns3Q1mxX8Azffb7oTtDtBgFkxRvhDp38YAARmRplrj0+XGkhOJB5XziArX+4HUUABtyZ0w==",
"dev": true,
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.5.tgz",
"integrity": "sha512-OMHzJdyJbYTZo4uQE393fHcqqPYsEtkjfMgvCHr6rejT+Ezn4OZbNyGH50vv+SunC1RMvwOTSWkEODQLzw1M9g==",
"requires": {
"classnames": "^2.2.5",
"prop-types": "^15.6.0"
"clsx": "^1.1.1",
"prop-types": "^15.8.1"
},
"dependencies": {
"prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"requires": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
}
}
},
"react-dropzone": {
@ -25495,6 +25591,14 @@
"prop-types": "^15.7.2"
}
},
"react-easy-swipe": {
"version": "0.0.21",
"resolved": "https://registry.npmjs.org/react-easy-swipe/-/react-easy-swipe-0.0.21.tgz",
"integrity": "sha512-OeR2jAxdoqUMHIn/nS9fgreI5hSpgGoL5ezdal4+oO7YSSgJR8ga+PkYGJrSrJ9MKlPcQjMQXnketrD7WNmNsg==",
"requires": {
"prop-types": "^15.5.8"
}
},
"react-editor-js": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/react-editor-js/-/react-editor-js-2.0.6.tgz",
@ -25714,6 +25818,14 @@
"resolved": "https://registry.npmjs.org/react-moment/-/react-moment-1.1.1.tgz",
"integrity": "sha512-WjwvxBSnmLMRcU33do0KixDB+9vP3e84eCse+rd+HNklAMNWyRgZTDEQlay/qK6lcXFPRuEIASJTpEt6pyK7Ww=="
},
"react-number-format": {
"version": "4.9.4",
"resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-4.9.4.tgz",
"integrity": "sha512-Gq20Z3ugqPLFgeaidnx5on9cNpbQZntPN3QgNAL/WJrNNlQnNznY0LCx7g8xtssmRBw0/hw+SCqw6zAcajooiA==",
"requires": {
"prop-types": "^15.7.2"
}
},
"react-popper": {
"version": "1.3.11",
"resolved": "https://registry.npmjs.org/react-popper/-/react-popper-1.3.11.tgz",
@ -25756,6 +25868,16 @@
"react-popper": "^1.3.7"
}
},
"react-responsive-carousel": {
"version": "3.2.23",
"resolved": "https://registry.npmjs.org/react-responsive-carousel/-/react-responsive-carousel-3.2.23.tgz",
"integrity": "sha512-pqJLsBaKHWJhw/ItODgbVoziR2z4lpcJg+YwmRlSk4rKH32VE633mAtZZ9kDXjy4wFO+pgUZmDKPsPe1fPmHCg==",
"requires": {
"classnames": "^2.2.5",
"prop-types": "^15.5.8",
"react-easy-swipe": "^0.0.21"
}
},
"react-router": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-5.2.0.tgz",

View file

@ -24,6 +24,7 @@
"@editorjs/list": "^1.7.0",
"@editorjs/paragraph": "^2.8.0",
"@editorjs/quote": "^2.4.0",
"@glideapps/glide-data-grid": "^5.0.0",
"@material-ui/core": "^4.11.4",
"@material-ui/icons": "^4.11.2",
"@material-ui/lab": "^4.0.0-alpha.58",
@ -57,10 +58,12 @@
"jwt-decode": "^3.1.2",
"keycode": "^2.2.0",
"lodash": "^4.17.20",
"marked": "^4.0.17",
"moment-timezone": "^0.5.32",
"qs": "^6.9.0",
"react": "^16.12.0",
"react-dom": "^16.9.0",
"react-draggable": "^4.4.5",
"react-dropzone": "^11.2.4",
"react-editor-js": "^2.0.6",
"react-error-boundary": "^1.2.5",
@ -72,6 +75,7 @@
"react-jss": "^10.0.0",
"react-markdown": "^4.3.1",
"react-moment": "^1.0.0",
"react-responsive-carousel": "^3.2.23",
"react-router": "^5.0.1",
"react-router-dom": "^5.0.1",
"react-sortable-hoc": "^1.10.1",
@ -170,9 +174,12 @@
"graphql-request": "^3.7.0",
"html-webpack-plugin": "^3.2.0",
"husky": "^4.3.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^24.8.0",
"jest-canvas-mock": "^2.4.0",
"jest-file": "^1.0.0",
"jest-localstorage-mock": "^2.4.3",
"jest-mock-random": "^1.1.1",
"jscodeshift": "^0.13.0",
"lint-staged": "^10.5.1",
"mocha-junit-reporter": "^2.0.2",
@ -211,6 +218,7 @@
"jest": {
"resetMocks": false,
"setupFiles": [
"jest-canvas-mock",
"jest-localstorage-mock"
],
"transform": {
@ -227,6 +235,7 @@
"node"
],
"moduleNameMapper": {
"\\.(css)$": "identity-obj-proxy",
"@assets(.*)$": "<rootDir>/assets/$1",
"@locale(.*)$": "<rootDir>/locale/$1",
"@saleor(?!.*macaw)(?!.*sdk)(.*)$": "<rootDir>/src/$1",

View file

@ -1423,6 +1423,7 @@ input AttributeFilterInput {
type: AttributeTypeEnum
inCollection: ID
inCategory: ID
slugs: [String!]
"""
Specifies the channel by which the data should be filtered.
@ -1824,7 +1825,7 @@ input AttributeValueFilterInput {
input AttributeValueInput {
"""ID of the selected attribute."""
id: ID!
id: ID
"""
The value or slug of an attribute to resolve. If the passed value is non-existent, it will be created.
@ -2353,6 +2354,7 @@ input CategoryFilterInput {
search: String
metadata: [MetadataFilter!]
ids: [ID!]
slugs: [String!]
}
input CategoryInput {
@ -3011,6 +3013,15 @@ type Checkout implements Node & ObjectWithMetadata {
"""The price of the checkout before shipping, with taxes included."""
subtotalPrice: TaxedMoney!
"""
Returns True if checkout has to be exempt from taxes.
Added in Saleor 3.8.
Note: this API is currently in Feature Preview and can be subject to changes at later point.
"""
taxExemption: Boolean!
"""The checkout's token."""
token: UUID!
@ -3432,6 +3443,13 @@ input CheckoutLineInput {
Note: this API is currently in Feature Preview and can be subject to changes at later point.
"""
forceNewLine: Boolean = false
"""
Fields required to update the object's metadata.
Added in Saleor 3.8.
"""
metadata: [MetadataInput!]
}
input CheckoutLineUpdateInput {
@ -3950,6 +3968,7 @@ input CollectionFilterInput {
search: String
metadata: [MetadataFilter!]
ids: [ID!]
slugs: [String!]
"""
Specifies the channel by which the data should be filtered.
@ -8250,6 +8269,7 @@ input MenuFilterInput {
search: String
slug: [String!]
metadata: [MetadataFilter!]
slugs: [String!]
}
input MenuInput {
@ -8851,6 +8871,23 @@ type Mutation {
shippingZoneIds: [ID!]!
): WarehouseShippingZoneUnassign
"""
Exempt checkout or order from charging the taxes. When tax exemption is enabled, taxes won't be charged for the checkout or order. Taxes may still be calculated in cases when product prices are entered with the tax included and the net price needs to be known.
Added in Saleor 3.8.
Note: this API is currently in Feature Preview and can be subject to changes at later point.
Requires one of the following permissions: MANAGE_TAXES.
"""
taxExemptionManage(
"""ID of the Checkout or Order object."""
id: ID!
"""Determines if a taxes should be exempt."""
taxExemption: Boolean!
): TaxExemptionManage
"""
Creates a new staff notification recipient.
@ -11123,6 +11160,13 @@ type Mutation {
"""
id: ID
"""
Fields required to update the checkout metadata.
Added in Saleor 3.8.
"""
metadata: [MetadataInput!]
"""Client-side generated data required to finalize the payment."""
paymentData: JSONString
@ -11531,6 +11575,20 @@ type Mutation {
"""ID of a checkout that will be converted to an order."""
id: ID!
"""
Fields required to update the checkout metadata.
Added in Saleor 3.8.
"""
metadata: [MetadataInput!]
"""
Fields required to update the checkout private metadata.
Added in Saleor 3.8.
"""
privateMetadata: [MetadataInput!]
"""
Determines if checkout should be removed after creating an order. Default true.
"""
@ -12520,6 +12578,15 @@ type Order implements Node & ObjectWithMetadata {
"""
chargeStatus: OrderChargeStatusEnum!
"""
Returns True if order has to be exempt from taxes.
Added in Saleor 3.8.
Note: this API is currently in Feature Preview and can be subject to changes at later point.
"""
taxExemption: Boolean!
"""
List of transactions for the order. Requires one of the following permissions: MANAGE_ORDERS, HANDLE_PAYMENTS.
@ -14193,6 +14260,7 @@ input PageFilterInput {
metadata: [MetadataFilter!]
pageTypes: [ID!]
ids: [ID!]
slugs: [String!]
}
"""
@ -14579,6 +14647,7 @@ type PageTypeDeleted implements Event {
input PageTypeFilterInput {
search: String
slugs: [String!]
}
"""
@ -15218,6 +15287,10 @@ enum PermissionEnum {
IMPERSONATE_USER
MANAGE_APPS
MANAGE_OBSERVABILITY
MANAGE_CHECKOUTS
HANDLE_CHECKOUTS
HANDLE_TAXES
MANAGE_TAXES
MANAGE_CHANNELS
MANAGE_DISCOUNTS
MANAGE_PLUGINS
@ -15232,9 +15305,6 @@ enum PermissionEnum {
MANAGE_SHIPPING
MANAGE_SETTINGS
MANAGE_TRANSLATIONS
MANAGE_CHECKOUTS
HANDLE_CHECKOUTS
HANDLE_TAXES
}
"""
@ -16228,6 +16298,7 @@ input ProductFilterInput {
giftCard: Boolean
ids: [ID!]
hasPreorderedVariants: Boolean
slugs: [String!]
"""
Specifies the channel by which the data should be filtered.
@ -16801,6 +16872,7 @@ input ProductTypeFilterInput {
metadata: [MetadataFilter!]
kind: ProductTypeKindEnum
ids: [ID!]
slugs: [String!]
}
input ProductTypeInput {
@ -18296,7 +18368,11 @@ type Query {
"""Sort menus."""
sortBy: MenuSortingInput
"""Filtering options for menus."""
"""
Filtering options for menus.
`slug`: This field will be removed in Saleor 4.0. Use `slugs` instead.
"""
filter: MenuFilterInput
"""Return the elements in the list that come before the specified cursor."""
@ -21239,6 +21315,41 @@ type Subscription {
event: Event
}
"""
Exempt checkout or order from charging the taxes. When tax exemption is enabled, taxes won't be charged for the checkout or order. Taxes may still be calculated in cases when product prices are entered with the tax included and the net price needs to be known.
Added in Saleor 3.8.
Note: this API is currently in Feature Preview and can be subject to changes at later point.
Requires one of the following permissions: MANAGE_TAXES.
"""
type TaxExemptionManage {
taxableObject: TaxSourceObject
errors: [TaxExemptionManageError!]!
}
type TaxExemptionManageError {
"""
Name of a field that caused the error. A value of `null` indicates that the error isn't associated with a particular field.
"""
field: String
"""The error message."""
message: String
"""The error code."""
code: TaxExemptionManageErrorCode!
}
"""An enumeration."""
enum TaxExemptionManageErrorCode {
GRAPHQL_ERROR
INVALID
NOT_FOUND
NOT_EDITABLE_ORDER
}
union TaxSourceLine = CheckoutLine | OrderLine
union TaxSourceObject = Checkout | Order
@ -23068,6 +23179,7 @@ input WarehouseFilterInput {
ids: [ID!]
isPrivate: Boolean
channels: [ID!]
slugs: [String!]
}
"""

View file

@ -1,135 +0,0 @@
import { Accordion, AccordionSummary, Typography } from "@material-ui/core";
import { ChannelData } from "@saleor/channels/utils";
import { Messages } from "@saleor/components/ChannelsAvailabilityCard/types";
import { ProductDetailsVariantFragment } from "@saleor/graphql";
import IconChevronDown from "@saleor/icons/ChevronDown";
import { makeStyles } from "@saleor/macaw-ui";
import Label from "@saleor/orders/components/OrderHistory/Label";
import { getById } from "@saleor/orders/components/OrderReturnPage/utils";
import { ChannelsWithVariantsData } from "@saleor/products/views/ProductUpdate/types";
import { areAllChannelVariantsSelected } from "@saleor/products/views/ProductUpdate/utils";
import React from "react";
import { defineMessages, useIntl } from "react-intl";
const useExpanderStyles = makeStyles(
theme => ({
expanded: {},
root: {
boxShadow: "none",
margin: 0,
padding: 0,
paddingBottom: theme.spacing(2),
"&:before": {
content: "none",
},
"&$expanded": {
margin: 0,
border: "none",
},
},
}),
{ name: "ChannelWithVariantAvailabilityItemWrapperExpander" },
);
const useSummaryStyles = makeStyles(
theme => ({
expanded: {},
root: {
width: "100%",
border: "none",
margin: 0,
padding: 0,
minHeight: 0,
paddingTop: theme.spacing(2),
"&$expanded": {
minHeight: 0,
},
},
content: {
margin: 0,
"&$expanded": {
margin: 0,
},
},
}),
{ name: "ChannelWithVariantAvailabilityItemWrapperSummary" },
);
const useStyles = makeStyles(
() => ({
container: {
display: "flex",
flexDirection: "column",
},
}),
{ name: "ChannelWithVariantAvailabilityItemWrapper" },
);
const messages = defineMessages({
variantCountLabel: {
id: "1w06LC",
defaultMessage: "{variantsCount} variants",
description: "variants count label",
},
allVariantsLabel: {
id: "37U5su",
defaultMessage: "All variants",
description: "all variants label",
},
});
interface ChannelAvailabilityItemWrapperProps {
variants: ProductDetailsVariantFragment[];
channelId: string;
channels: ChannelData[];
channelsWithVariantsData: ChannelsWithVariantsData;
messages: Messages;
}
const ChannelWithVariantsAvailabilityItemWrapper: React.FC<ChannelAvailabilityItemWrapperProps> = ({
channels,
channelsWithVariantsData,
channelId,
variants,
messages: commonChannelMessages,
children,
}) => {
const expanderClasses = useExpanderStyles();
const summaryClasses = useSummaryStyles();
const classes = useStyles();
const intl = useIntl();
const { name } = channels.find(getById(channelId));
const { selectedVariantsIds } = channelsWithVariantsData[channelId];
const variantsCount = selectedVariantsIds.length;
const variantsLabel = areAllChannelVariantsSelected(
variants?.map(variant => variant.id),
selectedVariantsIds,
)
? messages.allVariantsLabel
: messages.variantCountLabel;
return (
<Accordion classes={expanderClasses}>
<AccordionSummary
expandIcon={<IconChevronDown />}
classes={summaryClasses}
>
<div className={classes.container}>
<Typography>{name}</Typography>
<Label text={intl.formatMessage(variantsLabel, { variantsCount })} />
<Label text={commonChannelMessages.availableDateText} />
</div>
</AccordionSummary>
{children}
</Accordion>
);
};
export default ChannelWithVariantsAvailabilityItemWrapper;

View file

@ -1,108 +0,0 @@
import { ProductDetailsVariantFragment } from "@saleor/graphql";
import CentralPlacementDecorator from "@saleor/storybook/CentralPlacementDecorator";
import CommonDecorator from "@saleor/storybook/Decorator";
import { storiesOf } from "@storybook/react";
import React from "react";
import ChannelsWithVariantsAvailabilityCard, {
ChannelsWithVariantsAvailabilityCardProps,
} from "./ChannelsWithVariantsAvailabilityCard";
const props: ChannelsWithVariantsAvailabilityCardProps = {
channels: [
{
id: "1",
name: "Channel 1",
isAvailableForPurchase: false,
isPublished: true,
publicationDate: "2020-07-30",
availableForPurchase: null,
visibleInListings: false,
currency: "EUR",
variantsIds: ["variantA"],
costPrice: "5",
price: "15",
},
{
id: "2",
name: "Channel 2",
isAvailableForPurchase: false,
isPublished: true,
publicationDate: "2020-07-30",
availableForPurchase: null,
visibleInListings: false,
currency: "EUR",
variantsIds: ["variantA"],
costPrice: "5",
price: "15",
},
{
id: "3",
name: "Channel 3",
isAvailableForPurchase: false,
isPublished: true,
publicationDate: "2020-07-30",
availableForPurchase: null,
visibleInListings: false,
currency: "EUR",
variantsIds: ["variantA"],
costPrice: "5",
price: "15",
},
],
variants: [
{
id: "variantA",
name: "Variant A",
media: [
{
url:
"https://test-envs-stack-testenvsmediabucket050c0d50-qdkqrzgoumxv.s3.amazonaws.com/feature-assing-variants-to-channel-listings/products/saleordemoproduct_fd_juice_06.png",
},
],
},
{
id: "variantB",
name: "Variant B",
media: [
{
url:
"https://test-envs-stack-testenvsmediabucket050c0d50-qdkqrzgoumxv.s3.amazonaws.com/feature-assing-variants-to-channel-listings/products/saleordemoproduct_fd_juice_05.png",
},
],
},
{
id: "variantC",
name: "Variant C",
media: [],
},
] as ProductDetailsVariantFragment[],
channelsWithVariantsData: {
["1"]: {
selectedVariantsIds: ["variantA", "variantB"],
variantsIdsToRemove: [],
variantsIdsToAdd: [],
},
["2"]: {
selectedVariantsIds: ["variantA", "variantC"],
variantsIdsToRemove: [],
variantsIdsToAdd: [],
},
["3"]: {
selectedVariantsIds: [],
variantsIdsToRemove: [],
variantsIdsToAdd: [],
},
},
messages: {
hiddenLabel: "Not published",
visibleLabel: "Published",
},
onChange: () => undefined,
openModal: () => undefined,
};
storiesOf("Channels / Channels with variants availability card", module)
.addDecorator(CommonDecorator)
.addDecorator(CentralPlacementDecorator)
.add("default", () => <ChannelsWithVariantsAvailabilityCard {...props} />);

View file

@ -1,100 +0,0 @@
import { ChannelData } from "@saleor/channels/utils";
import ChannelAvailabilityItemContent from "@saleor/components/ChannelsAvailabilityCard/Channel/ChannelAvailabilityItemContent";
import ChannelsAvailabilityCardWrapper, {
ChannelsAvailabilityWrapperProps,
} from "@saleor/components/ChannelsAvailabilityCard/ChannelsAvailabilityCardWrapper";
import {
ChannelOpts,
ChannelsAvailabilityError,
Messages,
} from "@saleor/components/ChannelsAvailabilityCard/types";
import { getChannelsAvailabilityMessages } from "@saleor/components/ChannelsAvailabilityCard/utils";
import { PermissionEnum, ProductDetailsVariantFragment } from "@saleor/graphql";
import useDateLocalize from "@saleor/hooks/useDateLocalize";
import { getById } from "@saleor/orders/components/OrderReturnPage/utils";
import { ChannelsWithVariantsData } from "@saleor/products/views/ProductUpdate/types";
import {
areAnyChannelVariantsSelected,
getTotalSelectedChannelsCount,
} from "@saleor/products/views/ProductUpdate/utils";
import React from "react";
import { useIntl } from "react-intl";
import ChannelWithVariantsAvailabilityItemWrapper from "./ChannelWithVariantAvailabilityItemWrapper";
type CommonChannelsAvailabilityProps = Omit<
ChannelsAvailabilityWrapperProps,
| "children"
| "selectedChannelsCount"
| "allChannelsCount"
| "managePermissions"
>;
export interface ChannelsWithVariantsAvailabilityCardProps
extends CommonChannelsAvailabilityProps {
channelsWithVariantsData: ChannelsWithVariantsData;
channels: ChannelData[];
variants: ProductDetailsVariantFragment[];
errors?: ChannelsAvailabilityError[];
messages: Messages;
onChange: (id: string, data: ChannelOpts) => void;
}
const ChannelsWithVariantsAvailabilityCard: React.FC<ChannelsWithVariantsAvailabilityCardProps> = ({
channels,
channelsWithVariantsData,
openModal,
variants,
errors = [],
messages,
onChange,
}) => {
const intl = useIntl();
const localizeDate = useDateLocalize();
const channelsMessages = getChannelsAvailabilityMessages({
messages,
channels,
intl,
localizeDate,
});
const allChannelsCount = channels.length;
const selectedChannelsCount = getTotalSelectedChannelsCount(
channelsWithVariantsData,
);
return (
<ChannelsAvailabilityCardWrapper
managePermissions={[PermissionEnum.MANAGE_PRODUCTS]}
selectedChannelsCount={selectedChannelsCount}
allChannelsCount={allChannelsCount}
openModal={openModal}
>
{channels
.filter(({ id }) =>
areAnyChannelVariantsSelected(channelsWithVariantsData[id]),
)
.map(({ id }) => (
<ChannelWithVariantsAvailabilityItemWrapper
messages={channelsMessages[id]}
key={id}
channelsWithVariantsData={channelsWithVariantsData}
variants={variants}
channels={channels}
channelId={id}
>
<ChannelAvailabilityItemContent
onChange={onChange}
data={channels.find(getById(id))}
errors={errors}
messages={channelsMessages[id]}
/>
</ChannelWithVariantsAvailabilityItemWrapper>
))}
</ChannelsAvailabilityCardWrapper>
);
};
export default ChannelsWithVariantsAvailabilityCard;

View file

@ -1,62 +0,0 @@
import { ProductDetailsVariantFragment } from "@saleor/graphql";
import CommonDecorator from "@saleor/storybook/Decorator";
import { storiesOf } from "@storybook/react";
import React from "react";
import ChannelsWithVariantsAvailabilityDialog, {
ChannelsAvailabilityDialogProps,
} from "./ChannelsWithVariantsAvailabilityDialog";
const props: ChannelsAvailabilityDialogProps = {
channels: [
{
id: "1",
name: "Channel 1",
variantsIds: [],
},
{
id: "2",
name: "Channel 2",
variantsIds: [],
},
{
id: "3",
name: "Channel 3",
variantsIds: [],
},
],
variants: [
{
id: "variantA",
name: "Variant A",
media: [
{
url:
"https://test-envs-stack-testenvsmediabucket050c0d50-qdkqrzgoumxv.s3.amazonaws.com/feature-assing-variants-to-channel-listings/products/saleordemoproduct_fd_juice_06.png",
},
],
},
{
id: "variantB",
name: "Variant B",
media: [
{
url:
"https://test-envs-stack-testenvsmediabucket050c0d50-qdkqrzgoumxv.s3.amazonaws.com/feature-assing-variants-to-channel-listings/products/saleordemoproduct_fd_juice_05.png",
},
],
},
{
id: "variantC",
name: "Variant C",
media: [],
},
] as ProductDetailsVariantFragment[],
onClose: () => undefined,
onConfirm: () => undefined,
open: true,
};
storiesOf("Channels / Channels with Variants Availability Dialog", module)
.addDecorator(CommonDecorator)
.add("default", () => <ChannelsWithVariantsAvailabilityDialog {...props} />);

View file

@ -1,103 +0,0 @@
import { ChannelData } from "@saleor/channels/utils";
import ActionDialog from "@saleor/components/ActionDialog";
import { ProductDetailsVariantFragment } from "@saleor/graphql";
import useModalDialogOpen from "@saleor/hooks/useModalDialogOpen";
import { ChannelVariantListing } from "@saleor/products/views/ProductUpdate/types";
import useChannelsWithProductVariants from "@saleor/products/views/ProductUpdate/useChannelsWithProductVariants";
import {
areAllVariantsAtAllChannelsSelected,
areAnyChannelVariantsSelected,
channelVariantListingDiffToDict,
} from "@saleor/products/views/ProductUpdate/utils";
import { DialogProps } from "@saleor/types";
import React from "react";
import { defineMessages, useIntl } from "react-intl";
import { useChannelsSearch } from "../../../components/ChannelsAvailabilityDialog/utils";
import ChannelsAvailabilityContentWrapper from "../../../components/ChannelsAvailabilityDialogWrapper/ChannelsAvailabilityDialogWrapper";
import ChannelsWithVariantsAvailabilityDialogContent from "./ChannelsWithVariantsAvailabilityDialogContent";
const messages = defineMessages({
title: {
id: "p/EWEZ",
defaultMessage: "Manage Channels",
description: "channels variants availability dialog title",
},
});
export interface ChannelsAvailabilityDialogProps extends DialogProps {
channels: ChannelData[];
contentType?: string;
variants: ProductDetailsVariantFragment[];
onConfirm: (listings: ChannelVariantListing) => void;
}
export const ChannelsWithVariantsAvailabilityDialog: React.FC<ChannelsAvailabilityDialogProps> = ({
channels,
contentType,
variants,
open,
onClose,
onConfirm,
}) => {
const intl = useIntl();
const {
channelsWithVariantsData,
toggleAllChannels,
addVariantToChannel,
removeVariantFromChannel,
toggleAllChannelVariants,
channelVariantListing,
reset,
} = useChannelsWithProductVariants(
channels,
variants?.map(variant => variant.id),
);
useModalDialogOpen(open, {
onClose: reset,
});
const { query, onQueryChange, filteredChannels } = useChannelsSearch(
channels,
);
const hasAllChannelsSelected = areAllVariantsAtAllChannelsSelected(
variants.map(variant => variant.id),
channelVariantListingDiffToDict(channelsWithVariantsData),
);
const isChannelSelected = (channelId: string) =>
areAnyChannelVariantsSelected(channelsWithVariantsData[channelId]);
return (
<ActionDialog
confirmButtonState="default"
open={open}
onClose={onClose}
onConfirm={() => onConfirm(channelVariantListing)}
title={intl.formatMessage(messages.title)}
>
<ChannelsAvailabilityContentWrapper
hasAllSelected={hasAllChannelsSelected}
hasAnyChannelsToDisplay={!!filteredChannels.length}
query={query}
onQueryChange={onQueryChange}
toggleAll={toggleAllChannels}
contentType={contentType}
>
<ChannelsWithVariantsAvailabilityDialogContent
allVariants={variants}
channels={filteredChannels}
isChannelSelected={isChannelSelected}
channelsWithVariants={channelsWithVariantsData}
addVariantToChannel={addVariantToChannel}
removeVariantFromChannel={removeVariantFromChannel}
toggleAllChannelVariants={toggleAllChannelVariants}
/>
</ChannelsAvailabilityContentWrapper>
</ActionDialog>
);
};
export default ChannelsWithVariantsAvailabilityDialog;

View file

@ -1,228 +0,0 @@
import placeholderImage from "@assets/images/placeholder60x60.png";
import {
Accordion,
AccordionSummary,
Divider,
Typography,
} from "@material-ui/core";
import { ChannelData } from "@saleor/channels/utils";
import { ProductDetailsVariantFragment } from "@saleor/graphql";
import IconChevronDown from "@saleor/icons/ChevronDown";
import { makeStyles } from "@saleor/macaw-ui";
import Label from "@saleor/orders/components/OrderHistory/Label";
import { getById } from "@saleor/orders/components/OrderReturnPage/utils";
import { ChannelsWithVariantsData } from "@saleor/products/views/ProductUpdate/types";
import {
areAllChannelVariantsSelected,
channelVariantListingDiffToDict,
} from "@saleor/products/views/ProductUpdate/utils";
import map from "lodash/map";
import React, { ChangeEvent } from "react";
import { defineMessages, useIntl } from "react-intl";
import ControlledCheckbox from "../../../components/ControlledCheckbox";
import Avatar from "../../../components/TableCellAvatar/Avatar";
const useStyles = makeStyles(
theme => ({
variantContainer: {
padding: theme.spacing(2, 0, 2, 4),
},
channelContainer: {
width: "100%",
},
channelCheckboxContainer: {
width: "100%",
padding: theme.spacing(2, 0),
},
channelTitleContainer: {
display: "flex",
flexDirection: "column",
},
variantTitleContainer: {
display: "flex",
flexDirection: "row",
alignItems: "center",
},
}),
{ name: "ChannelsWithVariantsAvailabilityDialogContent" },
);
const useExpanderStyles = makeStyles(
() => ({
expanded: {},
root: {
boxShadow: "none",
"&:before": {
content: "none",
},
"&$expanded": {
margin: 0,
border: "none",
},
},
}),
{ name: "ChannelsWithVariantsAvailabilityExpander" },
);
const useSummaryStyles = makeStyles(
theme => ({
expanded: {},
root: {
width: "100%",
border: "none",
height: theme.spacing(10),
padding: 0,
margin: 0,
minHeight: 0,
paddingRight: theme.spacing(2.5),
},
content: {
margin: 0,
},
}),
{ name: "ChannelsWithVariantsAvailabilityChannelSummary" },
);
const messages = defineMessages({
variantsSelectedLabel: {
id: "yHaQWG",
defaultMessage: "{variantsAmount} variants selected",
description: "variants selected label",
},
});
interface ChannelsWithVariantsAvailabilityDialogContentProps {
addVariantToChannel: (channelId: string, variantId: string) => void;
removeVariantFromChannel: (channelId: string, variantId: string) => void;
channelsWithVariants: ChannelsWithVariantsData;
toggleAllChannelVariants: (channelId: string) => void;
isChannelSelected: (channelId: string) => boolean;
channels: ChannelData[];
allVariants: ProductDetailsVariantFragment[];
}
const ChannelsWithVariantsAvailabilityDialogContent: React.FC<ChannelsWithVariantsAvailabilityDialogContentProps> = ({
channelsWithVariants,
addVariantToChannel,
removeVariantFromChannel,
toggleAllChannelVariants,
isChannelSelected,
channels,
allVariants,
}) => {
const intl = useIntl();
const classes = useStyles({});
const expanderClasses = useExpanderStyles({});
const summaryClasses = useSummaryStyles({});
const handleVariantChange = (channelId: string, variantId: string) => (
event: ChangeEvent<any>,
) =>
event.target.value
? addVariantToChannel(channelId, variantId)
: removeVariantFromChannel(channelId, variantId);
const isChannelPartiallySelected = (channelId: string) => {
const selectedVariants = channelVariantListingDiffToDict(
channelsWithVariants,
)[channelId];
if (selectedVariants.length === 0) {
return false;
}
return !areAllChannelVariantsSelected(
allVariants?.map(variant => variant.id),
selectedVariants,
);
};
return (
<>
{map(channelsWithVariants, ({ selectedVariantsIds }, channelId) => {
const filteredChannel = channels.find(getById(channelId));
if (!filteredChannel) {
return null;
}
const { name } = filteredChannel;
const isVariantSelected = (variantId: string) =>
selectedVariantsIds.includes(variantId);
const getVariantThumbnailSrc = (variantId: string) =>
allVariants.find(getById(variantId)).media[0]?.url ||
placeholderImage;
return (
<Accordion
classes={expanderClasses}
data-test-id="expand-channel-row"
key={channelId}
>
<AccordionSummary
expandIcon={<IconChevronDown />}
classes={summaryClasses}
>
<div
className={classes.channelContainer}
// stop expander when selecting & deselecting channel
onClick={event => event.stopPropagation()}
>
<div className={classes.channelCheckboxContainer}>
<ControlledCheckbox
checked={isChannelSelected(channelId)}
indeterminate={isChannelPartiallySelected(channelId)}
name={name}
label={
<div className={classes.channelTitleContainer}>
<Typography>{name}</Typography>
<Label
text={intl.formatMessage(
messages.variantsSelectedLabel,
{
variantsAmount: selectedVariantsIds.length,
},
)}
/>
</div>
}
onChange={() => toggleAllChannelVariants(channelId)}
/>
</div>
<Divider />
</div>
</AccordionSummary>
{allVariants.map(({ id: variantId, name }) => (
<React.Fragment key={variantId}>
<div
data-test-id="channel-variant-row"
key={variantId}
className={classes.variantContainer}
>
<ControlledCheckbox
checked={isVariantSelected(variantId)}
name={name}
label={
<div className={classes.variantTitleContainer}>
<Avatar thumbnail={getVariantThumbnailSrc(variantId)} />
<Typography>{name}</Typography>
</div>
}
onChange={handleVariantChange(channelId, variantId)}
/>
</div>
<Divider />
</React.Fragment>
))}
</Accordion>
);
})}
</>
);
};
export default ChannelsWithVariantsAvailabilityDialogContent;

View file

@ -1,2 +0,0 @@
export * from "./ChannelsWithVariantsAvailabilityDialog";
export { default } from "./ChannelsWithVariantsAvailabilityDialog";

View file

@ -19,7 +19,7 @@ export const channelCreateErrors: ChannelErrorFragment[] = [
export const channelsList: ChannelDetailsFragment[] = [
{
__typename: "Channel",
currencyCode: "euro",
currencyCode: "EUR",
hasOrders: false,
id: "Q2hhbm5lcDoy",
isActive: true,
@ -49,7 +49,7 @@ export const channelsList: ChannelDetailsFragment[] = [
},
{
__typename: "Channel",
currencyCode: "euro",
currencyCode: "EUR",
hasOrders: false,
id: "Q2hhbm7lbDoy213",
isActive: true,
@ -79,7 +79,7 @@ export const channelsList: ChannelDetailsFragment[] = [
},
{
__typename: "Channel",
currencyCode: "euro",
currencyCode: "EUR",
hasOrders: false,
id: "Q2hhbn5lbDoytr",
isActive: true,
@ -109,7 +109,7 @@ export const channelsList: ChannelDetailsFragment[] = [
},
{
__typename: "Channel",
currencyCode: "euro",
currencyCode: "EUR",
hasOrders: false,
id: "Q2hhbm5lbDo5bot",
isActive: true,
@ -139,7 +139,7 @@ export const channelsList: ChannelDetailsFragment[] = [
},
{
__typename: "Channel",
currencyCode: "euro",
currencyCode: "EUR",
hasOrders: false,
id: "Q2hhbm7lbDoyr0tr",
isActive: true,
@ -169,7 +169,7 @@ export const channelsList: ChannelDetailsFragment[] = [
},
{
__typename: "Channel",
currencyCode: "euro",
currencyCode: "EUR",
hasOrders: false,
id: "Q2hhbn5lbDoyya",
isActive: true,
@ -199,7 +199,7 @@ export const channelsList: ChannelDetailsFragment[] = [
},
{
__typename: "Channel",
currencyCode: "euro",
currencyCode: "EUR",
hasOrders: false,
id: "Q2hhbm5lbDo5w0z",
isActive: true,

View file

@ -42,6 +42,15 @@ export interface ChannelData {
unitsSold?: number;
}
export interface ProductChannelListingData extends Channel {
isPublished: boolean;
publicationDate: string | null;
availableForPurchase: string;
isAvailableForPurchase: boolean;
visibleInListings: boolean;
currency?: string;
}
export interface ChannelPriceData {
id: string;
name: string;

View file

@ -8,7 +8,7 @@ import {
Popper,
Typography,
} from "@material-ui/core";
import { makeStyles, SettingsIcon } from "@saleor/macaw-ui";
import { IconButtonProps, makeStyles, SettingsIcon } from "@saleor/macaw-ui";
import classNames from "classnames";
import React, { useEffect, useRef, useState } from "react";
import { FormattedMessage } from "react-intl";
@ -32,6 +32,8 @@ export interface CardMenuProps {
disabled?: boolean;
menuItems: CardMenuItem[];
outlined?: boolean;
Icon?: React.ElementType<{}>;
IconButtonProps?: IconButtonProps;
}
const useStyles = makeStyles(
@ -64,7 +66,15 @@ const useStyles = makeStyles(
);
const CardMenu: React.FC<CardMenuProps> = props => {
const { className, disabled, menuItems, outlined, ...rest } = props;
const {
className,
disabled,
menuItems,
outlined,
Icon: icon,
IconButtonProps = {},
...rest
} = props;
const classes = useStyles(props);
const anchorRef = useRef<HTMLButtonElement | null>(null);
@ -120,6 +130,8 @@ const CardMenu: React.FC<CardMenuProps> = props => {
const isWithLoading = menuItems.some(({ withLoading }) => withLoading);
const Icon = icon ?? SettingsIcon;
return (
<div className={className} {...rest}>
<IconButton
@ -133,8 +145,9 @@ const CardMenu: React.FC<CardMenuProps> = props => {
onClick={handleToggle}
variant={outlined ? "primary" : "secondary"}
state={open ? "active" : "default"}
{...IconButtonProps}
>
<SettingsIcon />
<Icon />
</IconButton>
<Popper
placement="bottom-end"

View file

@ -22,6 +22,7 @@ export interface ChannelsAvailability
"children" | "selectedChannelsCount"
> {
channels: ChannelData[];
/** Channels that have no settings */
channelsList: ChannelList[];
errors?: ChannelsAvailabilityError[];
disabled?: boolean;
@ -71,9 +72,9 @@ export const ChannelsAvailability: React.FC<ChannelsAvailabilityCardProps> = pro
return (
<ChannelAvailabilityItemWrapper
key={data.id}
messages={channelsMessages[data.id]}
messages={messages}
data={data}
key={data.id}
>
<ChannelAvailabilityItemContent
data={data}

View file

@ -1,7 +1,13 @@
import { ClickAwayListener, Grow, Popper } from "@material-ui/core";
import { FormChange } from "@saleor/hooks/useForm";
import useStateFromProps from "@saleor/hooks/useStateFromProps";
import { Choice, ColumnsIcon, IconButton, makeStyles } from "@saleor/macaw-ui";
import {
Choice,
ColumnsIcon,
IconButton,
IconButtonProps,
makeStyles,
} from "@saleor/macaw-ui";
import { FetchMoreProps } from "@saleor/types";
import { score } from "fuzzaldrin";
import sortBy from "lodash/sortBy";
@ -20,6 +26,7 @@ export interface ColumnPickerProps
defaultColumns: string[];
initialColumns: Choice[];
initialOpen?: boolean;
IconButtonProps?: IconButtonProps;
query: string;
onSave: (columns: string[]) => void;
}
@ -44,6 +51,7 @@ const ColumnPicker: React.FC<ColumnPickerProps> = props => {
initialOpen = false,
onSave,
query,
IconButtonProps = {},
...rest
} = props;
const classes = useStyles(props);
@ -99,6 +107,7 @@ const ColumnPicker: React.FC<ColumnPickerProps> = props => {
<ClickAwayListener onClickAway={() => setExpansionState(false)}>
<div ref={anchor} className={className}>
<IconButton
{...IconButtonProps}
state={isExpanded ? "active" : "default"}
onClick={() => setExpansionState(prevState => !prevState)}
>

View file

@ -0,0 +1,151 @@
import { GridCell, Item } from "@glideapps/glide-data-grid";
import { Button } from "@saleor/macaw-ui";
import Decorator from "@saleor/storybook/Decorator";
import { storiesOf } from "@storybook/react";
import { score } from "fuzzaldrin";
import sortBy from "lodash/sortBy";
import throttle from "lodash/throttle";
import React from "react";
import {
booleanCell,
dropdownCell,
moneyCell,
numberCell,
textCell,
} from "./cells";
import Datagrid, { GetCellContentOpts } from "./Datagrid";
import { DropdownChoice } from "./DropdownCell";
import { initialData } from "./fixtures";
import { numberCellEmptyValue } from "./NumberCell";
import {
DatagridChangeStateContext,
useDatagridChangeState,
} from "./useDatagridChange";
const availableColumns = [
{ title: "ID", id: "uuid", width: 300 },
{ title: "Loaned", id: "loan-active", width: 70 },
{ title: "Loaned Amount", id: "loan", width: 200 },
{ title: "Name", id: "name", width: 200 },
{ title: "Job", id: "job", width: 200 },
{ title: "Balance", id: "balance", width: 200 },
{ title: "Eye color", id: "eyeColor", width: 200 },
{ title: "Age", id: "age", width: 80 },
] as const;
const jobChoices = [
{ label: "QA", value: "qa" },
{ label: "Engineer", value: "eng" },
{ label: "Designer", value: "designer" },
{ label: "Director", value: "director" },
];
const getJobChoices = throttle(
(text: string) =>
new Promise<DropdownChoice[]>(resolve =>
setTimeout(() => {
resolve(
sortBy(jobChoices, choice => -score(choice.label, text)).slice(0, 2),
);
}, 500),
),
500,
);
const DefaultStory: React.FC<{ error?: boolean }> = ({ error }) => {
const changeProps = useDatagridChangeState();
const getCellContent = React.useCallback(
(
[column, row]: Item,
{ changes, getChangeIndex, added, removed }: GetCellContentOpts,
): GridCell => {
const columnId = availableColumns[column].id;
const change = changes.current[getChangeIndex(columnId, row)]?.data;
const dataRow = added.includes(row)
? undefined
: initialData[row + removed.filter(r => r <= row).length];
if (columnId === "loan-active") {
return booleanCell(change ?? dataRow?.loan.active ?? null);
}
if (columnId === "loan") {
return moneyCell(
change?.value ?? dataRow?.loan.amount ?? null,
dataRow?.loan.currency ?? "USD",
);
}
if (columnId === "balance") {
return moneyCell(
change?.value ?? dataRow?.balance.amount ?? null,
dataRow?.balance.currency ?? "USD",
);
}
if (columnId === "job") {
return dropdownCell(change?.value ?? dataRow?.job, {
update: getJobChoices,
});
}
if (columnId === "age") {
return numberCell(
change?.value ?? dataRow?.age ?? numberCellEmptyValue,
);
}
const data = change ?? (dataRow ? dataRow[columnId] : "");
return textCell(data);
},
[],
);
const getCellError = React.useCallback(([column, row]: Item): boolean => {
if (!error) {
return false;
}
const columnId = availableColumns[column].id;
return row === 3 && columnId === "uuid";
}, []);
return (
<DatagridChangeStateContext.Provider value={changeProps}>
<div style={{ width: 800, margin: "auto" }}>
<Datagrid
addButtonLabel="Add row"
availableColumns={availableColumns}
getCellContent={getCellContent}
getCellError={getCellError}
emptyText="Use button above to add new rows"
menuItems={() => [
{
label: "Do something",
onSelect: () => undefined,
},
]}
selectionActions={selection => (
<Button
variant="tertiary"
onClick={() =>
// eslint-disable-next-line no-console
console.log(selection.map(index => initialData[index]))
}
>
Action 1
</Button>
)}
rows={initialData.length}
title="Datagrid"
/>
</div>
</DatagridChangeStateContext.Provider>
);
};
storiesOf("Generics / Datagrid", module)
.addDecorator(Decorator)
.add("default", () => <DefaultStory />)
.add("with errors", () => <DefaultStory error />);

View file

@ -0,0 +1,329 @@
import "@glideapps/glide-data-grid/dist/index.css";
import DataEditor, {
DataEditorRef,
EditableGridCell,
GridCell,
GridSelection,
Item,
} from "@glideapps/glide-data-grid";
import { Card, CardContent, Typography } from "@material-ui/core";
import { usePreventHistoryBack } from "@saleor/hooks/usePreventHistoryBack";
import {
Button,
MoreHorizontalIcon,
PlusSmallIcon,
useTheme,
} from "@saleor/macaw-ui";
import classNames from "classnames";
import range from "lodash/range";
import throttle from "lodash/throttle";
import React from "react";
import CardMenu, { CardMenuItem } from "../CardMenu";
import CardTitle from "../CardTitle";
import ColumnPicker from "../ColumnPicker";
import useStyles, { useDatagridTheme } from "./styles";
import { AvailableColumn } from "./types";
import useCells from "./useCells";
import useColumns from "./useColumns";
import useDatagridChange, {
DatagridChange,
OnDatagridChange,
} from "./useDatagridChange";
export interface GetCellContentOpts {
changes: React.MutableRefObject<DatagridChange[]>;
added: number[];
removed: number[];
getChangeIndex: (column: string, row: number) => number;
}
export interface MenuItemsActions {
removeRows: (indexes: number[]) => void;
}
export interface DatagridProps {
addButtonLabel: string;
availableColumns: readonly AvailableColumn[];
emptyText: string;
getCellError: (item: Item, opts: GetCellContentOpts) => boolean;
getCellContent: (item: Item, opts: GetCellContentOpts) => GridCell;
menuItems: (index: number) => CardMenuItem[];
rows: number;
title: string;
selectionActions: (
selection: number[],
actions: MenuItemsActions,
) => React.ReactNode;
onChange?: OnDatagridChange;
}
export const Datagrid: React.FC<DatagridProps> = ({
addButtonLabel,
availableColumns,
emptyText,
getCellContent,
getCellError,
menuItems,
rows,
selectionActions,
title,
onChange,
}): React.ReactElement => {
const classes = useStyles();
const datagridTheme = useDatagridTheme();
const editor = React.useRef<DataEditorRef>();
const {
availableColumnsChoices,
columns,
columnChoices,
defaultColumns,
displayedColumns,
onColumnMoved,
onColumnResize,
onColumnsChange,
picker,
} = useColumns(availableColumns);
const {
added,
onCellEdited,
onRowsRemoved,
changes,
removed,
getChangeIndex,
onRowAdded,
} = useDatagridChange(availableColumns, rows, onChange);
const theme = useTheme();
const [scrolledToRight, setScrolledToRight] = React.useState(false);
const scroller: HTMLDivElement = document.querySelector(".dvn-scroller");
const scrollerInner: HTMLDivElement = document.querySelector(
".dvn-scroll-inner",
);
usePreventHistoryBack(scroller);
React.useEffect(() => {
if (!(scroller && scrollerInner)) {
return;
}
const handler = throttle(() => {
const isScrolledToRight =
scroller.scrollWidth - scroller.clientWidth - scroller.scrollLeft < 2;
setScrolledToRight(isScrolledToRight);
}, 100);
scroller.addEventListener("scroll", handler);
return () => scroller.removeEventListener("scroll", handler);
}, [scroller, scrollerInner]);
const getCellContentEnh = React.useCallback(
([column, row]: Item): GridCell => {
const item = [
availableColumns.findIndex(ac => ac.id === displayedColumns[column]),
row,
] as const;
const opts = { changes, added, removed, getChangeIndex };
const columnId = availableColumns[column].id;
const changed = !!changes.current[getChangeIndex(columnId, row)]?.data;
return {
...getCellContent(item, opts),
...(changed
? { themeOverride: { bgCell: theme.palette.saleor.active[5] } }
: {}),
...(getCellError(item, opts)
? {
themeOverride: {
bgCell:
theme.palette.saleor.theme === "light"
? theme.palette.saleor.fail.light
: theme.palette.saleor.errorAction[5],
},
}
: {}),
};
},
[getCellContent, availableColumns, displayedColumns, added, removed],
);
const onCellEditedEnh = React.useCallback(
([column, row]: Item, newValue: EditableGridCell): void => {
onCellEdited(
[
availableColumns.findIndex(ac => ac.id === displayedColumns[column]),
row,
],
newValue,
);
editor.current.updateCells(
range(displayedColumns.length).map(offset => ({
cell: [column + offset, row],
})),
);
},
[onCellEdited, getCellContent, availableColumns, displayedColumns],
);
const [selection, setSelection] = React.useState<GridSelection>();
const props = useCells();
const removeRows = React.useCallback(
(rows: number[]) => {
if (selection?.rows) {
onRowsRemoved(rows);
setSelection(undefined);
}
},
[selection, onRowsRemoved],
);
const selectionActionsComponent = React.useMemo(
() =>
selection?.rows.length > 0
? selectionActions(Array.from(selection.rows), { removeRows })
: null,
[selection, selectionActions, removeRows],
);
const rowsTotal = rows - removed.length + added.length;
const hasColumnGroups = columns.some(col => col.group);
return (
<Card className={classes.root}>
<CardTitle
title={title}
toolbar={
<div className={classes.btnContainer}>
<Button
data-test-id="button-add-variant"
className={classes.addBtn}
variant="tertiary"
onClick={onRowAdded}
>
<PlusSmallIcon />
{addButtonLabel}
</Button>
</div>
}
/>
<CardContent classes={{ root: classes.cardContentRoot }}>
{rowsTotal > 0 ? (
<>
{selection?.rows.length > 0 && (
<div className={classes.actionBtnBar}>
{selectionActionsComponent}
</div>
)}
<div className={classes.editorContainer}>
<DataEditor
{...props}
theme={datagridTheme}
className={classes.datagrid}
getCellContent={getCellContentEnh}
onCellEdited={onCellEditedEnh}
columns={columns}
rows={rowsTotal}
freezeColumns={1}
smoothScrollX
rowMarkers="checkbox"
rowSelect="multi"
rowSelectionMode="multi"
rangeSelect="multi-rect"
columnSelect="none"
getCellsForSelection
onColumnMoved={onColumnMoved}
onColumnResize={onColumnResize}
onGridSelectionChange={setSelection}
gridSelection={selection}
rowHeight={48}
headerHeight={48}
ref={editor}
onPaste
rightElementProps={{
sticky: true,
}}
rightElement={
<div
className={classNames(classes.rowActionBar, {
[classes.rowActionBarScrolledToRight]: scrolledToRight,
})}
>
<div
className={classNames(classes.rowActionBarShadow, {
[classes.rowActionBarShadowActive]: !scrolledToRight,
})}
/>
<div className={classes.columnPicker}>
<ColumnPicker
IconButtonProps={{
className: classes.columnPickerBtn,
variant: "secondary",
hoverOutline: false,
}}
availableColumns={availableColumnsChoices}
initialColumns={columnChoices}
defaultColumns={defaultColumns}
onSave={onColumnsChange}
hasMore={false}
loading={false}
onFetchMore={() => undefined}
onQueryChange={picker.setQuery}
query={picker.query}
/>
</div>
{hasColumnGroups && (
<div
className={classNames(classes.rowAction, {
[classes.rowActionScrolledToRight]: scrolledToRight,
})}
/>
)}
{Array(rowsTotal)
.fill(0)
.map((_, index) => (
<div
className={classNames(classes.rowAction, {
[classes.rowActionSelected]: selection?.rows.hasIndex(
index,
),
[classes.rowActionScrolledToRight]: scrolledToRight,
})}
key={index}
>
<CardMenu
disabled={index >= rowsTotal - added.length}
Icon={MoreHorizontalIcon}
IconButtonProps={{
className: classes.columnPickerBtn,
hoverOutline: false,
state: "default",
}}
menuItems={menuItems(index)}
/>
</div>
))}
</div>
}
rowMarkerWidth={48}
/>
{/* FIXME: https://github.com/glideapps/glide-data-grid/issues/505 */}
{hasColumnGroups && <div className={classes.columnGroupFixer} />}
</div>
</>
) : (
<Typography align="center">{emptyText}</Typography>
)}
</CardContent>
<div id="portal" className={classes.portal} />
</Card>
);
};
Datagrid.displayName = "Datagrid";
export default Datagrid;

View file

@ -0,0 +1,128 @@
import {
CustomCell,
CustomCellRenderer,
getMiddleCenterBias,
ProvideEditorCallback,
} from "@glideapps/glide-data-grid";
import { makeStyles } from "@saleor/macaw-ui";
import pick from "lodash/pick";
import React from "react";
import SingleAutocompleteSelectField, {
SingleAutocompleteSelectFieldProps,
} from "../SingleAutocompleteSelectField";
import { Choice } from "../SingleSelectField";
export type DropdownChoice = Choice<string, string>;
export type DropdownCellContentProps = Pick<
SingleAutocompleteSelectFieldProps,
"allowCustomValues" | "emptyOption"
>;
export type DropdownCellGetSuggestionsFn = (
text: string,
) => Promise<DropdownChoice[]>;
interface DropdownCellProps extends DropdownCellContentProps {
readonly choices?: DropdownChoice[];
readonly update?: DropdownCellGetSuggestionsFn;
readonly kind: "dropdown-cell";
readonly value: DropdownChoice | null;
}
export type DropdownCell = CustomCell<DropdownCellProps>;
export const emptyDropdownCellValue: DropdownChoice = {
label: "",
value: null,
};
const useStyles = makeStyles(
{
root: {
"& > div": {
padding: 0,
},
"& input": {
height: "unset",
},
},
},
{ name: "DropdownCell" },
);
const DropdownCellEdit: ReturnType<ProvideEditorCallback<DropdownCell>> = ({
value: cell,
onFinishedEditing,
}) => {
const [data, setData] = React.useState<DropdownChoice[]>([]);
const getChoices = React.useCallback(
async (text: string) => {
setData(await cell.data.update(text));
},
[cell.data],
);
const classes = useStyles();
const userProps = pick(cell.data, ["allowCustomValues", "emptyOption"]);
const props = cell.data.update
? { fetchOnFocus: true, fetchChoices: getChoices, choices: data }
: { choices: cell.data.choices };
return (
<SingleAutocompleteSelectField
{...userProps}
{...props}
className={classes.root}
nakedInput
onChange={event =>
onFinishedEditing({
...cell,
data: {
...cell.data,
value: props.choices.find(c => c.value === event.target.value) ?? {
label: event.target.value,
value: event.target.value,
},
},
})
}
name=""
displayValue={cell.data.value.label}
value={cell.data.value.value}
/>
);
};
export const dropdownCellRenderer: CustomCellRenderer<DropdownCell> = {
isMatch: (c): c is DropdownCell => (c.data as any).kind === "dropdown-cell",
draw: (args, cell) => {
const { ctx, theme, rect } = args;
const { value } = cell.data;
ctx.fillStyle = theme.textDark;
ctx.fillText(
value.label,
rect.x + 8,
rect.y + rect.height / 2 + getMiddleCenterBias(ctx, theme),
);
return true;
},
provideEditor: () => ({
editor: DropdownCellEdit,
disablePadding: true,
deletedValue: cell => ({
...cell,
copyData: "",
data: {
...cell.data,
display: "",
value: null,
},
}),
}),
onPaste: (value, data) => ({
...data,
value: { value, label: value } ?? null,
}),
};

View file

@ -0,0 +1,117 @@
import {
CustomCell,
CustomCellRenderer,
getMiddleCenterBias,
ProvideEditorCallback,
} from "@glideapps/glide-data-grid";
import React from "react";
import { Locale } from "../Locale";
import { usePriceField } from "../PriceField/usePriceField";
interface MoneyCellProps {
readonly kind: "money-cell";
readonly currency: string;
readonly value: number | null;
}
export type MoneyCell = CustomCell<MoneyCellProps>;
const MoneyCellEdit: ReturnType<ProvideEditorCallback<MoneyCell>> = ({
value: cell,
onChange: onChangeBase,
}) => {
const { onChange, onKeyDown, minValue, step } = usePriceField(
cell.data.currency,
event =>
onChangeBase({
...cell,
data: {
...cell.data,
value: event.target.value,
},
}),
);
return (
<input
type="number"
onChange={onChange}
onKeyDown={onKeyDown}
value={cell.data.value ?? ""}
min={minValue}
step={step}
autoFocus
/>
);
};
const getFractionDigits = (locale: Locale, currency: string) => {
try {
const numberFormat = new Intl.NumberFormat(locale, {
style: "currency",
currency,
});
return numberFormat.resolvedOptions().maximumFractionDigits;
} catch (e) {
return 2;
}
};
export const moneyCellRenderer = (
locale: Locale,
): CustomCellRenderer<MoneyCell> => ({
isMatch: (c): c is MoneyCell => (c.data as any).kind === "money-cell",
draw: (args, cell) => {
const { ctx, theme, rect } = args;
const { currency, value } = cell.data;
const hasValue = value === 0 ? true : !!value;
const currencyFractionDigits = getFractionDigits(locale, currency);
const formatted =
value?.toLocaleString(locale, {
maximumFractionDigits: currencyFractionDigits,
minimumFractionDigits: currencyFractionDigits,
}) ?? "-";
ctx.fillStyle = theme.textDark;
ctx.textAlign = "right";
ctx.fillText(
formatted,
rect.x + rect.width - 8,
rect.y + rect.height / 2 + getMiddleCenterBias(ctx, theme),
);
ctx.save();
ctx.fillStyle = theme.textMedium;
ctx.textAlign = "left";
ctx.font = [
theme.baseFontStyle.replace(/bold/g, "normal"),
theme.fontFamily,
].join(" ");
ctx.fillText(
hasValue ? currency : "-",
rect.x + 8,
rect.y + rect.height / 2 + getMiddleCenterBias(ctx, theme),
);
ctx.restore();
return true;
},
provideEditor: () => ({
editor: MoneyCellEdit,
disablePadding: true,
deletedValue: cell => ({
...cell,
copyData: "",
data: {
...cell.data,
value: cell.data.value ?? null,
},
}),
}),
onPaste: (value, data) => ({
...data,
value: parseFloat(value),
}),
});

View file

@ -0,0 +1,74 @@
import {
CustomCell,
CustomCellRenderer,
getMiddleCenterBias,
ProvideEditorCallback,
} from "@glideapps/glide-data-grid";
import React from "react";
import { Locale } from "../Locale";
export const numberCellEmptyValue = Symbol();
interface NumberCellProps {
readonly kind: "number-cell";
readonly value: number | typeof numberCellEmptyValue;
}
export type NumberCell = CustomCell<NumberCellProps>;
const NumberCellEdit: ReturnType<ProvideEditorCallback<NumberCell>> = ({
value: cell,
onChange,
}) => (
<input
type="number"
onChange={event =>
onChange({
...cell,
data: {
...cell.data,
value: event.target.value ? parseFloat(event.target.value) : null,
},
})
}
value={cell.data.value === numberCellEmptyValue ? "" : cell.data.value}
autoFocus
/>
);
export const numberCellRenderer = (
locale: Locale,
): CustomCellRenderer<NumberCell> => ({
isMatch: (c): c is NumberCell => (c.data as any).kind === "number-cell",
draw: (args, cell) => {
const { ctx, theme, rect } = args;
const { value } = cell.data;
const formatted =
value === numberCellEmptyValue ? "-" : value.toLocaleString(locale);
ctx.fillStyle = theme.textDark;
ctx.textAlign = "right";
ctx.fillText(
formatted,
rect.x + rect.width - 8,
rect.y + rect.height / 2 + getMiddleCenterBias(ctx, theme),
);
return true;
},
provideEditor: () => ({
editor: NumberCellEdit,
disablePadding: true,
deletedValue: cell => ({
...cell,
copyData: "",
data: {
...cell.data,
value: numberCellEmptyValue,
},
}),
}),
onPaste: (value, data) => ({
...data,
value: value ? parseFloat(value) : numberCellEmptyValue,
}),
});

View file

@ -0,0 +1,82 @@
import { GridCell, GridCellKind } from "@glideapps/glide-data-grid";
import {
NumberCell,
numberCellEmptyValue,
} from "@saleor/components/Datagrid/NumberCell";
import {
DropdownCell,
DropdownCellContentProps,
DropdownChoice,
} from "./DropdownCell";
import { MoneyCell } from "./MoneyCell";
const common = {
allowOverlay: true,
readonly: false,
};
export function textCell(value: string): GridCell {
return {
...common,
data: value,
displayData: value,
kind: GridCellKind.Text,
};
}
export function booleanCell(value: boolean): GridCell {
return {
...common,
allowOverlay: false,
kind: GridCellKind.Boolean,
data: value,
};
}
export function numberCell(
value: number | typeof numberCellEmptyValue,
): NumberCell {
return {
...common,
data: {
kind: "number-cell",
value,
},
kind: GridCellKind.Custom,
copyData: value !== numberCellEmptyValue ? value.toString() : "",
};
}
export function moneyCell(value: number | null, currency: string): MoneyCell {
return {
...common,
kind: GridCellKind.Custom,
data: {
kind: "money-cell",
value,
currency,
},
copyData: value?.toString() ?? "",
};
}
export function dropdownCell(
value: DropdownChoice,
opts: DropdownCellContentProps &
(
| { choices: DropdownChoice[] }
| { update: (text: string) => Promise<DropdownChoice[]> }
),
): DropdownCell {
return {
...common,
data: {
...opts,
kind: "dropdown-cell",
value,
},
kind: GridCellKind.Custom,
copyData: value.label,
};
}

View file

@ -0,0 +1,74 @@
# Datagrid
Datagrid component is a wrapper for NPM's package `@glideapps/glide-data-grid`, providing easy (ok, at least easiest than implementing it from scratch) integration with Saleor Dashboard's forms.
## Forms
A good and easy example would be `src/components/Datagrid/Datagrid.stories.tsx` file as it represents minimal setup to get datagrid component up and running.
The most important thing is to provide React Context (avoid prop-drilling through multiple intermediate components) in form component definition:
```jsx
const Form: React.FC = ({ children }) => {
// This one is not needed by datagrid component and is put here just because
// we assumed it's a form component
const formProps = useForm({});
const datagridProps = useDatagridChangeState();
return (
<DatagridChangeStateContext.Provider value={changeProps}>
{children}
</DatagridChangeStateContext.Provider>
);
};
```
It creates a state which holds _all changes_ made in datagrid. Please not that it holds only changes and not whole state, which greatly simplifies pruning identity mutations (updating object with the same data). When the context is set, the next step begins.
## Accessors
Original component from `@glideapps/glide-data-grid` requires user to provide callback function to get data, instead of passing 2D array. [Docs can be found here](https://glideapps.github.io/glide-data-grid/?path=/story/glide-data-grid-docs--getting-started), but we introduced a few additional concepts.
- `getCellContent` is augmented with additional data denoted by following interface:
```typescript
export interface GetCellContentOpts {
changes: React.MutableRefObject<DatagridChange[]>;
added: number[];
removed: number[];
getChangeIndex: (column: string, row: number) => number;
}
```
It allows us to correctly map indices used by datagrid to original data from API (or any other source) and to display updated data instead of original one.
Note that `getCellContent` should return not only display value but all settings regarding particular cell. To make this easier and less mundane work, we created utility functions which can be found in `src/components/Datagrid/cells.ts` file.
- we introduces `getCellError` accessor to be able to map validation errors to their respective cells - this way we can handle partial save (let's say save 3 out of 4 updated variants) and display red background where validation failed. It uses the same `GetCellContentOpts` object as second argument as `getCellContent`.
## Adding and removing rows
Datagrid component fully supports adding and removing rows on the fly.
- Adding rows work out-of-the-box by passing `addButtonLabel` as prop to component.
- `selectionActions` prop is used for all actions that appear after selecting rows, such as deletion or publication. You can pass a list of buttons that use actions from `MenuItemsActions` interface. Currently only deleting rows is supported out of the box.
## API
After we set up form and accessors, we need a way to send this data to API. This will vary depending on available API (especially if we can use bulk update API or not). Implementation stitching five different mutations can be found in `src/products/views/ProductUpdate/handlers/useProductUpdateHandler.ts` file. It's important to reduce response size, time and render count by requesting only error list and refetch whole list after all mutations are fulfilled.
## Error Handling
This part is also greatly affected by the API as it requires mapping potentially various GraphQL objects to combination of row and column ID. What we found useful during implementing datagrid in variants list is creating objects, which contain data like variant ID, warehouse/channel ID and error code, which then `getCellError` can easily interpret and pin to particular cell. This code can be found in `src/products/views/ProductUpdate/handlers/errors.ts` file.
After successful submission to API we need to clean all changes that were saved and leave those which weren't. In `src/products/components/ProductUpdatePage/form.tsx` we can see that after submission changes are cleared if no error was found. Beacuse of possibility that data in one row can be saved using multiple mutations, developers should write their own logic to specify which fields were saved and which were not, given the list of errors.
## Summary
Wrapping everything up - to use datagrid component, you need to take care of:
1. connecting it to form
2. create accessors `getCellContent`
3. create save handler
4. update list of errors
5. clean changes array from already saved ones.

View file

@ -0,0 +1,172 @@
export const initialData = [
{
id: "1",
uuid: "627bd0e7476cd1000ecef6b4",
balance: { amount: 3067.88, currency: "EUR" },
age: 40,
eyeColor: "green",
name: "Melba Keller",
loan: {
active: true,
amount: 10.0,
currency: "USD",
},
job: {
label: "Engineer",
value: "eng",
},
},
{
id: "2",
uuid: "627bd0e763eb9e68f3800b9f",
balance: { amount: 3523.57, currency: "EUR" },
age: 39,
eyeColor: "green",
name: "Dixie Rivera",
loan: {
active: true,
amount: 151.0,
currency: "USD",
},
job: {
label: "Engineer",
value: "eng",
},
},
{
id: "3",
uuid: "627bd0e785b17c7a9b406c62",
balance: { amount: 3240.22, currency: "EUR" },
age: 38,
eyeColor: "brown",
name: "Lamb Clayton",
loan: {
active: true,
amount: 21.0,
currency: "USD",
},
job: {
label: "Director",
value: "director",
},
},
{
id: "4",
uuid: "627bd0e7c6460044c736e00a",
balance: { amount: 3450.59, currency: "EUR" },
age: 30,
eyeColor: "blue",
name: "Charity Mcgee",
loan: {
active: true,
amount: 52.0,
currency: "USD",
},
job: {
label: "QA",
value: "qa",
},
},
{
id: "5",
uuid: "627bd0e77792e92a1b00d09c",
balance: { amount: 3196.74, currency: "EUR" },
age: 39,
eyeColor: "brown",
name: "Ward Hayden",
loan: {
active: true,
amount: 991.0,
currency: "USD",
},
job: {
label: "QA",
value: "qa",
},
},
{
id: "6",
uuid: "627bd0e78ff0fc7e94570ecd",
balance: { amount: 2222.02, currency: "EUR" },
age: 30,
eyeColor: "blue",
name: "Emily Parrish",
loan: {
active: true,
amount: 82.0,
currency: "USD",
},
job: {
label: "QA",
value: "qa",
},
},
{
id: "7",
uuid: "627bd0e7d0b1a0050a83619b",
balance: { amount: 3558.55, currency: "EUR" },
age: 37,
eyeColor: "blue",
name: "Wood Hensley",
loan: {
active: false,
amount: null,
currency: "USD",
},
job: {
label: "Engineer",
value: "eng",
},
},
{
id: "8",
uuid: "627bd0e7138637a41c457158",
balance: { amount: 1955.85, currency: "EUR" },
age: 29,
eyeColor: "green",
name: "Karin Cote",
loan: {
active: true,
amount: 1.1,
currency: "USD",
},
job: {
label: "Designer",
value: "designer",
},
},
{
id: "9",
uuid: "627bd0e7c3502435bc459efe",
balance: { amount: -2358.54, currency: "EUR" },
age: 37,
eyeColor: "green",
name: "Roberta Rollins",
loan: {
active: true,
amount: 1000.0,
currency: "USD",
},
job: {
label: "Designer",
value: "designer",
},
},
{
id: "10",
uuid: "627bd0e7da2e10b6154b85cf",
balance: { amount: 2048.1, currency: "EUR" },
age: 26,
eyeColor: "green",
name: "Francis Talley",
loan: {
active: false,
amount: null,
currency: "USD",
},
job: {
label: "Manager",
value: "manager",
},
},
];

View file

@ -0,0 +1,211 @@
import { Theme } from "@glideapps/glide-data-grid";
import { Typography } from "@material-ui/core/styles/createTypography";
import { makeStyles, useTheme } from "@saleor/macaw-ui";
import { useMemo } from "react";
const useStyles = makeStyles(
theme => {
const rowActionSelected = {
background: theme.palette.background.paper,
color: theme.palette.saleor.main[1],
};
const activeBorderColor =
theme.palette.saleor.theme === "light" ? "#D4D4D4" : "#232323";
return {
actionBtnBar: {
position: "absolute",
zIndex: 1,
background: theme.palette.background.paper,
borderRadius: 8,
// Right and left toolbars
width: "calc(100% - 64px - 48px - 1px)",
marginTop: 1,
marginLeft: 50,
height: 48,
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
padding: theme.spacing(1),
},
btnContainer: {
display: "flex",
flexDirection: "row-reverse",
},
addBtn: {
marginBottom: theme.spacing(2),
},
columnPicker: {
display: "flex",
alignItems: "center",
justifyContent: "center",
height: 48,
},
columnPickerBtn: {
"&:hover": {
color: theme.palette.saleor.main[1],
},
},
portal: {
"& input::-webkit-outer-spin-button, input::-webkit-inner-spin-button": {
appearance: "none",
margin: 0,
},
"& input[type=number]": {
appearance: "textfield",
},
"& .clip-region": {
border: `1px solid ${theme.palette.saleor.main[1]}`,
},
"& .gdg-growing-entry": {
flex: 1,
marginTop: 0,
},
"& .gdg-style": {
background: theme.palette.background.paper,
border: "none",
// Setting these with !important because we never intend to style
// this particular element, like, never ever
boxShadow: "none !important",
padding: "0 !important",
},
"& input, & textarea": {
...theme.typography.body1,
appearance: "none",
background: "none",
border: "none",
fontSize: theme.typography.body1.fontSize,
letterSpacing: "0.44px",
padding: `1.4rem ${theme.spacing(1)}`,
outline: 0,
},
'& input[type="number"]': {
textAlign: "right",
width: "100%",
},
position: "fixed",
top: 0,
left: 0,
},
datagrid: {
"& .dvn-scroller": {
overscrollBehaviorX: "none",
},
borderTop: `1px solid ${theme.palette.divider}`,
borderBottom: `1px solid ${theme.palette.divider}`,
borderRadius: 0,
boxSizing: "content-box",
width: "100%",
paddingBottom: "1px",
},
root: {
position: "relative",
},
rowActionBar: {
height: "100%",
background: theme.palette.background.paper,
borderLeft: `1px solid ${activeBorderColor}`,
width: 48,
},
rowActionBarScrolledToRight: {
borderLeftColor: theme.palette.divider,
},
rowAction: {
"&:hover, $rowActionSelected": {
rowActionSelected,
},
"&:not(:last-child)": {
marginBottom: -1,
},
border: `1px solid ${theme.palette.divider}`,
borderLeftColor: activeBorderColor,
borderRight: "none",
cursor: "pointer",
color: theme.palette.saleor.main[5],
marginLeft: -1,
display: "flex",
alignItems: "center",
justifyContent: "center",
height: 47,
},
rowActionScrolledToRight: {
borderLeftColor: theme.palette.divider,
},
columnGroupFixer: {
position: "absolute",
top: 1,
left: 0,
height: 48,
width: 10,
borderLeft: 0,
background: theme.palette.background.paper,
},
editorContainer: {
position: "relative",
},
rowActionBarShadow: {
height: "100%",
width: 1,
position: "absolute",
zIndex: -1,
transition: theme.transitions.create("box-shadow", {
duration: theme.transitions.duration.short,
}),
boxShadow: "-1px 0px 12px transparent",
},
rowActionBarShadowActive: {
boxShadow: "-1px 0px 12px rgba(0, 0, 0, 0.80)",
},
rowActionSelected,
cardContentRoot: {
padding: "0 0 2.4rem 0",
},
};
},
{ name: "Datagrid" },
);
const calculateFontToPx = (remValue: string | number, base: number) => {
if (typeof remValue === "string") {
return `${parseFloat(remValue) * base}px`;
}
return `${remValue * base}px`;
};
type HtmlTypography = Typography & { htmlFontSize: number };
export function useDatagridTheme() {
const theme = useTheme();
const base = (theme.typography as HtmlTypography).htmlFontSize * 0.625;
const datagridTheme = useMemo(
(): Partial<Theme> => ({
accentColor: theme.palette.primary.main,
accentLight: theme.palette.background.default,
accentFg: "transparent",
bgCell: theme.palette.background.paper,
bgHeader: theme.palette.background.paper,
bgHeaderHasFocus: theme.palette.background.paper,
bgHeaderHovered: theme.palette.background.paper,
bgBubbleSelected: theme.palette.background.paper,
textHeader: theme.palette.text.secondary,
borderColor: theme.palette.divider,
fontFamily: theme.typography.fontFamily,
baseFontStyle: calculateFontToPx(theme.typography.body1.fontSize, base),
headerFontStyle: calculateFontToPx(theme.typography.body2.fontSize, base),
editorFontSize: calculateFontToPx(theme.typography.body1.fontSize, base),
textMedium: theme.palette.text.primary,
textGroupHeader: theme.palette.text.secondary,
textBubble: theme.palette.text.primary,
textDark: theme.palette.text.primary,
textLight: theme.palette.text.primary,
}),
[theme],
);
return datagridTheme;
}
export default useStyles;

View file

@ -0,0 +1,6 @@
export interface AvailableColumn {
id: string;
title: string;
width: number;
group?: string;
}

View file

@ -0,0 +1,23 @@
import { useCustomCells } from "@glideapps/glide-data-grid";
import useLocale from "@saleor/hooks/useLocale";
import { useMemo } from "react";
import { dropdownCellRenderer } from "./DropdownCell";
import { moneyCellRenderer } from "./MoneyCell";
import { numberCellRenderer } from "./NumberCell";
function useCells() {
const { locale } = useLocale();
const value = useMemo(
() => [
moneyCellRenderer(locale),
numberCellRenderer(locale),
dropdownCellRenderer,
],
[locale],
);
return useCustomCells(value);
}
export default useCells;

View file

@ -0,0 +1,85 @@
import { GridColumn } from "@glideapps/glide-data-grid";
import useStateFromProps from "@saleor/hooks/useStateFromProps";
import { addAtIndex, removeAtIndex } from "@saleor/utils/lists";
import { useCallback, useMemo, useState } from "react";
import { AvailableColumn } from "./types";
function useColumns(availableColumns: readonly AvailableColumn[]) {
const [query, setQuery] = useState("");
const [displayedColumns, setDisplayedColumns] = useStateFromProps(
availableColumns.map(({ id }) => id),
);
const [columnState, setColumnState] = useStateFromProps(availableColumns);
const onColumnMoved = useCallback(
(startIndex: number, endIndex: number): void => {
setDisplayedColumns(old =>
addAtIndex(old[startIndex], removeAtIndex(old, startIndex), endIndex),
);
},
[],
);
const onColumnResize = useCallback(
(column: GridColumn, newSize: number) =>
setColumnState(prevColumns =>
prevColumns.map(prevColumn =>
prevColumn.id === column.id
? { ...prevColumn, width: newSize }
: prevColumn,
),
),
[],
);
const onColumnsChange = useCallback(
(picked: string[]) =>
setDisplayedColumns(prevColumns => [
...prevColumns.filter(column => picked.includes(column)),
...picked
.filter(column => !prevColumns.find(c => c === column))
.map(column => availableColumns.find(ac => ac.id === column).id),
]),
[availableColumns],
);
const columns = useMemo(
() => displayedColumns.map(id => columnState.find(ac => ac.id === id)),
[displayedColumns, columnState],
);
const columnChoices = useMemo(
() =>
columns.map(({ id, title }) => ({
label: title,
value: id,
})),
[columns],
);
const availableColumnsChoices = useMemo(
() =>
availableColumns.map(({ id, title }) => ({
label: title,
value: id,
})),
[availableColumns],
);
const defaultColumns = useMemo(() => availableColumns.map(({ id }) => id), [
availableColumns,
]);
return {
availableColumnsChoices,
columns,
columnChoices,
defaultColumns,
displayedColumns,
onColumnMoved,
onColumnResize,
onColumnsChange,
picker: {
query,
setQuery,
},
};
}
export default useColumns;

View file

@ -0,0 +1,131 @@
import { act, renderHook } from "@testing-library/react-hooks";
import React from "react";
import { AvailableColumn } from "./types";
import useDatagridChange, {
DatagridChangeStateContext,
useDatagridChangeState,
} from "./useDatagridChange";
const columns: AvailableColumn[] = [
{ id: "name", title: "Name", width: 100 },
{ id: "sku", title: "SKU", width: 100 },
{ id: "size", title: "Size", width: 100 },
];
const GridContext = ({ children }) => {
const stateProps = useDatagridChangeState();
return (
<DatagridChangeStateContext.Provider value={stateProps}>
{children}
</DatagridChangeStateContext.Provider>
);
};
function setupHook() {
return renderHook(() => useDatagridChange(columns, 10), {
wrapper: GridContext,
});
}
describe("useDatagridChange", () => {
it("properly adds row", () => {
const { result } = setupHook();
act(result.current.onRowAdded);
expect(result.current.added).toHaveLength(1);
expect(result.current.added[0]).toBe(10);
});
it("properly removes rows", () => {
const { result } = setupHook();
act(() => {
result.current.onRowsRemoved([7]);
});
act(() => {
result.current.onRowsRemoved([7]);
});
expect(result.current.removed).toHaveLength(2);
expect(result.current.removed[0]).toBe(7);
expect(result.current.removed[1]).toBe(8);
});
it("properly removes added rows", () => {
const { result } = setupHook();
act(result.current.onRowAdded);
act(() => {
result.current.onRowsRemoved([7]);
});
act(() => {
result.current.onRowsRemoved([9]);
});
expect(result.current.added).toHaveLength(0);
expect(result.current.removed).toHaveLength(1);
expect(result.current.removed[0]).toBe(7);
});
it("properly removes added rows 2", () => {
const { result } = setupHook();
act(result.current.onRowAdded);
act(result.current.onRowAdded);
act(() => {
result.current.onRowsRemoved([10]);
});
expect(result.current.added).toHaveLength(1);
expect(result.current.added[0]).toBe(10);
expect(result.current.removed).toHaveLength(0);
});
it("properly removes added rows 3", () => {
const { result } = setupHook();
act(result.current.onRowAdded);
act(result.current.onRowAdded);
act(() => {
result.current.onRowsRemoved([10]);
});
act(() => {
result.current.onRowsRemoved([10]);
});
expect(result.current.added).toHaveLength(0);
expect(result.current.removed).toHaveLength(0);
});
it("properly updates changes after row removal", () => {
const { result } = setupHook();
act(() => {
result.current.onCellEdited([1, 1], {} as any);
});
act(() => {
result.current.onRowsRemoved([0]);
});
expect(result.current.changes.current).toHaveLength(1);
expect(result.current.changes.current[0].row).toBe(0);
});
it("properly updates changes after row removal 2", () => {
const { result } = setupHook();
act(result.current.onRowAdded);
act(() => {
result.current.onCellEdited([1, 10], {} as any);
});
act(() => {
result.current.onRowsRemoved([0]);
});
expect(result.current.changes.current).toHaveLength(1);
expect(result.current.changes.current[0].row).toBe(9);
});
});

View file

@ -0,0 +1,147 @@
import { EditableGridCell, Item } from "@glideapps/glide-data-grid";
import { updateAtIndex } from "@saleor/utils/lists";
import {
createContext,
Dispatch,
MutableRefObject,
SetStateAction,
useCallback,
useContext,
useRef,
useState,
} from "react";
import { AvailableColumn } from "./types";
export interface DatagridChange {
data: any;
row: number;
column: string;
}
export interface DatagridChangeOpts {
added: number[];
removed: number[];
updates: DatagridChange[];
}
export type OnDatagridChange = (opts: DatagridChangeOpts) => void;
export interface UseDatagridChangeState {
added: number[];
setAdded: Dispatch<SetStateAction<number[]>>;
removed: number[];
setRemoved: Dispatch<SetStateAction<number[]>>;
changes: MutableRefObject<DatagridChange[]>;
}
export function useDatagridChangeState(): UseDatagridChangeState {
const [added, setAdded] = useState<number[]>([]);
const [removed, setRemoved] = useState<number[]>([]);
const changes = useRef<DatagridChange[]>([]);
return {
added,
setAdded,
removed,
setRemoved,
changes,
};
}
export const DatagridChangeStateContext = createContext<UseDatagridChangeState>(
undefined,
);
export const useDatagridChangeStateContext = () =>
useContext(DatagridChangeStateContext);
function useDatagridChange(
availableColumns: readonly AvailableColumn[],
rows: number,
onChange?: OnDatagridChange,
) {
const {
added,
setAdded,
removed,
setRemoved,
changes,
} = useDatagridChangeStateContext();
const getChangeIndex = useCallback(
(column: string, row: number): number =>
changes.current.findIndex(
change => change.column === column && change.row === row,
),
[],
);
const notify = useCallback(
(updates: DatagridChange[], added: number[], removed: number[]) => {
if (onChange) {
onChange({
updates,
removed,
added,
});
}
},
[onChange],
);
const onCellEdited = useCallback(
([column, row]: Item, newValue: EditableGridCell): void => {
const columnId = availableColumns[column].id;
const existingIndex = getChangeIndex(columnId, row);
const update = { data: newValue.data, column: columnId, row };
changes.current =
existingIndex === -1
? [...changes.current, update]
: updateAtIndex(update, changes.current, existingIndex);
notify(changes.current, added, removed);
},
[availableColumns, notify, added, removed],
);
const onRowsRemoved = useCallback(
(rows: number[]) => {
const getRowOffset = (row: number) => rows.filter(r => r < row).length;
const newAdded = added
.filter(row => !rows.includes(row))
.map(row => row - getRowOffset(row));
const newRemoved = [
...removed,
...rows
.filter(row => !added.includes(row))
.map(row => row + removed.filter(r => r <= row).length),
];
setRemoved(newRemoved);
changes.current = changes.current
.filter(change => !rows.includes(change.row))
.map(change => ({
...change,
row: change.row - getRowOffset(change.row),
}));
setAdded(newAdded);
notify(changes.current, newAdded, newRemoved);
},
[added, removed, notify],
);
const onRowAdded = useCallback(() => {
const newAdded = [...added, rows - removed.length + added.length];
setAdded(newAdded);
notify(changes.current, newAdded, removed);
}, [added, notify, removed, rows]);
return {
added,
changes,
removed,
getChangeIndex,
onCellEdited,
onRowsRemoved,
onRowAdded,
};
}
export default useDatagridChange;

View file

@ -20,7 +20,7 @@ const useStyles = makeStyles(
},
root: {
"& > div": {
overflow: "hidden",
minWidth: 0,
},
display: "grid",
gridColumnGap: theme.spacing(3),
@ -33,11 +33,8 @@ const useStyles = makeStyles(
uniform: {
gridTemplateColumns: "1fr 1fr",
},
richText: {
"&& > div": {
overflow: "visible",
},
},
// TODO: Remove me
richText: {},
}),
{ name: "Grid" },
);

View file

@ -1,12 +1,9 @@
import { InputAdornment, TextField, TextFieldProps } from "@material-ui/core";
import { InputAdornment, TextField } from "@material-ui/core";
import { InputProps } from "@material-ui/core/Input";
import { FormChange } from "@saleor/hooks/useForm";
import { makeStyles } from "@saleor/macaw-ui";
import React, { useMemo } from "react";
import { FormattedMessage } from "react-intl";
import React from "react";
import { SEPARATOR_CHARACTERS } from "./consts";
import { findPriceSeparator, getCurrencyDecimalPoints } from "./utils";
import { usePriceField } from "./usePriceField";
const useStyles = makeStyles(
theme => ({
@ -40,7 +37,7 @@ interface PriceFieldProps {
hint?: string;
label?: string;
name?: string;
value?: string | number;
value?: string;
InputProps?: InputProps;
inputProps?: InputProps["inputProps"];
required?: boolean;
@ -56,7 +53,7 @@ export const PriceField: React.FC<PriceFieldProps> = props => {
hint = "",
currencySymbol,
name,
onChange,
onChange: onChangeBase,
required,
value,
InputProps,
@ -64,66 +61,16 @@ export const PriceField: React.FC<PriceFieldProps> = props => {
} = props;
const classes = useStyles(props);
const minValue = 0;
const maxDecimalLength = useMemo(
() => getCurrencyDecimalPoints(currencySymbol),
[currencySymbol],
const { onChange, onKeyDown, minValue, step } = usePriceField(
currencySymbol,
onChangeBase,
);
const handleChange: FormChange = e => {
let value = e.target.value;
const splitCharacter = findPriceSeparator(value);
const [integerPart, decimalPart] = value.split(splitCharacter);
if (maxDecimalLength === 0 && decimalPart) {
// this shouldn't happen - decimal character should be ignored
value = integerPart;
}
if (decimalPart?.length > maxDecimalLength) {
const shortenedDecimalPart = decimalPart.slice(0, maxDecimalLength);
value = `${integerPart}${splitCharacter}${shortenedDecimalPart}`;
}
onChange({
target: {
name: e.target.name,
value,
},
});
};
const handleKeyPress: TextFieldProps["onKeyDown"] = e => {
// disallow entering e (exponent)
if (e.key === "e" || e.key === "E") {
e.preventDefault();
}
// ignore separator input when currency doesn't support decimal values
if (
maxDecimalLength === 0 &&
SEPARATOR_CHARACTERS.some(separator => e.key === separator)
) {
e.preventDefault();
}
};
return (
<TextField
className={className}
error={error || value < minValue}
helperText={
hint ? (
hint
) : value < minValue ? (
<FormattedMessage
id="WHkx+F"
defaultMessage="Price cannot be lower than 0"
/>
) : (
""
)
}
error={error}
helperText={hint}
label={label}
fullWidth
value={value}
@ -138,7 +85,7 @@ export const PriceField: React.FC<PriceFieldProps> = props => {
),
inputProps: {
min: 0,
step: 1 / Math.pow(10, maxDecimalLength),
step,
...InputProps?.inputProps,
},
type: "number",
@ -151,8 +98,8 @@ export const PriceField: React.FC<PriceFieldProps> = props => {
name={name}
disabled={disabled}
required={required}
onChange={handleChange}
onKeyDown={handleKeyPress}
onChange={onChange}
onKeyDown={onKeyDown}
/>
);
};

View file

@ -0,0 +1,60 @@
import { TextFieldProps } from "@material-ui/core";
import { FormChange } from "@saleor/hooks/useForm";
import { useMemo } from "react";
import { SEPARATOR_CHARACTERS } from "./consts";
import { findPriceSeparator, getCurrencyDecimalPoints } from "./utils";
export function usePriceField(currency: string, onChange: FormChange) {
const minValue = 0;
const maxDecimalLength = useMemo(() => getCurrencyDecimalPoints(currency), [
currency,
]);
const handleChange: FormChange = e => {
let value = e.target.value;
const splitCharacter = findPriceSeparator(value);
const [integerPart, decimalPart] = value.split(splitCharacter);
if (maxDecimalLength === 0 && decimalPart) {
// This shouldn't happen - decimal character should be ignored
value = integerPart;
}
if (decimalPart?.length > maxDecimalLength) {
const shortenedDecimalPart = decimalPart.slice(0, maxDecimalLength);
value = `${integerPart}${splitCharacter}${shortenedDecimalPart}`;
}
onChange({
target: {
name: e.target.name,
value: value ? parseFloat(value) : null,
},
});
};
const handleKeyDown: TextFieldProps["onKeyDown"] = e => {
// Disallow entering e (exponent)
if (e.key === "e" || e.key === "E" || e.key === "-") {
e.preventDefault();
}
// ignore separator input when currency doesn't support decimal values
if (
maxDecimalLength === 0 &&
SEPARATOR_CHARACTERS.some(separator => e.key === separator)
) {
e.preventDefault();
}
};
const step = 1 / Math.pow(10, maxDecimalLength);
return {
onChange: handleChange,
onKeyDown: handleKeyDown,
minValue,
step,
};
}

View file

@ -92,10 +92,10 @@ const SingleAutocompleteSelectFieldComponent: React.FC<SingleAutocompleteSelectF
return (
<DebounceAutocomplete debounceFn={fetchChoices}>
{debounceFn => (
{fetchChoicesDebounced => (
<Downshift
itemToString={() => displayValue || ""}
onInputValueChange={value => debounceFn(value)}
onInputValueChange={value => fetchChoicesDebounced(value)}
onSelect={handleChange}
selectedItem={value || ""}
// this is to prevent unwanted state updates when the dropdown is closed with an empty value,
@ -213,7 +213,11 @@ const SingleAutocompleteSelectFieldComponent: React.FC<SingleAutocompleteSelectF
return (
<div
className={classNames(classes.container, className)}
className={classNames(
classes.container,
"click-outside-ignore",
className,
)}
{...rest}
>
<TextFieldComponent

View file

@ -204,7 +204,12 @@ const SingleAutocompleteSelectFieldContent: React.FC<SingleAutocompleteSelectFie
const choicesToDisplay = choices.slice(0, slice);
return (
<Paper className={classes.root} elevation={8} style={style}>
<Paper
// click-outside-ignore is used by glide-datagrid
className={classNames("click-outside-ignore", classes.root)}
elevation={8}
style={style}
>
<div
className={classes.content}
ref={anchor}

View file

@ -147,7 +147,7 @@ export const productVariantAttributesFragment = gql`
}
productType {
id
variantAttributes(variantSelection: VARIANT_SELECTION) {
variantAttributes {
id
name
inputType
@ -178,7 +178,16 @@ export const productDetailsVariant = gql`
id
sku
name
margin
attributes {
attribute {
id
name
}
values {
id
name
}
}
media {
url(size: 200)
}
@ -308,6 +317,7 @@ export const fragmentVariant = gql`
url
}
channelListings {
id
publicationDate
isPublished
channel {

View file

@ -1,6 +1,7 @@
// DON'T TOUCH THIS
// These are separate clients and do not share configs between themselves
import { ApolloClient, InMemoryCache } from "@apollo/client";
import { ApolloLink } from "@apollo/client/link/core";
import { createFetch, createSaleorClient } from "@saleor/sdk";
import { createUploadLink } from "apollo-upload-client";
@ -8,11 +9,23 @@ import { API_URI } from "../config";
import introspectionQueryResultData from "./fragmentTypes.generated";
import { TypedTypePolicies } from "./typePolicies.generated";
export const link = createUploadLink({
const attachVariablesLink = new ApolloLink((operation, forward) =>
forward(operation).map(data => ({
...data,
extensions: {
...data.extensions,
variables: operation.variables,
},
})),
);
export const link = attachVariablesLink.concat(
createUploadLink({
credentials: "include",
uri: API_URI,
fetch: createFetch(),
});
}),
);
export const apolloClient = new ApolloClient({
cache: new InMemoryCache({

View file

@ -1901,7 +1901,7 @@ export const ProductVariantAttributesFragmentDoc = gql`
}
productType {
id
variantAttributes(variantSelection: VARIANT_SELECTION) {
variantAttributes {
id
name
inputType
@ -1968,7 +1968,16 @@ export const ProductDetailsVariantFragmentDoc = gql`
id
sku
name
margin
attributes {
attribute {
id
name
}
values {
id
name
}
}
media {
url(size: 200)
}
@ -2113,6 +2122,7 @@ export const ProductVariantFragmentDoc = gql`
url
}
channelListings {
id
publicationDate
isPublished
channel {
@ -11272,18 +11282,14 @@ export type ProductVariantSetDefaultMutationHookResult = ReturnType<typeof usePr
export type ProductVariantSetDefaultMutationResult = Apollo.MutationResult<Types.ProductVariantSetDefaultMutation>;
export type ProductVariantSetDefaultMutationOptions = Apollo.BaseMutationOptions<Types.ProductVariantSetDefaultMutation, Types.ProductVariantSetDefaultMutationVariables>;
export const ProductUpdateDocument = gql`
mutation ProductUpdate($id: ID!, $input: ProductInput!, $firstValues: Int, $afterValues: String, $lastValues: Int, $beforeValues: String) {
mutation ProductUpdate($id: ID!, $input: ProductInput!) {
productUpdate(id: $id, input: $input) {
errors {
...ProductErrorWithAttributes
}
product {
...Product
}
}
}
${ProductErrorWithAttributesFragmentDoc}
${ProductFragmentDoc}`;
${ProductErrorWithAttributesFragmentDoc}`;
export type ProductUpdateMutationFn = Apollo.MutationFunction<Types.ProductUpdateMutation, Types.ProductUpdateMutationVariables>;
/**
@ -11301,10 +11307,6 @@ export type ProductUpdateMutationFn = Apollo.MutationFunction<Types.ProductUpdat
* variables: {
* id: // value for 'id'
* input: // value for 'input'
* firstValues: // value for 'firstValues'
* afterValues: // value for 'afterValues'
* lastValues: // value for 'lastValues'
* beforeValues: // value for 'beforeValues'
* },
* });
*/
@ -11315,93 +11317,6 @@ export function useProductUpdateMutation(baseOptions?: ApolloReactHooks.Mutation
export type ProductUpdateMutationHookResult = ReturnType<typeof useProductUpdateMutation>;
export type ProductUpdateMutationResult = Apollo.MutationResult<Types.ProductUpdateMutation>;
export type ProductUpdateMutationOptions = Apollo.BaseMutationOptions<Types.ProductUpdateMutation, Types.ProductUpdateMutationVariables>;
export const SimpleProductUpdateDocument = gql`
mutation SimpleProductUpdate($id: ID!, $input: ProductInput!, $productVariantId: ID!, $productVariantInput: ProductVariantInput!, $addStocks: [StockInput!]!, $deleteStocks: [ID!]!, $updateStocks: [StockInput!]!, $firstValues: Int, $afterValues: String, $lastValues: Int, $beforeValues: String) {
productUpdate(id: $id, input: $input) {
errors {
...ProductErrorWithAttributes
}
product {
...Product
}
}
productVariantUpdate(id: $productVariantId, input: $productVariantInput) {
errors {
...ProductErrorWithAttributes
}
productVariant {
...ProductVariant
}
}
productVariantStocksCreate(stocks: $addStocks, variantId: $productVariantId) {
errors {
...BulkStockError
}
productVariant {
...ProductVariant
}
}
productVariantStocksDelete(
warehouseIds: $deleteStocks
variantId: $productVariantId
) {
errors {
...StockError
}
productVariant {
...ProductVariant
}
}
productVariantStocksUpdate(stocks: $updateStocks, variantId: $productVariantId) {
errors {
...BulkStockError
}
productVariant {
...ProductVariant
}
}
}
${ProductErrorWithAttributesFragmentDoc}
${ProductFragmentDoc}
${ProductVariantFragmentDoc}
${BulkStockErrorFragmentDoc}
${StockErrorFragmentDoc}`;
export type SimpleProductUpdateMutationFn = Apollo.MutationFunction<Types.SimpleProductUpdateMutation, Types.SimpleProductUpdateMutationVariables>;
/**
* __useSimpleProductUpdateMutation__
*
* To run a mutation, you first call `useSimpleProductUpdateMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useSimpleProductUpdateMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [simpleProductUpdateMutation, { data, loading, error }] = useSimpleProductUpdateMutation({
* variables: {
* id: // value for 'id'
* input: // value for 'input'
* productVariantId: // value for 'productVariantId'
* productVariantInput: // value for 'productVariantInput'
* addStocks: // value for 'addStocks'
* deleteStocks: // value for 'deleteStocks'
* updateStocks: // value for 'updateStocks'
* firstValues: // value for 'firstValues'
* afterValues: // value for 'afterValues'
* lastValues: // value for 'lastValues'
* beforeValues: // value for 'beforeValues'
* },
* });
*/
export function useSimpleProductUpdateMutation(baseOptions?: ApolloReactHooks.MutationHookOptions<Types.SimpleProductUpdateMutation, Types.SimpleProductUpdateMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return ApolloReactHooks.useMutation<Types.SimpleProductUpdateMutation, Types.SimpleProductUpdateMutationVariables>(SimpleProductUpdateDocument, options);
}
export type SimpleProductUpdateMutationHookResult = ReturnType<typeof useSimpleProductUpdateMutation>;
export type SimpleProductUpdateMutationResult = Apollo.MutationResult<Types.SimpleProductUpdateMutation>;
export type SimpleProductUpdateMutationOptions = Apollo.BaseMutationOptions<Types.SimpleProductUpdateMutation, Types.SimpleProductUpdateMutationVariables>;
export const ProductCreateDocument = gql`
mutation ProductCreate($input: ProductCreateInput!) {
productCreate(input: $input) {
@ -11478,6 +11393,121 @@ export function useVariantDeleteMutation(baseOptions?: ApolloReactHooks.Mutation
export type VariantDeleteMutationHookResult = ReturnType<typeof useVariantDeleteMutation>;
export type VariantDeleteMutationResult = Apollo.MutationResult<Types.VariantDeleteMutation>;
export type VariantDeleteMutationOptions = Apollo.BaseMutationOptions<Types.VariantDeleteMutation, Types.VariantDeleteMutationVariables>;
export const VariantDatagridUpdateDocument = gql`
mutation VariantDatagridUpdate($id: ID!, $input: ProductVariantInput!) {
productVariantUpdate(id: $id, input: $input) {
errors {
...ProductErrorWithAttributes
}
}
}
${ProductErrorWithAttributesFragmentDoc}`;
export type VariantDatagridUpdateMutationFn = Apollo.MutationFunction<Types.VariantDatagridUpdateMutation, Types.VariantDatagridUpdateMutationVariables>;
/**
* __useVariantDatagridUpdateMutation__
*
* To run a mutation, you first call `useVariantDatagridUpdateMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useVariantDatagridUpdateMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [variantDatagridUpdateMutation, { data, loading, error }] = useVariantDatagridUpdateMutation({
* variables: {
* id: // value for 'id'
* input: // value for 'input'
* },
* });
*/
export function useVariantDatagridUpdateMutation(baseOptions?: ApolloReactHooks.MutationHookOptions<Types.VariantDatagridUpdateMutation, Types.VariantDatagridUpdateMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return ApolloReactHooks.useMutation<Types.VariantDatagridUpdateMutation, Types.VariantDatagridUpdateMutationVariables>(VariantDatagridUpdateDocument, options);
}
export type VariantDatagridUpdateMutationHookResult = ReturnType<typeof useVariantDatagridUpdateMutation>;
export type VariantDatagridUpdateMutationResult = Apollo.MutationResult<Types.VariantDatagridUpdateMutation>;
export type VariantDatagridUpdateMutationOptions = Apollo.BaseMutationOptions<Types.VariantDatagridUpdateMutation, Types.VariantDatagridUpdateMutationVariables>;
export const VariantDatagridStockUpdateDocument = gql`
mutation VariantDatagridStockUpdate($stocks: [StockInput!]!, $removeStocks: [ID!]!, $id: ID!) {
productVariantStocksDelete(warehouseIds: $removeStocks, variantId: $id) {
errors {
...ProductVariantStocksDeleteError
}
}
productVariantStocksUpdate(stocks: $stocks, variantId: $id) {
errors {
...BulkStockError
}
}
}
${ProductVariantStocksDeleteErrorFragmentDoc}
${BulkStockErrorFragmentDoc}`;
export type VariantDatagridStockUpdateMutationFn = Apollo.MutationFunction<Types.VariantDatagridStockUpdateMutation, Types.VariantDatagridStockUpdateMutationVariables>;
/**
* __useVariantDatagridStockUpdateMutation__
*
* To run a mutation, you first call `useVariantDatagridStockUpdateMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useVariantDatagridStockUpdateMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [variantDatagridStockUpdateMutation, { data, loading, error }] = useVariantDatagridStockUpdateMutation({
* variables: {
* stocks: // value for 'stocks'
* removeStocks: // value for 'removeStocks'
* id: // value for 'id'
* },
* });
*/
export function useVariantDatagridStockUpdateMutation(baseOptions?: ApolloReactHooks.MutationHookOptions<Types.VariantDatagridStockUpdateMutation, Types.VariantDatagridStockUpdateMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return ApolloReactHooks.useMutation<Types.VariantDatagridStockUpdateMutation, Types.VariantDatagridStockUpdateMutationVariables>(VariantDatagridStockUpdateDocument, options);
}
export type VariantDatagridStockUpdateMutationHookResult = ReturnType<typeof useVariantDatagridStockUpdateMutation>;
export type VariantDatagridStockUpdateMutationResult = Apollo.MutationResult<Types.VariantDatagridStockUpdateMutation>;
export type VariantDatagridStockUpdateMutationOptions = Apollo.BaseMutationOptions<Types.VariantDatagridStockUpdateMutation, Types.VariantDatagridStockUpdateMutationVariables>;
export const VariantDatagridChannelListingUpdateDocument = gql`
mutation VariantDatagridChannelListingUpdate($id: ID!, $input: [ProductVariantChannelListingAddInput!]!) {
productVariantChannelListingUpdate(id: $id, input: $input) {
errors {
...ProductChannelListingError
}
}
}
${ProductChannelListingErrorFragmentDoc}`;
export type VariantDatagridChannelListingUpdateMutationFn = Apollo.MutationFunction<Types.VariantDatagridChannelListingUpdateMutation, Types.VariantDatagridChannelListingUpdateMutationVariables>;
/**
* __useVariantDatagridChannelListingUpdateMutation__
*
* To run a mutation, you first call `useVariantDatagridChannelListingUpdateMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useVariantDatagridChannelListingUpdateMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [variantDatagridChannelListingUpdateMutation, { data, loading, error }] = useVariantDatagridChannelListingUpdateMutation({
* variables: {
* id: // value for 'id'
* input: // value for 'input'
* },
* });
*/
export function useVariantDatagridChannelListingUpdateMutation(baseOptions?: ApolloReactHooks.MutationHookOptions<Types.VariantDatagridChannelListingUpdateMutation, Types.VariantDatagridChannelListingUpdateMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return ApolloReactHooks.useMutation<Types.VariantDatagridChannelListingUpdateMutation, Types.VariantDatagridChannelListingUpdateMutationVariables>(VariantDatagridChannelListingUpdateDocument, options);
}
export type VariantDatagridChannelListingUpdateMutationHookResult = ReturnType<typeof useVariantDatagridChannelListingUpdateMutation>;
export type VariantDatagridChannelListingUpdateMutationResult = Apollo.MutationResult<Types.VariantDatagridChannelListingUpdateMutation>;
export type VariantDatagridChannelListingUpdateMutationOptions = Apollo.BaseMutationOptions<Types.VariantDatagridChannelListingUpdateMutation, Types.VariantDatagridChannelListingUpdateMutationVariables>;
export const VariantUpdateDocument = gql`
mutation VariantUpdate($addStocks: [StockInput!]!, $removeStocks: [ID!]!, $id: ID!, $attributes: [AttributeValueInput!], $sku: String, $quantityLimitPerCustomer: Int, $trackInventory: Boolean!, $stocks: [StockInput!]!, $preorder: PreorderSettingsInput, $weight: WeightScalar, $firstValues: Int, $afterValues: String, $lastValues: Int, $beforeValues: String) {
productVariantStocksDelete(warehouseIds: $removeStocks, variantId: $id) {
@ -11842,6 +11872,45 @@ export function useProductBulkDeleteMutation(baseOptions?: ApolloReactHooks.Muta
export type ProductBulkDeleteMutationHookResult = ReturnType<typeof useProductBulkDeleteMutation>;
export type ProductBulkDeleteMutationResult = Apollo.MutationResult<Types.ProductBulkDeleteMutation>;
export type ProductBulkDeleteMutationOptions = Apollo.BaseMutationOptions<Types.ProductBulkDeleteMutation, Types.ProductBulkDeleteMutationVariables>;
export const ProductVariantBulkCreateDocument = gql`
mutation ProductVariantBulkCreate($id: ID!, $inputs: [ProductVariantBulkCreateInput!]!) {
productVariantBulkCreate(product: $id, variants: $inputs) {
errors {
...BulkProductError
}
productVariants {
id
}
}
}
${BulkProductErrorFragmentDoc}`;
export type ProductVariantBulkCreateMutationFn = Apollo.MutationFunction<Types.ProductVariantBulkCreateMutation, Types.ProductVariantBulkCreateMutationVariables>;
/**
* __useProductVariantBulkCreateMutation__
*
* To run a mutation, you first call `useProductVariantBulkCreateMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useProductVariantBulkCreateMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [productVariantBulkCreateMutation, { data, loading, error }] = useProductVariantBulkCreateMutation({
* variables: {
* id: // value for 'id'
* inputs: // value for 'inputs'
* },
* });
*/
export function useProductVariantBulkCreateMutation(baseOptions?: ApolloReactHooks.MutationHookOptions<Types.ProductVariantBulkCreateMutation, Types.ProductVariantBulkCreateMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return ApolloReactHooks.useMutation<Types.ProductVariantBulkCreateMutation, Types.ProductVariantBulkCreateMutationVariables>(ProductVariantBulkCreateDocument, options);
}
export type ProductVariantBulkCreateMutationHookResult = ReturnType<typeof useProductVariantBulkCreateMutation>;
export type ProductVariantBulkCreateMutationResult = Apollo.MutationResult<Types.ProductVariantBulkCreateMutation>;
export type ProductVariantBulkCreateMutationOptions = Apollo.BaseMutationOptions<Types.ProductVariantBulkCreateMutation, Types.ProductVariantBulkCreateMutationVariables>;
export const ProductVariantBulkDeleteDocument = gql`
mutation ProductVariantBulkDelete($ids: [ID!]!) {
productVariantBulkDelete(ids: $ids) {
@ -11919,25 +11988,11 @@ export type ProductExportMutationOptions = Apollo.BaseMutationOptions<Types.Prod
export const ProductChannelListingUpdateDocument = gql`
mutation ProductChannelListingUpdate($id: ID!, $input: ProductChannelListingUpdateInput!) {
productChannelListingUpdate(id: $id, input: $input) {
product {
id
channelListings {
...ChannelListingProductWithoutPricing
}
variants {
id
channelListings {
...ChannelListingProductVariant
}
}
}
errors {
...ProductChannelListingError
}
}
}
${ChannelListingProductWithoutPricingFragmentDoc}
${ChannelListingProductVariantFragmentDoc}
${ProductChannelListingErrorFragmentDoc}`;
export type ProductChannelListingUpdateMutationFn = Apollo.MutationFunction<Types.ProductChannelListingUpdateMutation, Types.ProductChannelListingUpdateMutationVariables>;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4,9 +4,9 @@ import { MessageContext } from "@saleor/components/messages";
import { SubmitPromise } from "@saleor/hooks/useForm";
import { useContext } from "react";
interface UseHandleFormSubmitProps<TData, TErrors> {
interface UseHandleFormSubmitProps<TData, TError> {
formId?: FormId;
onSubmit: (data: TData) => SubmitPromise<TErrors[]> | void;
onSubmit: (data: TData) => SubmitPromise<TError[]> | void;
}
function useHandleFormSubmit<TData, TErrors>({

View file

@ -0,0 +1,35 @@
import React, { useRef } from "react";
/**
* Hook that prevents hostory-back when use touchpad on Mac.
* Since overscroll-behavior sometimes may not work (eg. Safari 15 which is in use)
* We need to handle this in JS
*
* https://caniuse.com/css-overscroll-behavior
*/
export const usePreventHistoryBack = (scroller: HTMLDivElement) => {
const offsetY = useRef(0);
const wheelHandler = evt => {
const notVertival = Math.abs(evt.deltaX) - Math.abs(evt.deltaY) >= 0;
if (evt.target.scrollLeft <= 0 && evt.deltaX <= 0 && notVertival) {
evt.preventDefault();
}
offsetY.current = window.scrollY;
};
React.useEffect(() => {
if (!scroller) {
return;
}
scroller.addEventListener("wheel", wheelHandler, { passive: false });
return () => {
scroller.removeEventListener("wheel", wheelHandler);
};
}, [scroller]);
};

View file

@ -1,13 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<meta name="robots" content="noindex">
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=no"
/>
<meta name="robots" content="noindex" />
<title>Saleor e-commerce</title>
</head>
<body>
<div id="dashboard-app"></div>
<div id="portal" />
</body>
</html>

View file

@ -126,6 +126,7 @@ const Routes: React.FC = () => {
return (
<>
<style>{`#portal { position: fixed; top: 0; }`}</style>
<WindowTitle title={intl.formatMessage(commonMessages.dashboard)} />
{DEMO_MODE && <DemoBanner />}
{homePageLoaded ? (

View file

@ -256,6 +256,15 @@ export const extractMutationErrors = async <
return e as TErrors;
};
export const hasMutationErrors = (result: FetchResult): boolean => {
if (!result?.data) {
return false;
}
return Object.values(result.data).some(
({ errors }: SaleorMutationResult) => errors.length > 0,
);
};
export const getMutationErrors = <
T extends FetchResult<any>,
TData extends T["data"],

View file

@ -24,7 +24,7 @@ export enum OrderRefundAmountCalculationMode {
}
export interface OrderRefundData {
amount: number | string;
amount: string;
type: OrderRefundType;
refundShipmentCosts: boolean;
amountCalculationMode: OrderRefundAmountCalculationMode;

View file

@ -37,7 +37,6 @@ import React from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { ProductCreateData } from "../ProductCreatePage";
import { ProductUpdateSubmitData } from "../ProductUpdatePage/form";
import { ProductVariantCreateData } from "../ProductVariantCreatePage/form";
import { ProductVariantUpdateData } from "../ProductVariantPage/form";
import { messages } from "./messages";
@ -68,7 +67,6 @@ export interface ProductStocksProps {
formErrors:
| FormErrors<ProductVariantCreateData>
| FormErrors<ProductVariantUpdateData>
| FormErrors<ProductUpdateSubmitData>
| FormErrors<ProductCreateData>;
hasVariants: boolean;
stocks: ProductStockInput[];

View file

@ -0,0 +1,72 @@
import ChannelsAvailabilityDialog from "@saleor/components/ChannelsAvailabilityDialog";
import { ChannelFragment } from "@saleor/graphql";
import useStateFromProps from "@saleor/hooks/useStateFromProps";
import { DialogProps } from "@saleor/types";
import { arrayDiff } from "@saleor/utils/arrays";
import { toggle } from "@saleor/utils/lists";
import React from "react";
import { useIntl } from "react-intl";
import { ProductUpdateData } from "./types";
export type ProductChannelsListingDialogSubmit = (
update: Record<"added" | "removed", string[]>,
) => void;
export interface ProductChannelsListingsDialogProps extends DialogProps {
channels: ChannelFragment[];
data: Pick<ProductUpdateData, "channels">;
onConfirm: ProductChannelsListingDialogSubmit;
}
const ProductChannelsListingsDialog: React.FC<ProductChannelsListingsDialogProps> = ({
channels,
data,
open,
onClose,
onConfirm,
}) => {
const intl = useIntl();
const [selected, setSelected] = useStateFromProps(
data.channels.updateChannels.map(listing => listing.channelId),
);
const handleConfirm = () => {
onConfirm(
arrayDiff(
data.channels.updateChannels.map(({ channelId }) => channelId),
selected,
),
);
onClose();
};
const handleToggleAll = () =>
selected.length !== channels.length
? setSelected(channels.map(({ id }) => id))
: setSelected([]);
return (
<ChannelsAvailabilityDialog
toggleAll={handleToggleAll}
isSelected={({ id }) => selected.includes(id)}
channels={channels}
onChange={({ id }) =>
setSelected(toggle(id, selected, (a, b) => a === b))
}
onClose={onClose}
open={open}
title={intl.formatMessage({
id: "Eau5AV",
defaultMessage: "Manage Products Channel Availability",
})}
confirmButtonState="default"
selected={selected.length}
onConfirm={handleConfirm}
/>
);
};
ProductChannelsListingsDialog.displayName = "ProductChannelsListingsDialog";
export default ProductChannelsListingsDialog;

View file

@ -1,8 +1,7 @@
import placeholderImage from "@assets/images/placeholder255x255.png";
import { channelsList } from "@saleor/channels/fixtures";
import { createChannelsData } from "@saleor/channels/utils";
import { collections } from "@saleor/collections/fixtures";
import { fetchMoreProps, limits, listActionsProps } from "@saleor/fixtures";
import { fetchMoreProps, limits } from "@saleor/fixtures";
import { product as productFixture } from "@saleor/products/fixtures";
import { taxTypes } from "@saleor/storybook/stories/taxes/fixtures";
import { warehouseList } from "@saleor/warehouses/fixtures";
@ -13,7 +12,6 @@ import React from "react";
import ProductUpdatePage, { ProductUpdatePageProps } from "./ProductUpdatePage";
const product = productFixture(placeholderImage);
const channels = createChannelsData(channelsList);
import * as _useNavigator from "@saleor/hooks/useNavigator";
import Adapter from "enzyme-adapter-react-16";
@ -26,6 +24,7 @@ const onSubmit = jest.fn();
const useNavigator = jest.spyOn(_useNavigator, "default");
jest.mock("@saleor/components/RichTextEditor/RichTextEditor");
jest.mock("@saleor/utils/richText/useRichText");
jest.mock("@glideapps/glide-data-grid");
(global as any).document.createRange = () => ({
// eslint-disable-next-line
@ -39,18 +38,13 @@ jest.mock("@saleor/utils/richText/useRichText");
});
const props: ProductUpdatePageProps = {
...listActionsProps,
channels: channelsList,
variantListErrors: [],
productId: "123",
allChannelsCount: 5,
categories: [product.category],
channelsData: [],
channelsWithVariantsData: {},
isSimpleProduct: false,
setChannelsData: () => undefined,
channelsErrors: [],
collections,
currentChannels: channels,
defaultWeightUnit: "kg",
disabled: false,
errors: [],
fetchCategories: () => undefined,
@ -63,25 +57,21 @@ const props: ProductUpdatePageProps = {
header: product.name,
media: product.media,
limits,
refetch: () => undefined,
onAttributeValuesSearch: () => Promise.resolve([]),
onAssignReferencesClick: () => undefined,
onChannelsChange: () => undefined,
onCloseDialog: () => undefined,
onDelete: () => undefined,
onImageDelete: () => undefined,
onImageUpload: () => undefined,
onMediaUrlUpload: () => undefined,
onSetDefaultVariant: () => undefined,
onSubmit,
onVariantReorder: () => undefined,
onVariantEndPreorderDialogOpen: () => undefined,
onWarehouseConfigure: () => undefined,
openChannelsModal: () => undefined,
onVariantShow: () => undefined,
placeholderImage,
product,
referencePages: [],
referenceProducts: [],
saveButtonBarState: "default",
selectedChannelId: "123",
taxTypes,
variants: product.variants,
warehouses: warehouseList,
@ -96,6 +86,7 @@ const selectors = {
describe("Product details page", () => {
useNavigator.mockImplementation();
// DataEditor.mockImplementation();
it("can select empty option on attribute", async () => {
const component = mount(
<MemoryRouter>

View file

@ -1,4 +1,3 @@
import { OutputData } from "@editorjs/editorjs";
import {
extensionMountPoints,
mapToMenuItemsForProductDetails,
@ -21,10 +20,13 @@ import Metadata from "@saleor/components/Metadata/Metadata";
import PageHeader from "@saleor/components/PageHeader";
import Savebar from "@saleor/components/Savebar";
import SeoForm from "@saleor/components/SeoForm";
import { Choice } from "@saleor/components/SingleSelectField";
import {
ChannelFragment,
PermissionEnum,
ProductChannelListingErrorFragment,
ProductDetailsVariantFragment,
ProductErrorFragment,
ProductErrorWithAttributesFragment,
ProductFragment,
RefreshLimitsQuery,
@ -37,51 +39,39 @@ import {
WarehouseFragment,
} from "@saleor/graphql";
import { SubmitPromise } from "@saleor/hooks/useForm";
import { FormsetData } from "@saleor/hooks/useFormset";
import useNavigator from "@saleor/hooks/useNavigator";
import useStateFromProps from "@saleor/hooks/useStateFromProps";
import { sectionNames } from "@saleor/intl";
import { ConfirmButtonTransitionState } from "@saleor/macaw-ui";
import { maybe } from "@saleor/misc";
import ProductExternalMediaDialog from "@saleor/products/components/ProductExternalMediaDialog";
import ProductVariantPrice from "@saleor/products/components/ProductVariantPrice";
import { productImageUrl, productListUrl } from "@saleor/products/urls";
import { ChannelsWithVariantsData } from "@saleor/products/views/ProductUpdate/types";
import {
ChannelProps,
FetchMoreProps,
ListActions,
RelayToFlat,
ReorderAction,
} from "@saleor/types";
import { ProductVariantListError } from "@saleor/products/views/ProductUpdate/handlers/errors";
import { UseProductUpdateHandlerError } from "@saleor/products/views/ProductUpdate/handlers/useProductUpdateHandler";
import { FetchMoreProps, RelayToFlat } from "@saleor/types";
import React from "react";
import { useIntl } from "react-intl";
import ChannelsWithVariantsAvailabilityCard from "../../../channels/ChannelsWithVariantsAvailabilityCard/ChannelsWithVariantsAvailabilityCard";
import { getChoices, ProductUpdatePageFormData } from "../../utils/data";
import { getChoices } from "../../utils/data";
import ProductDetailsForm from "../ProductDetailsForm";
import ProductMedia from "../ProductMedia";
import ProductOrganization from "../ProductOrganization";
import ProductShipping from "../ProductShipping/ProductShipping";
import ProductStocks, { ProductStockInput } from "../ProductStocks";
import ProductTaxes from "../ProductTaxes";
import ProductVariants from "../ProductVariants";
import ProductUpdateForm, {
import ProductUpdateForm from "./form";
import ProductChannelsListingsDialog from "./ProductChannelsListingsDialog";
import {
ProductUpdateData,
ProductUpdateHandlers,
} from "./form";
ProductUpdateSubmitData,
} from "./types";
export interface ProductUpdatePageProps extends ListActions, ChannelProps {
export interface ProductUpdatePageProps {
channels: ChannelFragment[];
productId: string;
channelsWithVariantsData: ChannelsWithVariantsData;
setChannelsData: (data: ChannelData[]) => void;
onChannelsChange: (data: ChannelData[]) => void;
channelsData: ChannelData[];
currentChannels: ChannelData[];
allChannelsCount: number;
channelsErrors: ProductChannelListingErrorFragment[];
defaultWeightUnit: string;
errors: ProductErrorWithAttributesFragment[];
variantListErrors: ProductVariantListError[];
errors: UseProductUpdateHandlerError[];
placeholderImage: string;
collections: RelayToFlat<SearchCollectionsQuery["search"]>;
categories: RelayToFlat<SearchCategoriesQuery["search"]>;
@ -112,39 +102,31 @@ export interface ProductUpdatePageProps extends ListActions, ChannelProps {
fetchReferencePages?: (data: string) => void;
fetchReferenceProducts?: (data: string) => void;
fetchAttributeValues: (query: string, attributeId: string) => void;
refetch: () => Promise<any>;
onAttributeValuesSearch: (
id: string,
query: string,
) => Promise<Array<Choice<string, string>>>;
onAssignReferencesClick: (attribute: AttributeInput) => void;
onCloseDialog: () => void;
onVariantReorder: ReorderAction;
onVariantEndPreorderDialogOpen: () => void;
onImageDelete: (id: string) => () => void;
onSubmit: (data: ProductUpdatePageSubmitData) => SubmitPromise;
openChannelsModal: () => void;
onSubmit: (data: ProductUpdateSubmitData) => SubmitPromise;
onVariantShow: (id: string) => void;
onAttributeSelectBlur: () => void;
onDelete();
onImageReorder?(event: { oldIndex: number; newIndex: number });
onImageUpload(file: File);
onMediaUrlUpload(mediaUrl: string);
onSeoClick?();
onSetDefaultVariant(variant: ProductDetailsVariantFragment);
onWarehouseConfigure();
}
export interface ProductUpdatePageSubmitData extends ProductUpdatePageFormData {
addStocks: ProductStockInput[];
attributes: AttributeInput[];
attributesWithNewFileValue: FormsetData<null, File>;
collections: string[];
description: OutputData;
removeStocks: string[];
updateStocks: ProductStockInput[];
}
export const ProductUpdatePage: React.FC<ProductUpdatePageProps> = ({
productId,
defaultWeightUnit,
disabled,
categories: categoryChoiceList,
channels,
channelsErrors,
variantListErrors,
collections: collectionChoiceList,
attributeValues,
isSimpleProduct,
@ -161,33 +143,20 @@ export const ProductUpdatePage: React.FC<ProductUpdatePageProps> = ({
saveButtonBarState,
variants,
warehouses,
setChannelsData,
taxTypes,
referencePages = [],
referenceProducts = [],
onDelete,
allChannelsCount,
currentChannels,
onImageDelete,
onImageReorder,
onImageUpload,
onMediaUrlUpload,
openChannelsModal,
onVariantShow,
onSeoClick,
onSubmit,
channelsData,
onSetDefaultVariant,
onVariantReorder,
onVariantEndPreorderDialogOpen,
onWarehouseConfigure,
isChecked,
isMediaUrlModalVisible,
selected,
selectedChannelId,
toggle,
toggleAll,
toolbar,
assignReferencesAttributeId,
onAttributeValuesSearch,
onAssignReferencesClick,
fetchReferencePages,
fetchMoreReferencePages,
@ -195,13 +164,13 @@ export const ProductUpdatePage: React.FC<ProductUpdatePageProps> = ({
fetchMoreReferenceProducts,
fetchAttributeValues,
fetchMoreAttributeValues,
refetch,
onCloseDialog,
channelsWithVariantsData,
onChannelsChange,
onAttributeSelectBlur,
}) => {
const intl = useIntl();
const navigate = useNavigator();
const [channelPickerOpen, setChannelPickerOpen] = React.useState(false);
const [selectedCategory, setSelectedCategory] = useStateFromProps(
product?.category?.name || "",
@ -250,6 +219,22 @@ export const ProductUpdatePage: React.FC<ProductUpdatePageProps> = ({
extensionMountPoints.PRODUCT_DETAILS,
);
const productErrors = React.useMemo(
() =>
errors.filter(
error => error.__typename === "ProductError",
) as ProductErrorWithAttributesFragment[],
[errors],
);
const productOrganizationErrors = React.useMemo(
() =>
[...errors, ...channelsErrors].filter(err =>
["ProductChannelListingError", "ProductError"].includes(err.__typename),
) as Array<ProductErrorFragment | ProductChannelListingErrorFragment>,
[errors, channelsErrors],
);
const extensionMenuItems = mapToMenuItemsForProductDetails(
PRODUCT_DETAILS_MORE_ACTIONS,
productId,
@ -258,19 +243,14 @@ export const ProductUpdatePage: React.FC<ProductUpdatePageProps> = ({
return (
<ProductUpdateForm
isSimpleProduct={isSimpleProduct}
currentChannels={currentChannels}
channelsData={channelsData}
setChannelsData={setChannelsData}
onSubmit={onSubmit}
product={product}
categories={categories}
collections={collections}
channelsWithVariants={channelsWithVariantsData}
selectedCollections={selectedCollections}
setSelectedCategory={setSelectedCategory}
setSelectedCollections={setSelectedCollections}
setSelectedTaxType={setSelectedTaxType}
setChannels={onChannelsChange}
taxTypes={taxTypeChoices}
warehouses={warehouses}
hasVariants={hasVariants}
@ -282,11 +262,11 @@ export const ProductUpdatePage: React.FC<ProductUpdatePageProps> = ({
fetchMoreReferenceProducts={fetchMoreReferenceProducts}
assignReferencesAttributeId={assignReferencesAttributeId}
disabled={disabled}
refetch={refetch}
>
{({
change,
data,
formErrors,
handlers,
submit,
isSaveDisabled,
@ -308,12 +288,25 @@ export const ProductUpdatePage: React.FC<ProductUpdatePageProps> = ({
}),
},
errors: channelsErrors,
allChannelsCount,
allChannelsCount: channels?.length,
disabled,
onChange: handlers.changeChannels,
openModal: openChannelsModal,
openModal: () => setChannelPickerOpen(true),
};
const listings = data.channels.updateChannels.map<ChannelData>(
listing => {
const channel = channels?.find(ac => ac.id === listing.channelId);
return {
id: listing.channelId,
...channel,
...listing,
availableForPurchase: listing.availableForPurchaseDate,
currency: channel.currencyCode,
};
},
);
return (
<>
<Container>
@ -333,7 +326,7 @@ export const ProductUpdatePage: React.FC<ProductUpdatePageProps> = ({
<ProductDetailsForm
data={data}
disabled={disabled}
errors={errors}
errors={productErrors}
onChange={change}
/>
<CardSpacer />
@ -353,7 +346,7 @@ export const ProductUpdatePage: React.FC<ProductUpdatePageProps> = ({
<Attributes
attributes={data.attributes}
attributeValues={attributeValues}
errors={errors}
errors={productErrors}
loading={disabled}
disabled={disabled}
onChange={handlers.selectAttribute}
@ -369,72 +362,20 @@ export const ProductUpdatePage: React.FC<ProductUpdatePageProps> = ({
/>
)}
<CardSpacer />
{isSimpleProduct && (
<>
<ProductVariantPrice
ProductVariantChannelListings={data.channelListings}
errors={channelsErrors}
loading={disabled}
onChange={handlers.changeChannelPrice}
/>
<CardSpacer />
</>
)}
{hasVariants ? (
<ProductVariants
productId={productId}
disabled={disabled}
errors={variantListErrors}
channels={listings}
limits={limits}
variants={variants}
product={product}
onVariantReorder={onVariantReorder}
onSetDefaultVariant={onSetDefaultVariant}
toolbar={toolbar}
isChecked={isChecked}
selected={selected}
selectedChannelId={selectedChannelId}
toggle={toggle}
toggleAll={toggleAll}
/>
) : (
<>
<ProductShipping
data={data}
disabled={disabled}
errors={errors}
weightUnit={product?.weight?.unit || defaultWeightUnit}
onChange={change}
/>
<CardSpacer />
<ProductStocks
onVariantChannelListingChange={
handlers.changeChannelPreorder
}
productVariantChannelListings={data.channelListings}
onEndPreorderTrigger={
!!variants?.[0]?.preorder
? () => onVariantEndPreorderDialogOpen()
: null
}
data={data}
disabled={disabled}
hasVariants={false}
errors={errors}
formErrors={formErrors}
stocks={data.stocks}
variantAttributes={product?.productType.variantAttributes}
warehouses={warehouses}
onChange={handlers.changeStock}
onFormDataChange={change}
onChangePreorderEndDate={handlers.changePreorderEndDate}
onWarehouseStockAdd={handlers.addStock}
onWarehouseStockDelete={handlers.deleteStock}
onWarehouseConfigure={onWarehouseConfigure}
onAttributeValuesSearch={onAttributeValuesSearch}
onChange={handlers.changeVariants}
onRowClick={onVariantShow}
/>
</>
)}
<CardSpacer />
<SeoForm
errors={errors}
errors={productErrors}
title={data.seoTitle}
titlePlaceholder={data.name}
description={data.seoDescription}
@ -462,7 +403,7 @@ export const ProductUpdatePage: React.FC<ProductUpdatePageProps> = ({
collectionsInputDisplayValue={selectedCollections}
data={data}
disabled={disabled}
errors={[...errors, ...channelsErrors]}
errors={productOrganizationErrors}
fetchCategories={fetchCategories}
fetchCollections={fetchCollections}
fetchMoreCategories={fetchMoreCategories}
@ -472,39 +413,10 @@ export const ProductUpdatePage: React.FC<ProductUpdatePageProps> = ({
onCollectionChange={handlers.selectCollection}
/>
<CardSpacer />
{isSimpleProduct ? (
<ChannelsAvailabilityCard
{...availabilityCommonProps}
channels={data.channelListings}
channels={listings}
/>
) : product?.variants.length === 0 ? (
<ChannelsAvailabilityCard
{...availabilityCommonProps}
channelsList={data.channelListings}
/>
) : (
<ChannelsWithVariantsAvailabilityCard
messages={{
hiddenLabel: intl.formatMessage({
id: "saKXY3",
defaultMessage: "Not published",
description: "product label",
}),
visibleLabel: intl.formatMessage({
id: "qJedl0",
defaultMessage: "Published",
description: "product label",
}),
}}
errors={channelsErrors}
channels={data.channelsData}
channelsWithVariantsData={channelsWithVariantsData}
variants={variants}
onChange={handlers.changeChannels}
openModal={openChannelsModal}
/>
)}
<CardSpacer />
<ProductTaxes
data={data}
@ -554,6 +466,13 @@ export const ProductUpdatePage: React.FC<ProductUpdatePageProps> = ({
open={mediaUrlModalStatus}
onSubmit={onMediaUrlUpload}
/>
<ProductChannelsListingsDialog
channels={channels}
data={data}
onClose={() => setChannelPickerOpen(false)}
open={channelPickerOpen}
onConfirm={handlers.updateChannelList}
/>
</Container>
</>
);

View file

@ -1,10 +1,8 @@
import { OutputData } from "@editorjs/editorjs";
import {
getAttributesDisplayData,
getRichTextAttributesFromMap,
getRichTextDataFromAttributes,
mergeAttributes,
RichTextProps,
} from "@saleor/attributes/utils/data";
import {
createAttributeChangeHandler,
@ -16,53 +14,20 @@ import {
createFetchReferencesHandler,
} from "@saleor/attributes/utils/handlers";
import {
ChannelData,
ChannelPreorderArgs,
ChannelPriceArgs,
} from "@saleor/channels/utils";
import { AttributeInput } from "@saleor/components/Attributes";
DatagridChangeOpts,
DatagridChangeStateContext,
useDatagridChangeState,
} from "@saleor/components/Datagrid/useDatagridChange";
import { useExitFormDialog } from "@saleor/components/Form/useExitFormDialog";
import { MetadataFormData } from "@saleor/components/Metadata";
import { MultiAutocompleteChoiceType } from "@saleor/components/MultiAutocompleteSelectField";
import { SingleAutocompleteChoiceType } from "@saleor/components/SingleAutocompleteSelectField";
import {
ProductFragment,
SearchPagesQuery,
SearchProductsQuery,
SearchWarehousesQuery,
} from "@saleor/graphql";
import useForm, {
CommonUseFormResultWithHandlers,
FormChange,
FormErrors,
SubmitPromise,
} from "@saleor/hooks/useForm";
import useFormset, {
FormsetAtomicData,
FormsetChange,
FormsetData,
} from "@saleor/hooks/useFormset";
import { ProductFragment } from "@saleor/graphql";
import useForm from "@saleor/hooks/useForm";
import useFormset from "@saleor/hooks/useFormset";
import useHandleFormSubmit from "@saleor/hooks/useHandleFormSubmit";
import { errorMessages } from "@saleor/intl";
import {
getAttributeInputFromProduct,
getProductUpdatePageFormData,
getStockInputFromProduct,
} from "@saleor/products/utils/data";
import {
createChannelsChangeHandler,
createChannelsPreorderChangeHandler,
createChannelsPriceChangeHandler,
createPreorderEndDateChangeHandler,
} from "@saleor/products/utils/handlers";
import {
validateCostPrice,
validatePrice,
} from "@saleor/products/utils/validation";
import { PRODUCT_UPDATE_FORM_ID } from "@saleor/products/views/ProductUpdate/consts";
import { ChannelsWithVariantsData } from "@saleor/products/views/ProductUpdate/types";
import { FetchMoreProps, RelayToFlat, ReorderEvent } from "@saleor/types";
import { arrayDiff } from "@saleor/utils/arrays";
import createMultiAutocompleteSelectHandler from "@saleor/utils/handlers/multiAutocompleteSelectChangeHandler";
import createSingleAutocompleteSelectHandler from "@saleor/utils/handlers/singleAutocompleteSelectChangeHandler";
import getMetadata from "@saleor/utils/metadata/getMetadata";
@ -70,190 +35,28 @@ import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTr
import { RichTextContext } from "@saleor/utils/richText/context";
import { useMultipleRichText } from "@saleor/utils/richText/useMultipleRichText";
import useRichText from "@saleor/utils/richText/useRichText";
import React, { useEffect, useMemo } from "react";
import { useIntl } from "react-intl";
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { ProductStockFormsetData, ProductStockInput } from "../ProductStocks";
export interface ProductUpdateFormData extends MetadataFormData {
category: string | null;
changeTaxCode: boolean;
channelsData: ChannelData[];
channelsWithVariants: ChannelsWithVariantsData;
channelListings: ChannelData[];
chargeTaxes: boolean;
collections: string[];
isAvailable: boolean;
name: string;
rating: number;
slug: string;
seoDescription: string;
seoTitle: string;
sku: string;
taxCode: string;
trackInventory: boolean;
isPreorder: boolean;
globalThreshold: string;
globalSoldUnits: number;
hasPreorderEndDate: boolean;
preorderEndDateTime?: string;
weight: string;
}
export interface FileAttributeInputData {
attributeId: string;
file: File;
}
export type FileAttributeInput = FormsetAtomicData<
FileAttributeInputData,
string[]
>;
export interface FileAttributesSubmitData {
fileAttributes: FileAttributeInput[];
}
export interface ProductUpdateData extends ProductUpdateFormData {
attributes: AttributeInput[];
description: OutputData;
stocks: ProductStockInput[];
}
export interface ProductUpdateSubmitData extends ProductUpdateFormData {
attributes: AttributeInput[];
attributesWithNewFileValue: FormsetData<null, File>;
collections: string[];
description: OutputData;
addStocks: ProductStockInput[];
updateStocks: ProductStockInput[];
removeStocks: string[];
}
export interface ProductUpdateHandlers
extends Record<
| "changeMetadata"
| "selectCategory"
| "selectCollection"
| "selectTaxRate",
FormChange
>,
Record<
"changeStock" | "selectAttribute" | "selectAttributeMultiple",
FormsetChange<string>
>,
Record<"changeChannelPrice", (id: string, data: ChannelPriceArgs) => void>,
Record<
"changeChannelPreorder",
(id: string, data: ChannelPreorderArgs) => void
>,
Record<
"changeChannels",
(
id: string,
data: Omit<ChannelData, "name" | "price" | "currency" | "id">,
) => void
>,
Record<"selectAttributeReference", FormsetChange<string[]>>,
Record<"selectAttributeFile", FormsetChange<File>>,
Record<"reorderAttributeValue", FormsetChange<ReorderEvent>>,
Record<"addStock" | "deleteStock", (id: string) => void> {
changePreorderEndDate: FormChange;
fetchReferences: (value: string) => void;
fetchMoreReferences: FetchMoreProps;
}
export interface UseProductUpdateFormOutput
extends CommonUseFormResultWithHandlers<
import { useProductChannelListingsForm } from "./formChannels";
import {
ProductUpdateData,
ProductUpdateHandlers
>,
RichTextProps {
formErrors: FormErrors<ProductUpdateSubmitData>;
}
export type UseProductUpdateFormRenderProps = Omit<
ProductUpdateFormProps,
ProductUpdateSubmitData,
SubmitResult,
UseProductUpdateFormOpts,
UseProductUpdateFormOutput,
"richText"
>;
export interface UseProductUpdateFormOpts
extends Record<
"categories" | "collections" | "taxTypes",
SingleAutocompleteChoiceType[]
> {
setSelectedCategory: React.Dispatch<React.SetStateAction<string>>;
setSelectedCollections: React.Dispatch<
React.SetStateAction<MultiAutocompleteChoiceType[]>
>;
setSelectedTaxType: React.Dispatch<React.SetStateAction<string>>;
selectedCollections: MultiAutocompleteChoiceType[];
warehouses: RelayToFlat<SearchWarehousesQuery["search"]>;
channelsData: ChannelData[];
hasVariants: boolean;
currentChannels: ChannelData[];
setChannels: (data: ChannelData[]) => void;
setChannelsData: (data: ChannelData[]) => void;
referencePages: RelayToFlat<SearchPagesQuery["search"]>;
referenceProducts: RelayToFlat<SearchProductsQuery["search"]>;
fetchReferencePages?: (data: string) => void;
fetchMoreReferencePages?: FetchMoreProps;
fetchReferenceProducts?: (data: string) => void;
fetchMoreReferenceProducts?: FetchMoreProps;
assignReferencesAttributeId?: string;
channelsWithVariants: ChannelsWithVariantsData;
isSimpleProduct: boolean;
}
export interface ProductUpdateFormProps extends UseProductUpdateFormOpts {
children: (props: UseProductUpdateFormRenderProps) => React.ReactNode;
product: ProductFragment;
onSubmit: (data: ProductUpdateSubmitData) => SubmitPromise;
disabled: boolean;
}
const getStocksData = (
product: ProductFragment,
stocks: FormsetData<ProductStockFormsetData, string>,
) => {
if (product?.productType?.hasVariants) {
return { addStocks: [], removeStocks: [], updateStocks: [] };
}
const dataStocks = stocks.map(stock => stock.id);
const variantStocks =
product?.variants[0]?.stocks.map(stock => stock.warehouse.id) || [];
const stockDiff = arrayDiff(variantStocks, dataStocks);
return {
addStocks: stocks.filter(stock =>
stockDiff.added.some(addedStock => addedStock === stock.id),
),
removeStocks: stockDiff.removed,
updateStocks: stocks.filter(
stock => !stockDiff.added.some(addedStock => addedStock === stock.id),
),
};
};
} from "./types";
function useProductUpdateForm(
product: ProductFragment,
onSubmit: (data: ProductUpdateSubmitData) => SubmitPromise,
onSubmit: (data: ProductUpdateSubmitData) => SubmitResult,
disabled: boolean,
refetch: () => Promise<any>,
opts: UseProductUpdateFormOpts,
): UseProductUpdateFormOutput {
const intl = useIntl();
const initial = useMemo(
() =>
getProductUpdatePageFormData(
product,
product?.variants,
opts.currentChannels,
opts.channelsData,
opts.channelsWithVariants,
),
[
product,
opts.currentChannels,
opts.channelsData,
opts.channelsWithVariants,
],
() => getProductUpdatePageFormData(product, product?.variants),
[product],
);
const form = useForm(initial, undefined, {
@ -269,6 +72,17 @@ function useProductUpdateForm(
setIsSubmitDisabled,
} = form;
const datagrid = useDatagridChangeState();
const variants = useRef<DatagridChangeOpts>({
added: [],
removed: [],
updates: [],
});
const handleVariantChange = React.useCallback((data: DatagridChangeOpts) => {
variants.current = data;
triggerChange();
}, []);
const attributes = useFormset(getAttributeInputFromProduct(product));
const {
getters: attributeRichTextGetters,
@ -278,7 +92,6 @@ function useProductUpdateForm(
triggerChange,
});
const attributesWithNewFileValue = useFormset<null, File>([]);
const stocks = useFormset(getStockInputFromProduct(product));
const richText = useRichText({
initial: product?.description,
loading: !product,
@ -295,6 +108,13 @@ function useProductUpdateForm(
makeChangeHandler: makeMetadataChangeHandler,
} = useMetadataChangeTrigger();
const {
channels,
handleChannelChange,
handleChannelListUpdate,
touched: touchedChannels,
} = useProductChannelListingsForm(product, triggerChange);
const handleCollectionSelect = createMultiAutocompleteSelectHandler(
event => toggleValue(event),
opts.setSelectedCollections,
@ -343,25 +163,6 @@ function useProductUpdateForm(
attributes.data,
triggerChange,
);
const handleStockChange: FormsetChange<string> = (id, value) => {
triggerChange();
stocks.change(id, value);
};
const handleStockAdd = (id: string) => {
triggerChange();
stocks.add({
data: {
quantityAllocated: 0,
},
id,
label: opts.warehouses.find(warehouse => warehouse.id === id).name,
value: "0",
});
};
const handleStockDelete = (id: string) => {
triggerChange();
stocks.remove(id);
};
const handleTaxTypeSelect = createSingleAutocompleteSelectHandler(
handleChange,
opts.setSelectedTaxType,
@ -369,47 +170,20 @@ function useProductUpdateForm(
);
const changeMetadata = makeMetadataChangeHandler(handleChange);
const handleChannelsChange = createChannelsChangeHandler(
opts.isSimpleProduct ? opts.currentChannels : opts.channelsData,
opts.isSimpleProduct ? opts.setChannels : opts.setChannelsData,
triggerChange,
);
const handleChannelPreorderChange = createChannelsPreorderChangeHandler(
opts.isSimpleProduct ? opts.currentChannels : opts.channelsData,
opts.isSimpleProduct ? opts.setChannels : opts.setChannelsData,
triggerChange,
);
const handleChannelPriceChange = createChannelsPriceChangeHandler(
opts.isSimpleProduct ? opts.currentChannels : opts.channelsData,
opts.isSimpleProduct ? opts.setChannels : opts.setChannelsData,
triggerChange,
);
const handlePreorderEndDateChange = createPreorderEndDateChangeHandler(
form,
triggerChange,
intl.formatMessage(errorMessages.preorderEndDateInFutureErrorText),
);
const data: ProductUpdateData = {
...formData,
channelListings: opts.currentChannels,
channelsData: opts.channelsData,
attributes: getAttributesDisplayData(
attributes.data,
attributesWithNewFileValue.data,
opts.referencePages,
opts.referenceProducts,
),
channels,
description: null,
stocks: stocks.data,
};
const getSubmitData = async (): Promise<ProductUpdateSubmitData> => ({
...data,
...getStocksData(product, stocks.data),
...getMetadata(data, isMetadataModified, isPrivateMetadataModified),
attributes: mergeAttributes(
attributes.data,
@ -419,7 +193,14 @@ function useProductUpdateForm(
),
),
attributesWithNewFileValue: attributesWithNewFileValue.data,
channels: {
...channels,
updateChannels: channels.updateChannels.filter(listing =>
touchedChannels.current.includes(listing.channelId),
),
},
description: await richText.getValue(),
variants: variants.current,
});
const handleSubmit = async (data: ProductUpdateSubmitData) => {
@ -437,7 +218,39 @@ function useProductUpdateForm(
onSubmit: handleSubmit,
});
const submit = async () => handleFormSubmit(await getSubmitData());
const submit = useCallback(async () => {
const result = await handleFormSubmit(await getSubmitData());
await refetch();
datagrid.setAdded(prevAdded =>
prevAdded.filter((_, index) =>
result.some(
error =>
error.__typename === "DatagridError" &&
error.type === "create" &&
error.index === index,
),
),
);
datagrid.changes.current = datagrid.changes.current.filter(change =>
datagrid.added.includes(change.row)
? result.some(
error =>
error.__typename === "DatagridError" &&
error.type === "create" &&
error.index === datagrid.added.findIndex(r => r === change.row),
)
: result.some(
error =>
error.__typename === "DatagridError" &&
error.type !== "create" &&
error.variantId === product.variants[change.row].id,
),
);
datagrid.setRemoved([]);
return result;
}, [datagrid, handleFormSubmit, getSubmitData]);
useEffect(() => setExitDialogSubmitRef(submit), [submit]);
@ -454,18 +267,6 @@ function useProductUpdateForm(
return false;
}
if (opts.hasVariants) {
return true;
}
const hasInvalidChannelListingPrices = data.channelListings.some(
channel =>
validatePrice(channel.price) || validateCostPrice(channel.costPrice),
);
if (hasInvalidChannelListingPrices) {
return false;
}
return true;
};
@ -479,16 +280,12 @@ function useProductUpdateForm(
return {
change: handleChange,
data,
datagrid,
formErrors: form.errors,
handlers: {
addStock: handleStockAdd,
changeChannelPrice: handleChannelPriceChange,
changeChannelPreorder: handleChannelPreorderChange,
changeChannels: handleChannelsChange,
changeChannels: handleChannelChange,
changeMetadata,
changeStock: handleStockChange,
changePreorderEndDate: handlePreorderEndDateChange,
deleteStock: handleStockDelete,
changeVariants: handleVariantChange,
fetchMoreReferences: handleFetchMoreReferences,
fetchReferences: handleFetchReferences,
reorderAttributeValue: handleAttributeValueReorder,
@ -499,6 +296,7 @@ function useProductUpdateForm(
selectCategory: handleCategorySelect,
selectCollection: handleCollectionSelect,
selectTaxRate: handleTaxTypeSelect,
updateChannelList: handleChannelListUpdate,
},
submit,
isSaveDisabled,
@ -511,21 +309,25 @@ const ProductUpdateForm: React.FC<ProductUpdateFormProps> = ({
children,
product,
onSubmit,
refetch,
disabled,
...rest
}) => {
const { richText, ...props } = useProductUpdateForm(
const { datagrid, richText, ...props } = useProductUpdateForm(
product,
onSubmit,
disabled,
refetch,
rest,
);
return (
<form onSubmit={props.submit}>
<DatagridChangeStateContext.Provider value={datagrid}>
<RichTextContext.Provider value={richText}>
{children(props)}
</RichTextContext.Provider>
</DatagridChangeStateContext.Provider>
</form>
);
};

View file

@ -0,0 +1,86 @@
import { ChannelOpts } from "@saleor/components/ChannelsAvailabilityCard/types";
import {
ProductChannelListingAddInput,
ProductChannelListingUpdateInput,
ProductFragment,
} from "@saleor/graphql";
import useStateFromProps from "@saleor/hooks/useStateFromProps";
import uniq from "lodash/uniq";
import uniqBy from "lodash/uniqBy";
import { useCallback, useRef } from "react";
import { ProductChannelsListingDialogSubmit } from "./ProductChannelsListingsDialog";
const emptyListing: Omit<ProductChannelListingAddInput, "channelId"> = {
availableForPurchaseDate: null,
isAvailableForPurchase: false,
isPublished: false,
publicationDate: null,
visibleInListings: false,
};
export function useProductChannelListingsForm(
product: Pick<ProductFragment, "channelListings">,
triggerChange: () => void,
) {
const [channels, setChannels] = useStateFromProps<
ProductChannelListingUpdateInput
>({
removeChannels: [],
updateChannels:
product?.channelListings.map(listing => ({
channelId: listing.channel.id,
availableForPurchaseDate: listing.availableForPurchase,
...listing,
})) ?? [],
});
const touched = useRef<string[]>([]);
const touch = (id: string) => {
touched.current = uniq([...touched.current, id]);
};
const handleChannelChange = useCallback((id: string, data: ChannelOpts) => {
setChannels(prevData => ({
...prevData,
updateChannels: prevData.updateChannels.map(prevListing =>
prevListing.channelId === id
? { ...prevListing, ...data }
: prevListing,
),
}));
triggerChange();
touch(id);
}, []);
const handleChannelListUpdate: ProductChannelsListingDialogSubmit = useCallback(
({ added, removed }) => {
setChannels(prevData => ({
...prevData,
updateChannels: uniqBy(
[
...prevData.updateChannels,
...added.map(id => ({
channelId: id,
...emptyListing,
})),
],
"channelId",
).filter(({ channelId }) => !removed.includes(channelId)),
removeChannels: uniq([...prevData.removeChannels, ...removed]).filter(
id => !added.includes(id),
),
}));
triggerChange();
added.forEach(id => touch(id));
},
[product],
);
return {
channels,
handleChannelChange,
handleChannelListUpdate,
touched,
};
}

View file

@ -0,0 +1,153 @@
import { OutputData } from "@editorjs/editorjs";
import { RichTextProps } from "@saleor/attributes/utils/data";
import { AttributeInput } from "@saleor/components/Attributes";
import { ChannelOpts } from "@saleor/components/ChannelsAvailabilityCard/types";
import {
DatagridChangeOpts,
UseDatagridChangeState,
} from "@saleor/components/Datagrid/useDatagridChange";
import { MetadataFormData } from "@saleor/components/Metadata";
import { MultiAutocompleteChoiceType } from "@saleor/components/MultiAutocompleteSelectField";
import { SingleAutocompleteChoiceType } from "@saleor/components/SingleAutocompleteSelectField";
import {
MetadataErrorFragment,
ProductChannelListingUpdateInput,
ProductFragment,
SearchPagesQuery,
SearchProductsQuery,
SearchWarehousesQuery,
} from "@saleor/graphql";
import {
CommonUseFormResultWithHandlers,
FormChange,
FormErrors,
SubmitPromise,
} from "@saleor/hooks/useForm";
import {
FormsetAtomicData,
FormsetChange,
FormsetData,
} from "@saleor/hooks/useFormset";
import { UseProductUpdateHandlerError } from "@saleor/products/views/ProductUpdate/handlers/useProductUpdateHandler";
import { FetchMoreProps, RelayToFlat, ReorderEvent } from "@saleor/types";
import { ProductChannelsListingDialogSubmit } from "./ProductChannelsListingsDialog";
export interface ProductUpdateFormData extends MetadataFormData {
category: string | null;
changeTaxCode: boolean;
chargeTaxes: boolean;
collections: string[];
isAvailable: boolean;
name: string;
rating: number;
slug: string;
seoDescription: string;
seoTitle: string;
sku: string;
taxCode: string;
trackInventory: boolean;
isPreorder: boolean;
globalThreshold: string;
globalSoldUnits: number;
hasPreorderEndDate: boolean;
preorderEndDateTime?: string;
weight: string;
}
export interface FileAttributeInputData {
attributeId: string;
file: File;
}
export type FileAttributeInput = FormsetAtomicData<
FileAttributeInputData,
string[]
>;
export interface FileAttributesSubmitData {
fileAttributes: FileAttributeInput[];
}
export interface ProductUpdateData extends ProductUpdateFormData {
attributes: AttributeInput[];
channels: ProductChannelListingUpdateInput;
description: OutputData;
}
export interface ProductUpdateSubmitData extends ProductUpdateFormData {
attributes: AttributeInput[];
attributesWithNewFileValue: FormsetData<null, File>;
channels: ProductChannelListingUpdateInput;
collections: string[];
description: OutputData;
variants: DatagridChangeOpts;
}
export interface ProductUpdateHandlers
extends Record<
| "changeMetadata"
| "selectCategory"
| "selectCollection"
| "selectTaxRate",
FormChange
>,
Record<
"selectAttribute" | "selectAttributeMultiple",
FormsetChange<string>
> {
changeChannels: (id: string, data: ChannelOpts) => void;
selectAttributeReference: FormsetChange<string[]>;
selectAttributeFile: FormsetChange<File>;
reorderAttributeValue: FormsetChange<ReorderEvent>;
changeVariants: (data: DatagridChangeOpts) => void;
fetchReferences: (value: string) => void;
fetchMoreReferences: FetchMoreProps;
updateChannelList: ProductChannelsListingDialogSubmit;
}
export interface UseProductUpdateFormOutput
extends CommonUseFormResultWithHandlers<
ProductUpdateData,
ProductUpdateHandlers
>,
RichTextProps {
datagrid: UseDatagridChangeState;
formErrors: FormErrors<ProductUpdateSubmitData>;
}
export type UseProductUpdateFormRenderProps = Omit<
UseProductUpdateFormOutput,
"datagrid" | "richText"
>;
export interface UseProductUpdateFormOpts
extends Record<
"categories" | "collections" | "taxTypes",
SingleAutocompleteChoiceType[]
> {
setSelectedCategory: React.Dispatch<React.SetStateAction<string>>;
setSelectedCollections: React.Dispatch<
React.SetStateAction<MultiAutocompleteChoiceType[]>
>;
setSelectedTaxType: React.Dispatch<React.SetStateAction<string>>;
selectedCollections: MultiAutocompleteChoiceType[];
warehouses: RelayToFlat<SearchWarehousesQuery["search"]>;
hasVariants: boolean;
referencePages: RelayToFlat<SearchPagesQuery["search"]>;
referenceProducts: RelayToFlat<SearchProductsQuery["search"]>;
fetchReferencePages?: (data: string) => void;
fetchMoreReferencePages?: FetchMoreProps;
fetchReferenceProducts?: (data: string) => void;
fetchMoreReferenceProducts?: FetchMoreProps;
assignReferencesAttributeId?: string;
isSimpleProduct: boolean;
}
export type SubmitResult = SubmitPromise<
Array<UseProductUpdateHandlerError | MetadataErrorFragment>
>;
export interface ProductUpdateFormProps extends UseProductUpdateFormOpts {
children: (props: UseProductUpdateFormRenderProps) => React.ReactNode;
product: ProductFragment;
onSubmit: (data: ProductUpdateSubmitData) => SubmitResult;
refetch: () => Promise<any>;
disabled: boolean;
}

View file

@ -18,7 +18,12 @@ export const CreateVariantTitle: React.FC<CreateVariantTitleProps> = ({
<CardTitle
title={intl.formatMessage(messages.title)}
toolbar={
<Button variant="tertiary" disabled={false} onClick={onManageClick}>
<Button
variant="tertiary"
data-testid="manage-channels-button"
disabled={false}
onClick={onManageClick}
>
{intl.formatMessage(messages.manageButtonText)}
</Button>
}

View file

@ -1,469 +1,135 @@
import { Item } from "@glideapps/glide-data-grid";
import { ChannelData } from "@saleor/channels/utils";
import Datagrid, {
GetCellContentOpts,
} from "@saleor/components/Datagrid/Datagrid";
import { DatagridChangeOpts } from "@saleor/components/Datagrid/useDatagridChange";
import { Choice } from "@saleor/components/SingleSelectField";
import {
Card,
CardContent,
Hidden,
TableCell,
Typography,
} from "@material-ui/core";
import { fade } from "@material-ui/core/styles/colorManipulator";
import { Button } from "@saleor/components/Button";
import CardTitle from "@saleor/components/CardTitle";
import Checkbox from "@saleor/components/Checkbox";
import LimitReachedAlert from "@saleor/components/LimitReachedAlert";
import LinkChoice from "@saleor/components/LinkChoice";
import Money from "@saleor/components/Money";
import ResponsiveTable from "@saleor/components/ResponsiveTable";
import { SingleAutocompleteChoiceType } from "@saleor/components/SingleAutocompleteSelectField";
import Skeleton from "@saleor/components/Skeleton";
import {
SortableTableBody,
SortableTableRow,
} from "@saleor/components/SortableTable";
import { TableButtonWrapper } from "@saleor/components/TableButtonWrapper/TableButtonWrapper";
import TableHead from "@saleor/components/TableHead";
import {
AttributeInputTypeEnum,
ProductDetailsVariantFragment,
ProductFragment,
RefreshLimitsQuery,
WarehouseFragment,
} from "@saleor/graphql";
import { makeStyles } from "@saleor/macaw-ui";
import {
productVariantAddUrl,
productVariantEditUrl,
} from "@saleor/products/urls";
import { isLimitReached } from "@saleor/utils/limits";
import { buttonMessages } from "@saleor/intl";
import { Button } from "@saleor/macaw-ui";
import { ProductVariantListError } from "@saleor/products/views/ProductUpdate/handlers/errors";
// import { isLimitReached } from "@saleor/utils/limits";
import React from "react";
import { FormattedMessage, IntlShape, useIntl } from "react-intl";
import { FormattedMessage, useIntl } from "react-intl";
import { maybe, renderCollection } from "../../../misc";
import { ChannelProps, ListActions, ReorderAction } from "../../../types";
import ProductVariantSetDefault from "../ProductVariantSetDefault";
import messages from "./messages";
import { getColumnData, getData, getError } from "./utils";
type Warehouse = ProductDetailsVariantFragment[][0]["stocks"][0]["warehouse"];
function getWarehouseChoices(
variants: ProductDetailsVariantFragment[],
intl: IntlShape,
): SingleAutocompleteChoiceType[] {
return [
{
label: intl.formatMessage({
id: "JtZ71e",
defaultMessage: "All Warehouses",
description: "filtering option",
}),
value: null,
},
...variants
.reduce<Warehouse[]>(
(warehouses, variant) => [
...warehouses,
...variant.stocks.reduce<Warehouse[]>((variantStocks, stock) => {
if (!!warehouses.find(w => w.id === stock.warehouse.id)) {
return variantStocks;
}
return [...variantStocks, stock.warehouse];
}, []),
],
[],
)
.map(w => ({
label: w.name,
value: w.id,
})),
];
}
const useStyles = makeStyles(
theme => ({
[theme.breakpoints.up("lg")]: {
colActions: {
width: 80,
},
colInventory: {
width: 200,
},
colName: {},
colPrice: {
width: 135,
},
colSku: {
width: 200,
},
},
alert: {
margin: theme.spacing(3, 3, 0),
},
colGrab: {
width: 60,
},
colInventory: {
textAlign: "right",
},
colName: {
paddingLeft: 0,
},
colPrice: {
textAlign: "right",
},
colSku: {},
colStatus: {},
defaultVariant: {
color: fade(theme.palette.text.secondary, 0.6),
display: "block",
},
link: {
cursor: "pointer",
},
select: {
display: "inline-block",
},
textLeft: {
textAlign: "left" as "left",
},
textRight: {
textAlign: "right" as "right",
},
warehouseLabel: {
display: "inline-block",
marginRight: theme.spacing(),
},
warehouseSelectContainer: {
paddingTop: theme.spacing(2),
},
}),
{ name: "ProductVariants" },
);
function getAvailabilityLabel(
intl: IntlShape,
warehouse: string,
variant: ProductDetailsVariantFragment[][0],
numAvailable: number,
): string {
if (variant.preorder) {
if (variant.preorder.globalThreshold) {
return intl.formatMessage(
{
id: "80FeaT",
defaultMessage: "{globalThreshold} Global threshold",
description: "product variant preorder threshold",
},
{
globalThreshold: variant.preorder.globalThreshold,
},
);
}
return intl.formatMessage({
id: "qbqMpk",
defaultMessage: "In preorder",
description: "product variant preorder threshold",
});
}
const variantStock = variant.stocks.find(s => s.warehouse.id === warehouse);
if (!!warehouse) {
if (!!variantStock) {
if (variantStock.quantity > 0) {
return intl.formatMessage(
{
id: "uVssds",
defaultMessage:
"{stockQuantity,plural,one{{stockQuantity} available} other{{stockQuantity} available}}",
description: "product variant inventory",
},
{
stockQuantity: variantStock.quantity,
},
);
} else {
return intl.formatMessage({
id: "7mK2vs",
defaultMessage: "Unavailable",
description: "product variant inventory",
});
}
} else {
return intl.formatMessage({
id: "9PmyrU",
defaultMessage: "Not stocked",
description: "product variant inventory",
});
}
} else {
if (numAvailable > 0) {
return intl.formatMessage(
{
id: "wWYYBR",
defaultMessage:
"{numLocations,plural,one{{numAvailable} available at {numLocations} location} other{{numAvailable} available at {numLocations} locations}}",
description: "product variant inventory",
},
{
numAvailable,
numLocations: variant.stocks.length,
},
);
} else {
return intl.formatMessage({
id: "6+sMz4",
defaultMessage: "Unavailable in all locations",
description: "product variant inventory",
});
}
}
}
interface ProductVariantsProps extends ListActions, ChannelProps {
productId: string;
disabled: boolean;
interface ProductVariantsProps {
channels: ChannelData[];
errors: ProductVariantListError[];
limits: RefreshLimitsQuery["shop"]["limits"];
product: ProductFragment;
variantAttributes: ProductFragment["productType"]["variantAttributes"];
variants: ProductDetailsVariantFragment[];
onVariantReorder: ReorderAction;
onSetDefaultVariant(variant: ProductDetailsVariantFragment[][0]);
warehouses: WarehouseFragment[];
onAttributeValuesSearch: (
id: string,
query: string,
) => Promise<Array<Choice<string, string>>>;
onChange: (data: DatagridChangeOpts) => void;
onRowClick: (id: string) => void;
}
const numberOfColumns = 7;
export const ProductVariants: React.FC<ProductVariantsProps> = props => {
const {
productId,
disabled,
limits,
export const ProductVariants: React.FC<ProductVariantsProps> = ({
channels,
errors,
variants,
product,
onVariantReorder,
onSetDefaultVariant,
isChecked,
selected,
selectedChannelId,
toggle,
toggleAll,
toolbar,
} = props;
const classes = useStyles(props);
warehouses,
variantAttributes,
onAttributeValuesSearch,
onChange,
onRowClick,
}) => {
const intl = useIntl();
const [warehouse, setWarehouse] = React.useState<string>(null);
const hasVariants = maybe(() => variants.length > 0, true);
const limitReached = isLimitReached(limits, "productVariants");
// const limitReached = isLimitReached(limits, "productVariants");
const columns = React.useMemo(
() =>
variantAttributes && warehouses && channels
? [
"name",
"sku",
...channels?.flatMap(channel => [
`availableInChannel:${channel.id}`,
`channel:${channel.id}`,
]),
...warehouses?.map(warehouse => `stock:${warehouse.id}`),
...variantAttributes
.filter(attribute =>
[
AttributeInputTypeEnum.DROPDOWN,
AttributeInputTypeEnum.PLAIN_TEXT,
].includes(attribute.inputType),
)
.map(attribute => `attribute:${attribute.id}`),
].map(c =>
getColumnData(c, channels, warehouses, variantAttributes, intl),
)
: [],
[variantAttributes, warehouses, channels],
);
const getCellContent = React.useCallback(
([column, row]: Item, opts: GetCellContentOpts) =>
getData({
availableColumns: columns,
column,
row,
channels,
variants,
searchAttributeValues: onAttributeValuesSearch,
...opts,
}),
[columns, variants],
);
const getCellError = React.useCallback(
([column, row]: Item, opts: GetCellContentOpts) =>
getError(errors, {
availableColumns: columns,
column,
row,
channels,
variants,
searchAttributeValues: onAttributeValuesSearch,
...opts,
}),
[columns, variants, errors],
);
return (
<Card>
<CardTitle
title={intl.formatMessage({
id: "1kdQdO",
defaultMessage: "Variants",
description: "section header",
<Datagrid
addButtonLabel={intl.formatMessage({
defaultMessage: "Add variant",
id: "3C3Nj5",
description: "button",
})}
toolbar={
<Button
disabled={limitReached}
href={productVariantAddUrl(productId)}
variant="tertiary"
data-test-id="button-add-variant"
>
<FormattedMessage
id="3VyHbJ"
defaultMessage="Create variant"
description="button"
/>
availableColumns={columns}
emptyText={intl.formatMessage(messages.empty)}
getCellContent={getCellContent}
getCellError={getCellError}
menuItems={index => [
{
label: "Edit Variant",
onSelect: () => onRowClick(variants[index].id),
},
]}
rows={variants?.length ?? 0}
selectionActions={(indexes, { removeRows }) => (
<Button variant="tertiary" onClick={() => removeRows(indexes)}>
<FormattedMessage {...buttonMessages.delete} />
</Button>
}
/>
{limitReached && (
<LimitReachedAlert
className={classes.alert}
title={intl.formatMessage({
id: "FwHWUm",
defaultMessage: "SKU limit reached",
description: "alert",
})}
>
<FormattedMessage
id="5Vwnu+"
defaultMessage="You have reached your SKU limit, you will be no longer able to add SKUs to your store. If you would like to up your limit, contact your administration staff about raising your limits."
/>
</LimitReachedAlert>
)}
{variants.length > 0 ? (
<CardContent className={classes.warehouseSelectContainer}>
<Typography className={classes.warehouseLabel}>
<FormattedMessage
id="FSinkL"
defaultMessage="Available inventory at:"
description="variant stock status"
title={intl.formatMessage(messages.title)}
onChange={onChange}
/>
</Typography>
<LinkChoice
className={classes.select}
choices={getWarehouseChoices(variants, intl)}
name="warehouse"
value={warehouse}
onChange={event => setWarehouse(event.target.value)}
/>
</CardContent>
) : (
<CardContent>
<Typography color={hasVariants ? "textPrimary" : "textSecondary"}>
<FormattedMessage
id="rIJbNC"
defaultMessage="Use variants for products that come in a variety of versions for example different sizes or colors"
/>
</Typography>
</CardContent>
)}
{hasVariants && (
<ResponsiveTable>
<colgroup>
<col className={classes.colGrab} />
<col />
<col className={classes.colName} />
<col className={classes.colSku} />
<col className={classes.colPrice} />
<col className={classes.colInventory} />
<col className={classes.colActions} />
</colgroup>
<TableHead
colSpan={numberOfColumns}
selected={selected}
disabled={disabled}
items={variants}
toggleAll={toggleAll}
toolbar={toolbar}
dragRows
>
<TableCell className={classes.colName}>
<FormattedMessage
id="OTek3r"
defaultMessage="Variant"
description="product variant name"
/>
</TableCell>
<TableCell className={classes.colSku}>
<FormattedMessage id="k4brJy" defaultMessage="SKU" />
</TableCell>
<Hidden smDown>
<TableCell className={classes.colPrice}>
<FormattedMessage
id="n02c9W"
defaultMessage="Price"
description="product variant price"
/>
</TableCell>
</Hidden>
<TableCell className={classes.colInventory}>
<FormattedMessage
id="kL3C+K"
defaultMessage="Inventory"
description="product variant inventory status"
/>
</TableCell>
<TableCell className={classes.colActions}></TableCell>
</TableHead>
<SortableTableBody onSortEnd={onVariantReorder}>
{renderCollection(variants, (variant, variantIndex) => {
const isSelected = variant ? isChecked(variant.id) : false;
const isDefault =
variant && product?.defaultVariant?.id === variant?.id;
const numAvailable =
variant && variant.stocks
? variant.stocks.reduce(
(acc, s) => acc + s.quantity - s.quantityAllocated,
0,
)
: null;
const channel = variant.channelListings.find(
listing => listing.channel.id === selectedChannelId,
);
return (
<SortableTableRow
data-test-id="product-variant-row"
selected={isSelected}
hover={!!variant}
href={productVariantEditUrl(product.id, variant.id)}
key={variant ? variant.id : "skeleton"}
index={variantIndex || 0}
className={classes.link}
>
<TableCell padding="checkbox">
<Checkbox
checked={isSelected}
disabled={disabled}
disableClickPropagation
onChange={() => toggle(variant.id)}
/>
</TableCell>
<TableCell className={classes.colName} data-test-id="name">
{variant ? variant.name || variant.sku : <Skeleton />}
{isDefault && (
<span className={classes.defaultVariant}>
{intl.formatMessage({
id: "vZMs8f",
defaultMessage: "Default",
description: "default product variant indicator",
})}
</span>
)}
</TableCell>
<TableCell className={classes.colSku} data-test-id="sku">
{variant ? variant.sku : <Skeleton />}
</TableCell>
<Hidden smDown>
<TableCell
className={classes.colPrice}
data-test-id="price"
>
{variant ? (
<Money money={channel?.price} />
) : (
<Skeleton />
)}
</TableCell>
</Hidden>
<TableCell
className={classes.colInventory}
data-test-id="inventory"
>
{numAvailable === null ? (
<Skeleton />
) : (
getAvailabilityLabel(
intl,
warehouse,
variant,
numAvailable,
)
)}
</TableCell>
<TableButtonWrapper>
<TableCell
className={classes.colActions}
data-test-id="actions"
>
{variant?.id !== product?.defaultVariant?.id && (
<ProductVariantSetDefault
onSetDefaultVariant={() =>
onSetDefaultVariant(variant)
}
/>
)}
</TableCell>
</TableButtonWrapper>
</SortableTableRow>
);
})}
</SortableTableBody>
</ResponsiveTable>
)}
</Card>
);
};
ProductVariants.displayName = "ProductVariants";

View file

@ -0,0 +1,18 @@
export function makeGetColumnData(
regexp: RegExp,
): (column: string) => string | null {
return column => {
if (!regexp.test(column)) {
return null;
}
return column.match(regexp)[1];
};
}
export const getColumnAttribute = makeGetColumnData(/^attribute:(.*)/);
export const getColumnChannel = makeGetColumnData(/^channel:(.*)/);
export const getColumnChannelAvailability = makeGetColumnData(
/^availableInChannel:(.*)/,
);
export const getColumnStock = makeGetColumnData(/^stock:(.*)/);

View file

@ -0,0 +1,62 @@
import {
DatagridChange,
DatagridChangeOpts,
} from "@saleor/components/Datagrid/useDatagridChange";
import { getColumnChannel, getColumnChannelAvailability } from "./columnData";
const byCurrentRowByIndex = (index: number, data: DatagridChangeOpts) => (
change: DatagridChange,
) => {
const totalRemoved = data.removed.filter(r => r <= index).length;
return change.row === index + totalRemoved;
};
const byChannelColumn = (change: DatagridChange) =>
getColumnChannel(change.column);
const availabilityToChannelColumn = (change: DatagridChange) => {
const availabilityChannelId = getColumnChannelAvailability(change.column);
if (availabilityChannelId) {
return {
data: {
value: change.data ? 0 : null,
},
column: `channel:${availabilityChannelId}`,
};
}
return change;
};
const byColumn = (prev: DatagridChange[], change: DatagridChange) => {
const index = prev.findIndex(p => p.column === change.column);
if (index > -1) {
prev[index] = change;
return prev;
}
return prev.concat(change);
};
const dataGridChangeToFlatChannel = (change: DatagridChange) => ({
channelId: getColumnChannel(change.column),
price: change.data.value,
});
const byNotNullPrice = (
change: ReturnType<typeof dataGridChangeToFlatChannel>,
) => change.price !== null;
export function getVariantChannelsInputs(
data: DatagridChangeOpts,
index: number,
) {
return data.updates
.filter(byCurrentRowByIndex(index, data))
.map(availabilityToChannelColumn)
.filter(byChannelColumn)
.reduce(byColumn, [])
.map(dataGridChangeToFlatChannel)
.filter(byNotNullPrice);
}

View file

@ -0,0 +1,46 @@
import { defineMessages } from "react-intl";
const messages = defineMessages({
name: {
defaultMessage: "Variant name",
id: "J4E+jp",
},
sku: {
defaultMessage: "SKU",
id: "k4brJy",
},
margin: {
defaultMessage: "Margin",
id: "Us9cA1",
description: "profit margin",
},
price: {
defaultMessage: "Price",
id: "4hl9rS",
description: "variant price in channel",
},
available: {
defaultMessage: "Available",
id: "BbP+k3",
description: "variant availability in channel",
},
empty: {
defaultMessage: "Use button above to add new product variants",
id: "IH47ID",
},
warehouses: {
defaultMessage: "Warehouses",
id: "U5Da30",
},
attributes: {
defaultMessage: "Attributes",
id: "+xTpT1",
},
title: {
defaultMessage: "Variants",
id: "1WbTJ5",
description: "product variants, title",
},
});
export default messages;

View file

@ -0,0 +1,370 @@
import { GridCell } from "@glideapps/glide-data-grid";
import { ChannelData } from "@saleor/channels/utils";
import {
booleanCell,
dropdownCell,
moneyCell,
numberCell,
textCell,
} from "@saleor/components/Datagrid/cells";
import { emptyDropdownCellValue } from "@saleor/components/Datagrid/DropdownCell";
import { numberCellEmptyValue } from "@saleor/components/Datagrid/NumberCell";
import { AvailableColumn } from "@saleor/components/Datagrid/types";
import {
DatagridChange,
DatagridChangeOpts,
} from "@saleor/components/Datagrid/useDatagridChange";
import { Choice } from "@saleor/components/SingleSelectField";
import {
ProductDetailsVariantFragment,
ProductFragment,
VariantDatagridChannelListingUpdateMutationVariables,
VariantDatagridStockUpdateMutationVariables,
VariantDatagridUpdateMutationVariables,
WarehouseFragment,
} from "@saleor/graphql";
import { ProductVariantListError } from "@saleor/products/views/ProductUpdate/handlers/errors";
import { mapNodeToChoice } from "@saleor/utils/maps";
import { MutableRefObject } from "react";
import { IntlShape } from "react-intl";
import {
getColumnAttribute,
getColumnChannel,
getColumnChannelAvailability,
getColumnStock,
} from "./datagrid/columnData";
import { getVariantChannelsInputs } from "./datagrid/getVariantChannelsInputs";
import messages from "./messages";
export function getVariantInput(data: DatagridChangeOpts, index: number) {
const attributes = data.updates
.filter(
change =>
getColumnAttribute(change.column) &&
change.row === index + data.removed.filter(r => r <= index).length,
)
.map(change => {
const attributeId = getColumnAttribute(change.column);
return {
id: attributeId,
values: [change.data.value.value],
};
});
const sku = data.updates.find(
change =>
change.column === "sku" &&
change.row === index + data.removed.filter(r => r <= index).length,
)?.data;
const name = data.updates.find(
change =>
change.column === "name" &&
change.row === index + data.removed.filter(r => r <= index).length,
)?.data;
return {
attributes,
sku,
name,
};
}
export function getVariantInputs(
variants: ProductFragment["variants"],
data: DatagridChangeOpts,
): VariantDatagridUpdateMutationVariables[] {
return variants
.map(
(variant, variantIndex): VariantDatagridUpdateMutationVariables => ({
id: variant.id,
input: getVariantInput(data, variantIndex),
}),
)
.filter(
variables =>
variables.input.sku ||
variables.input.name ||
variables.input.attributes.length > 0,
);
}
export function getStockInputs(data: DatagridChangeOpts, index: number) {
const stockChanges = data.updates.filter(change =>
getColumnStock(change.column),
);
const variantChanges = stockChanges
.filter(
change =>
change.row === index + data.removed.filter(r => r <= index).length,
)
.map(change => ({
warehouse: getColumnStock(change.column),
quantity: change.data.value,
}));
return {
stocks: variantChanges.filter(
change => change.quantity !== numberCellEmptyValue,
),
removeStocks: variantChanges
.filter(change => change.quantity === numberCellEmptyValue)
.map(({ warehouse }) => warehouse),
};
}
export function getStocks(
variants: ProductFragment["variants"],
data: DatagridChangeOpts,
): VariantDatagridStockUpdateMutationVariables[] {
return variants
.map((variant, variantIndex) => ({
id: variant.id,
...getStockInputs(data, variantIndex),
}))
.filter(
variables =>
variables.removeStocks.length > 0 || variables.stocks.length > 0,
);
}
export function getVariantChannels(
variants: ProductFragment["variants"],
data: DatagridChangeOpts,
): VariantDatagridChannelListingUpdateMutationVariables[] {
return variants
.map((variant, variantIndex) => ({
id: variant.id,
input: getVariantChannelsInputs(data, variantIndex),
}))
.filter(({ input }) => input.length > 0);
}
function errorMatchesColumn(
error: ProductVariantListError,
columnId: string,
): boolean {
if (error.type === "channel") {
return (
error.channelIds.includes(getColumnChannel(columnId)) ||
error.channelIds.includes(getColumnChannelAvailability(columnId))
);
}
if (error.type === "stock") {
return error.warehouseId.includes(getColumnStock(columnId));
}
if (error.type === "variantData") {
if (error.attributes?.length > 0) {
return error.attributes.includes(getColumnAttribute(columnId));
}
return columnId === "sku";
}
}
export function getError(
errors: ProductVariantListError[],
{ availableColumns, removed, column, row, variants }: GetDataOrError,
): boolean {
if (column === -1) {
return false;
}
const columnId = availableColumns[column].id;
const variantId = variants[row + removed.filter(r => r <= row).length]?.id;
if (!variantId) {
return errors.some(
err => err.type === "create" && err.index === row - variants.length,
);
}
return errors.some(
err =>
err.type !== "create" &&
err.variantId === variantId &&
errorMatchesColumn(err, columnId),
);
}
interface GetDataOrError {
availableColumns: AvailableColumn[];
column: number;
row: number;
variants: ProductDetailsVariantFragment[];
changes: MutableRefObject<DatagridChange[]>;
channels: ChannelData[];
added: number[];
removed: number[];
searchAttributeValues: (
id: string,
text: string,
) => Promise<Array<Choice<string, string>>>;
getChangeIndex: (column: string, row: number) => number;
}
export function getData({
availableColumns,
changes,
added,
removed,
column,
getChangeIndex,
row,
channels,
variants,
searchAttributeValues,
}: GetDataOrError): GridCell {
// For some reason it happens when user deselects channel
if (column === -1) {
return textCell("");
}
const columnId = availableColumns[column].id;
const change = changes.current[getChangeIndex(columnId, row)]?.data;
const dataRow = added.includes(row)
? undefined
: variants[row + removed.filter(r => r <= row).length];
switch (columnId) {
case "name":
case "sku":
const value = change ?? (dataRow ? dataRow[columnId] : "");
return textCell(value || "");
}
if (getColumnStock(columnId)) {
const value =
change?.value ??
dataRow?.stocks.find(
stock => stock.warehouse.id === getColumnStock(columnId),
)?.quantity ??
numberCellEmptyValue;
return numberCell(value);
}
if (getColumnChannel(columnId)) {
const channelId = getColumnChannel(columnId);
const listing = dataRow?.channelListings.find(
listing => listing.channel.id === channelId,
);
const available =
changes.current[getChangeIndex(`availableInChannel:${channelId}`, row)]
?.data ?? !!listing;
if (!available) {
return {
...numberCell(numberCellEmptyValue),
readonly: false,
allowOverlay: false,
};
}
const currency = channels.find(channel => channelId === channel.id)
?.currency;
const value = change?.value ?? listing?.price?.amount ?? 0;
return moneyCell(value, currency);
}
if (getColumnChannelAvailability(columnId)) {
const channelId = getColumnChannelAvailability(columnId);
const listing = dataRow?.channelListings.find(
listing => listing.channel.id === channelId,
);
const value = change ?? !!listing;
return booleanCell(value);
}
if (getColumnAttribute(columnId)) {
const value =
change?.value ??
mapNodeToChoice(
dataRow?.attributes.find(
attribute => attribute.attribute.id === getColumnAttribute(columnId),
)?.values,
)[0] ??
emptyDropdownCellValue;
return dropdownCell(value, {
allowCustomValues: true,
emptyOption: true,
update: text => searchAttributeValues(getColumnAttribute(columnId), text),
});
}
}
export function getColumnData(
name: string,
channels: ChannelData[],
warehouses: WarehouseFragment[],
variantAttributes: ProductFragment["productType"]["variantAttributes"],
intl: IntlShape,
): AvailableColumn {
const common = {
id: name,
width: 200,
// Now we don't weirdly merge top-left header with the frozen column (name),
// leaving rest unnamed group columns (sku in this case) unmerged
group: " ",
};
if (["name", "sku"].includes(name)) {
return {
...common,
title: intl.formatMessage(messages[name]),
};
}
if (getColumnStock(name)) {
return {
...common,
width: 100,
title: warehouses.find(warehouse => warehouse.id === getColumnStock(name))
?.name,
group: intl.formatMessage(messages.warehouses),
};
}
if (getColumnChannel(name)) {
const channel = channels.find(
channel => channel.id === getColumnChannel(name),
);
return {
...common,
width: 150,
title: intl.formatMessage(messages.price),
group: channel.name,
};
}
if (getColumnChannelAvailability(name)) {
const channel = channels.find(
channel => channel.id === getColumnChannelAvailability(name),
);
return {
...common,
width: 80,
title: intl.formatMessage(messages.available),
group: channel.name,
};
}
if (getColumnAttribute(name)) {
return {
...common,
title: variantAttributes.find(
attribute => attribute.id === getColumnAttribute(name),
)?.name,
group: intl.formatMessage(messages.attributes),
};
}
throw new Error(`Unknown column: ${name}`);
}

View file

@ -1,3 +1,4 @@
import { channelsList } from "@saleor/channels/fixtures";
import {
AttributeInputTypeEnum,
ProductFragment,
@ -9,7 +10,6 @@ import {
} from "@saleor/graphql";
import { ProductType } from "@saleor/sdk/dist/apollo/types";
import { RelayToFlat } from "@saleor/types";
import { warehouseList } from "@saleor/warehouses/fixtures";
import * as richTextEditorFixtures from "../components/RichTextEditor/fixtures.json";
@ -222,12 +222,7 @@ export const product: (
{
__typename: "ProductChannelListing",
availableForPurchase: null,
channel: {
__typename: "Channel",
currencyCode: "USD",
id: "123",
name: "Channel1",
},
channel: channelsList[0],
isAvailableForPurchase: false,
isPublished: true,
pricing: {
@ -258,12 +253,7 @@ export const product: (
{
__typename: "ProductChannelListing",
availableForPurchase: null,
channel: {
__typename: "Channel",
currencyCode: "USD",
id: "124",
name: "Channel2",
},
channel: channelsList[1],
isAvailableForPurchase: false,
isPublished: false,
pricing: {
@ -610,142 +600,408 @@ export const product: (
url: "/example-url",
variants: [
{
__typename: "ProductVariant",
channelListings: [],
id: "pv75934",
quantityLimitPerCustomer: 30,
margin: 2,
media: [
id: "UHJvZHVjdFZhcmlhbnQ6MjAz",
sku: "43226647",
name: "1l",
margin: null,
attributes: [
{
__typename: "ProductMedia",
id: "pi92837",
type: ProductMediaType.IMAGE,
oembedData: "{}",
url: placeholderImage,
attribute: {
id: "QXR0cmlidXRlOjE1",
name: "Bottle Size",
__typename: "Attribute",
},
values: [
{
__typename: "ProductMedia",
id: "pi92838",
type: ProductMediaType.IMAGE,
oembedData: "{}",
url: placeholderImage,
id: "QXR0cmlidXRlVmFsdWU6NDc=",
name: "1l",
__typename: "AttributeValue",
},
],
__typename: "SelectedAttribute",
},
],
media: [
{
id: "1",
type: ProductMediaType.IMAGE,
url: placeholderImage,
__typename: "ProductMedia",
},
],
name: "Cordoba Oro",
sku: "87192-94370",
stocks: [
{
__typename: "Stock",
id: "1",
quantity: 1,
id: "U3RvY2s6MTY0",
quantity: 272,
quantityAllocated: 0,
warehouse: warehouseList[0],
warehouse: {
id:
"V2FyZWhvdXNlOjI2NDNiNmIwLWExMTQtNGRiMC1hM2U4LTFkZGY3ZGM3NDVkMg==",
name: "Europe for click and collect",
__typename: "Warehouse",
},
__typename: "Stock",
},
{
id: "U3RvY2s6MTY1",
quantity: 272,
quantityAllocated: 0,
warehouse: {
id:
"V2FyZWhvdXNlOmFmZDA4YjY4LWQwYmMtNGQ1My1iNjJkLTg1YWMxOWI3MjliYg==",
name: "Europe",
__typename: "Warehouse",
},
__typename: "Stock",
id: "2",
quantity: 4,
},
{
id: "U3RvY2s6MTY2",
quantity: 274,
quantityAllocated: 2,
warehouse: warehouseList[1],
warehouse: {
id:
"V2FyZWhvdXNlOjMxOTRjNjY5LTY1YjItNDBjYy04ZDI5LWI3M2Q0YTUwODBmMQ==",
name: "Asia",
__typename: "Warehouse",
},
__typename: "Stock",
},
{
id: "U3RvY2s6MTYz",
quantity: 272,
quantityAllocated: 0,
warehouse: {
id:
"V2FyZWhvdXNlOjI5YzBlYmYwLWVkNzktNDlmOS1hYmQ0LWQwNDBlOGNlZmI3Mg==",
name: "Oceania",
__typename: "Warehouse",
},
__typename: "Stock",
},
{
id: "U3RvY2s6MTY4",
quantity: 272,
quantityAllocated: 0,
warehouse: {
id:
"V2FyZWhvdXNlOjRiNjc1ZmVlLTE3OWYtNGMwNS04YmJlLWE0ZDJjOTc0OWQzMA==",
name: "Africa",
__typename: "Warehouse",
},
__typename: "Stock",
},
{
id: "U3RvY2s6MTY3",
quantity: 274,
quantityAllocated: 2,
warehouse: {
id:
"V2FyZWhvdXNlOmQwODA2MzM5LTVhNjAtNDAxNi1hNGUwLTRjNDYxNTZlY2IzMQ==",
name: "Americas",
__typename: "Warehouse",
},
__typename: "Stock",
},
],
trackInventory: true,
preorder: {
__typename: "PreorderData",
endDate: null,
globalSoldUnits: null,
globalThreshold: 0,
},
},
{
__typename: "ProductVariant",
quantityLimitPerCustomer: null,
preorder: null,
channelListings: [
{
__typename: "ProductVariantChannelListing",
channel: {
__typename: "Channel",
id: "Q2hhbm5lbDox",
name: "Channel-USD",
currencyCode: "USD",
id: "123",
name: "Channel1",
},
costPrice: {
__typename: "Money",
amount: 10,
currency: "USD",
__typename: "Channel",
},
price: {
__typename: "Money",
amount: 1,
amount: 5.0,
currency: "USD",
__typename: "Money",
},
costPrice: {
amount: 1.5,
currency: "USD",
__typename: "Money",
},
preorderThreshold: {
__typename: "PreorderThreshold",
quantity: 0,
quantity: null,
soldUnits: 0,
__typename: "PreorderThreshold",
},
__typename: "ProductVariantChannelListing",
},
{
__typename: "ProductVariantChannelListing",
channel: {
id: "Q2hhbm5lbDoy",
name: "Channel-PLN",
currencyCode: "PLN",
__typename: "Channel",
currencyCode: "USD",
id: "124",
name: "Channel2",
},
costPrice: {
__typename: "Money",
amount: 10,
currency: "USD",
},
price: {
amount: 20.0,
currency: "PLN",
__typename: "Money",
},
costPrice: {
amount: 6.0,
currency: "PLN",
__typename: "Money",
amount: 1,
currency: "USD",
},
preorderThreshold: {
__typename: "PreorderThreshold",
quantity: 0,
quantity: null,
soldUnits: 0,
__typename: "PreorderThreshold",
},
__typename: "ProductVariantChannelListing",
},
],
quantityLimitPerCustomer: null,
__typename: "ProductVariant",
},
{
id: "UHJvZHVjdFZhcmlhbnQ6MjA0",
sku: "80884671",
name: "80884671",
margin: null,
attributes: [
{
attribute: {
id: "QXR0cmlidXRlOjE1",
name: "Bottle Size",
__typename: "Attribute",
},
values: [
{
id: "QXR0cmlidXRlVmFsdWU6NDg=",
name: "2l",
__typename: "AttributeValue",
},
],
__typename: "SelectedAttribute",
},
],
id: "pv68615",
margin: 7,
media: [
{
__typename: "ProductMedia",
id: "pi81234",
id: "1",
type: ProductMediaType.IMAGE,
url: placeholderImage,
__typename: "ProductMedia",
},
],
stocks: [],
trackInventory: true,
preorder: null,
channelListings: [
{
channel: {
id: "Q2hhbm5lbDox",
name: "Channel-USD",
currencyCode: "USD",
__typename: "Channel",
},
price: {
amount: 7.0,
currency: "USD",
__typename: "Money",
},
costPrice: {
amount: 2.0,
currency: "USD",
__typename: "Money",
},
preorderThreshold: {
quantity: null,
soldUnits: 0,
__typename: "PreorderThreshold",
},
__typename: "ProductVariantChannelListing",
},
{
channel: {
id: "Q2hhbm5lbDoy",
name: "Channel-PLN",
currencyCode: "PLN",
__typename: "Channel",
},
price: {
amount: 28.0,
currency: "PLN",
__typename: "Money",
},
costPrice: {
amount: 8.0,
currency: "PLN",
__typename: "Money",
},
preorderThreshold: {
quantity: null,
soldUnits: 0,
__typename: "PreorderThreshold",
},
__typename: "ProductVariantChannelListing",
},
],
quantityLimitPerCustomer: null,
__typename: "ProductVariant",
},
{
id: "UHJvZHVjdFZhcmlhbnQ6MjAy",
sku: "93855755",
name: "500ml",
margin: null,
attributes: [
{
attribute: {
id: "QXR0cmlidXRlOjE1",
name: "Bottle Size",
__typename: "Attribute",
},
values: [
{
id: "QXR0cmlidXRlVmFsdWU6NDY=",
name: "500ml",
__typename: "AttributeValue",
},
],
__typename: "SelectedAttribute",
},
],
media: [
{
id: "1",
type: ProductMediaType.IMAGE,
oembedData: "{}",
url: placeholderImage,
},
{
__typename: "ProductMedia",
id: "pi1236912",
type: ProductMediaType.IMAGE,
oembedData: "{}",
url: placeholderImage,
},
],
name: "silver",
sku: "69055-15190",
stocks: [
{
id: "U3RvY2s6MTU5",
quantity: 418,
quantityAllocated: 0,
warehouse: {
id:
"V2FyZWhvdXNlOmFmZDA4YjY4LWQwYmMtNGQ1My1iNjJkLTg1YWMxOWI3MjliYg==",
name: "Europe",
__typename: "Warehouse",
},
__typename: "Stock",
},
{
id: "U3RvY2s6MTYw",
quantity: 418,
quantityAllocated: 0,
warehouse: {
id:
"V2FyZWhvdXNlOjMxOTRjNjY5LTY1YjItNDBjYy04ZDI5LWI3M2Q0YTUwODBmMQ==",
name: "Asia",
__typename: "Warehouse",
},
__typename: "Stock",
},
{
id: "U3RvY2s6MTYx",
quantity: 418,
quantityAllocated: 0,
warehouse: {
id:
"V2FyZWhvdXNlOmQwODA2MzM5LTVhNjAtNDAxNi1hNGUwLTRjNDYxNTZlY2IzMQ==",
name: "Americas",
__typename: "Warehouse",
},
__typename: "Stock",
},
{
id: "U3RvY2s6MTU3",
quantity: 418,
quantityAllocated: 0,
warehouse: {
id:
"V2FyZWhvdXNlOjI5YzBlYmYwLWVkNzktNDlmOS1hYmQ0LWQwNDBlOGNlZmI3Mg==",
name: "Oceania",
__typename: "Warehouse",
},
__typename: "Stock",
},
{
id: "U3RvY2s6MTU4",
quantity: 418,
quantityAllocated: 0,
warehouse: {
id:
"V2FyZWhvdXNlOjI2NDNiNmIwLWExMTQtNGRiMC1hM2U4LTFkZGY3ZGM3NDVkMg==",
name: "Europe for click and collect",
__typename: "Warehouse",
},
__typename: "Stock",
},
{
id: "U3RvY2s6MTYy",
quantity: 418,
quantityAllocated: 0,
warehouse: {
id:
"V2FyZWhvdXNlOjRiNjc1ZmVlLTE3OWYtNGMwNS04YmJlLWE0ZDJjOTc0OWQzMA==",
name: "Africa",
__typename: "Warehouse",
},
__typename: "Stock",
id: "1",
quantity: 13,
quantityAllocated: 2,
warehouse: warehouseList[0],
},
],
trackInventory: false,
preorder: {
__typename: "PreorderData",
endDate: null,
globalSoldUnits: null,
globalThreshold: 0,
trackInventory: true,
preorder: null,
channelListings: [
{
channel: {
id: "Q2hhbm5lbDox",
name: "Channel-USD",
currencyCode: "USD",
__typename: "Channel",
},
price: {
amount: 5.0,
currency: "USD",
__typename: "Money",
},
costPrice: {
amount: 1.0,
currency: "USD",
__typename: "Money",
},
preorderThreshold: {
quantity: null,
soldUnits: 0,
__typename: "PreorderThreshold",
},
__typename: "ProductVariantChannelListing",
},
{
channel: {
id: "Q2hhbm5lbDoy",
name: "Channel-PLN",
currencyCode: "PLN",
__typename: "Channel",
},
price: {
amount: 20.0,
currency: "PLN",
__typename: "Money",
},
costPrice: {
amount: 4.0,
currency: "PLN",
__typename: "Money",
},
preorderThreshold: {
quantity: null,
soldUnits: 0,
__typename: "PreorderThreshold",
},
__typename: "ProductVariantChannelListing",
},
],
quantityLimitPerCustomer: null,
__typename: "ProductVariant",
},
],
visibleInListings: true,
@ -3122,6 +3378,7 @@ export const variant = (placeholderImage: string): ProductVariantFragment => ({
channelListings: [
{
__typename: "ProductChannelListing",
id: "11",
isPublished: false,
publicationDate: null,
channel: {
@ -3133,6 +3390,7 @@ export const variant = (placeholderImage: string): ProductVariantFragment => ({
},
{
__typename: "ProductChannelListing",
id: "12",
isPublished: true,
publicationDate: "2022-01-21",
channel: {

View file

@ -82,87 +82,11 @@ export const productVariantSetDefault = gql`
`;
export const productUpdateMutation = gql`
mutation ProductUpdate(
$id: ID!
$input: ProductInput!
$firstValues: Int
$afterValues: String
$lastValues: Int
$beforeValues: String
) {
mutation ProductUpdate($id: ID!, $input: ProductInput!) {
productUpdate(id: $id, input: $input) {
errors {
...ProductErrorWithAttributes
}
product {
...Product
}
}
}
`;
export const simpleProductUpdateMutation = gql`
mutation SimpleProductUpdate(
$id: ID!
$input: ProductInput!
$productVariantId: ID!
$productVariantInput: ProductVariantInput!
$addStocks: [StockInput!]!
$deleteStocks: [ID!]!
$updateStocks: [StockInput!]!
$firstValues: Int
$afterValues: String
$lastValues: Int
$beforeValues: String
) {
productUpdate(id: $id, input: $input) {
errors {
...ProductErrorWithAttributes
}
product {
...Product
}
}
productVariantUpdate(id: $productVariantId, input: $productVariantInput) {
errors {
...ProductErrorWithAttributes
}
productVariant {
...ProductVariant
}
}
productVariantStocksCreate(
stocks: $addStocks
variantId: $productVariantId
) {
errors {
...BulkStockError
}
productVariant {
...ProductVariant
}
}
productVariantStocksDelete(
warehouseIds: $deleteStocks
variantId: $productVariantId
) {
errors {
...StockError
}
productVariant {
...ProductVariant
}
}
productVariantStocksUpdate(
stocks: $updateStocks
variantId: $productVariantId
) {
errors {
...BulkStockError
}
productVariant {
...ProductVariant
}
}
}
`;
@ -193,6 +117,48 @@ export const variantDeleteMutation = gql`
}
`;
export const variantDatagridUpdateMutation = gql`
mutation VariantDatagridUpdate($id: ID!, $input: ProductVariantInput!) {
productVariantUpdate(id: $id, input: $input) {
errors {
...ProductErrorWithAttributes
}
}
}
`;
export const variantDatagridStockUpdateMutation = gql`
mutation VariantDatagridStockUpdate(
$stocks: [StockInput!]!
$removeStocks: [ID!]!
$id: ID!
) {
productVariantStocksDelete(warehouseIds: $removeStocks, variantId: $id) {
errors {
...ProductVariantStocksDeleteError
}
}
productVariantStocksUpdate(stocks: $stocks, variantId: $id) {
errors {
...BulkStockError
}
}
}
`;
export const variantDatagridChannelListingUpdateMutation = gql`
mutation VariantDatagridChannelListingUpdate(
$id: ID!
$input: [ProductVariantChannelListingAddInput!]!
) {
productVariantChannelListingUpdate(id: $id, input: $input) {
errors {
...ProductChannelListingError
}
}
}
`;
export const variantUpdateMutation = gql`
mutation VariantUpdate(
$addStocks: [StockInput!]!
@ -382,6 +348,22 @@ export const productBulkDeleteMutation = gql`
}
`;
export const ProductVariantBulkCreateMutation = gql`
mutation ProductVariantBulkCreate(
$id: ID!
$inputs: [ProductVariantBulkCreateInput!]!
) {
productVariantBulkCreate(product: $id, variants: $inputs) {
errors {
...BulkProductError
}
productVariants {
id
}
}
}
`;
export const ProductVariantBulkDeleteMutation = gql`
mutation ProductVariantBulkDelete($ids: [ID!]!) {
productVariantBulkDelete(ids: $ids) {
@ -411,18 +393,6 @@ export const ProductChannelListingUpdateMutation = gql`
$input: ProductChannelListingUpdateInput!
) {
productChannelListingUpdate(id: $id, input: $input) {
product {
id
channelListings {
...ChannelListingProductWithoutPricing
}
variants {
id
channelListings {
...ChannelListingProductVariant
}
}
}
errors {
...ProductChannelListingError
}

View file

@ -79,7 +79,6 @@ export const productListUrl = (params?: ProductListUrlQueryParams): string =>
export const productPath = (id: string) => urlJoin(productSection + id);
export type ProductUrlDialog =
| "remove"
| "remove-variants"
| "assign-attribute-value"
| ChannelsAction;
export type ProductUrlQueryParams = BulkAction &

View file

@ -3,12 +3,10 @@ import {
getSelectedAttributeValues,
mergeChoicesWithValues,
} from "@saleor/attributes/utils/data";
import { ChannelData } from "@saleor/channels/utils";
import {
AttributeInput,
VariantAttributeScope,
} from "@saleor/components/Attributes";
import { MetadataFormData } from "@saleor/components/Metadata/types";
import { SingleAutocompleteChoiceType } from "@saleor/components/SingleAutocompleteSelectField";
import {
ProductDetailsVariantFragment,
@ -26,7 +24,7 @@ import { mapEdgesToItems, mapMetadataItemToInput } from "@saleor/utils/maps";
import moment from "moment";
import { ProductStockInput } from "../components/ProductStocks";
import { ChannelsWithVariantsData } from "../views/ProductUpdate/types";
import { ProductUpdateFormData } from "../components/ProductUpdatePage/types";
export interface Collection {
id: string;
@ -171,19 +169,6 @@ export function getStockInputFromVariant(
);
}
export function getStockInputFromProduct(
product: ProductFragment,
): ProductStockInput[] {
return product?.variants[0]?.stocks.map(stock => ({
data: {
quantityAllocated: stock?.quantityAllocated,
},
id: stock.warehouse.id,
label: stock.warehouse.name,
value: stock.quantity.toString(),
}));
}
export function getCollectionInput(
productCollections: ProductFragment["collections"],
): Collection[] {
@ -208,42 +193,13 @@ export function getChoices(nodes: Node[]): SingleAutocompleteChoiceType[] {
);
}
export interface ProductUpdatePageFormData extends MetadataFormData {
category: string | null;
changeTaxCode: boolean;
channelsWithVariants: ChannelsWithVariantsData;
channelListings: ChannelData[];
channelsData: ChannelData[];
chargeTaxes: boolean;
collections: string[];
isAvailable: boolean;
name: string;
slug: string;
rating: number;
seoDescription: string;
seoTitle: string;
sku: string;
taxCode: string;
trackInventory: boolean;
weight: string;
isPreorder: boolean;
globalThreshold: string;
globalSoldUnits: number;
hasPreorderEndDate: boolean;
preorderEndDateTime?: string;
}
export function getProductUpdatePageFormData(
product: ProductFragment,
variants: ProductDetailsVariantFragment[],
currentChannels: ChannelData[],
channelsData: ChannelData[],
channelsWithVariants: ChannelsWithVariantsData,
): ProductUpdatePageFormData {
): ProductUpdateFormData {
const variant = product?.variants[0];
return {
channelsWithVariants,
channelsData,
category: maybe(() => product.category.id, ""),
changeTaxCode: !!product?.taxType.taxCode,
chargeTaxes: maybe(() => product.chargeTaxes, false),
@ -251,7 +207,6 @@ export function getProductUpdatePageFormData(
() => product.collections.map(collection => collection.id),
[],
),
channelListings: currentChannels.map(listing => ({ ...listing })),
isAvailable: !!product?.isAvailable,
metadata: product?.metadata?.map(mapMetadataItemToInput),
name: maybe(() => product.name, ""),

View file

@ -1,10 +1,10 @@
import {
ChannelData,
ChannelPreorderArgs,
ChannelPriceAndPreorderData,
ChannelPriceArgs,
ChannelPriceData,
} from "@saleor/channels/utils";
import { ProductChannelListingAddInput } from "@saleor/graphql";
import { FormChange, UseFormResult } from "@saleor/hooks/useForm";
import moment from "moment";
@ -26,26 +26,6 @@ export function createChannelsPriceChangeHandler(
};
}
export function createChannelsPreorderChangeHandler(
channelListings: ChannelData[],
updateChannels: (data: ChannelData[]) => void,
triggerChange: () => void,
) {
return (id: string, preorderData: ChannelPreorderArgs) => {
const { preorderThreshold, unitsSold } = preorderData;
const updatedChannels = channelListings.map(channel =>
channel.id === id
? { ...channel, preorderThreshold, unitsSold }
: channel,
);
updateChannels(updatedChannels);
triggerChange();
};
}
export function createChannelsChangeHandler(
channelsData: ChannelData[],
updateChannels: (data: ChannelData[]) => void,
@ -122,9 +102,17 @@ export const getChannelsInput = (channels: ChannelPriceAndPreorderData[]) =>
},
}));
export const getAvailabilityVariables = (channels: ChannelData[]) =>
export const getAvailabilityVariables = (
channels: ChannelData[],
): ProductChannelListingAddInput[] =>
channels.map(channel => {
const { isAvailableForPurchase, availableForPurchase } = channel;
const {
isAvailableForPurchase,
availableForPurchase,
isPublished,
publicationDate,
visibleInListings,
} = channel;
const isAvailable =
availableForPurchase && !isAvailableForPurchase
? true
@ -137,9 +125,9 @@ export const getAvailabilityVariables = (channels: ChannelData[]) =>
: availableForPurchase,
channelId: channel.id,
isAvailableForPurchase: isAvailable,
isPublished: channel.isPublished,
publicationDate: channel.publicationDate,
visibleInListings: channel.visibleInListings,
isPublished,
publicationDate,
visibleInListings,
};
});

View file

@ -1,15 +1,8 @@
import placeholderImg from "@assets/images/placeholder255x255.png";
import { DialogContentText } from "@material-ui/core";
import ChannelsWithVariantsAvailabilityDialog from "@saleor/channels/components/ChannelsWithVariantsAvailabilityDialog";
import {
ChannelData,
createChannelsDataWithPrice,
createSortedChannelsDataFromProduct,
} from "@saleor/channels/utils";
import ActionDialog from "@saleor/components/ActionDialog";
import useAppChannel from "@saleor/components/AppLayout/AppChannelContext";
import { AttributeInput } from "@saleor/components/Attributes";
import ChannelsAvailabilityDialog from "@saleor/components/ChannelsAvailabilityDialog";
import NotFoundPage from "@saleor/components/NotFoundPage";
import { useShopLimitsQuery } from "@saleor/components/Shop/queries";
import { WindowTitle } from "@saleor/components/WindowTitle";
@ -19,37 +12,18 @@ import {
} from "@saleor/config";
import {
ProductMediaCreateMutationVariables,
ProductUpdateMutation,
useAttributeValueDeleteMutation,
useFileUploadMutation,
useProductChannelListingUpdateMutation,
useProductDeleteMutation,
useProductDetailsQuery,
useProductMediaCreateMutation,
useProductMediaDeleteMutation,
useProductMediaReorderMutation,
useProductUpdateMutation,
useProductVariantBulkDeleteMutation,
useProductVariantChannelListingUpdateMutation,
useProductVariantPreorderDeactivateMutation,
useProductVariantReorderMutation,
useSimpleProductUpdateMutation,
useUpdateMetadataMutation,
useUpdatePrivateMetadataMutation,
useVariantCreateMutation,
useWarehouseListQuery,
} from "@saleor/graphql";
import { getSearchFetchMoreProps } from "@saleor/hooks/makeTopLevelSearch/utils";
import useBulkActions from "@saleor/hooks/useBulkActions";
import useChannels from "@saleor/hooks/useChannels";
import useNavigator from "@saleor/hooks/useNavigator";
import useNotifier from "@saleor/hooks/useNotifier";
import useOnSetDefaultVariant from "@saleor/hooks/useOnSetDefaultVariant";
import useShop from "@saleor/hooks/useShop";
import useStateFromProps from "@saleor/hooks/useStateFromProps";
import { commonMessages, errorMessages } from "@saleor/intl";
import { DeleteIcon, IconButton } from "@saleor/macaw-ui";
import ProductVariantEndPreorderDialog from "@saleor/products/components/ProductVariantEndPreorderDialog";
import { useSearchAttributeValuesSuggestions } from "@saleor/searches/useAttributeValueSearch";
import useCategorySearch from "@saleor/searches/useCategorySearch";
import useCollectionSearch from "@saleor/searches/useCollectionSearch";
import usePageSearch from "@saleor/searches/usePageSearch";
@ -57,9 +31,7 @@ import useProductSearch from "@saleor/searches/useProductSearch";
import { getProductErrorMessage } from "@saleor/utils/errors";
import useAttributeValueSearchHandler from "@saleor/utils/handlers/attributeValueSearchHandler";
import createDialogActionHandlers from "@saleor/utils/handlers/dialogActionHandlers";
import createMetadataUpdateHandler from "@saleor/utils/handlers/metadataUpdateHandler";
import { mapEdgesToItems } from "@saleor/utils/maps";
import { warehouseAddPath } from "@saleor/warehouses/urls";
import React from "react";
import { defineMessages, FormattedMessage, useIntl } from "react-intl";
@ -70,18 +42,13 @@ import {
productUrl,
ProductUrlDialog,
ProductUrlQueryParams,
productVariantEditUrl,
} from "../../urls";
import {
CHANNELS_AVAILIABILITY_MODAL_SELECTOR,
PRODUCT_UPDATE_FORM_ID,
} from "./consts";
import {
createImageReorderHandler,
createImageUploadHandler,
createUpdateHandler,
createVariantReorderHandler,
} from "./handlers";
import useChannelVariantListings from "./useChannelVariantListings";
import { useProductUpdateHandler } from "./handlers/useProductUpdateHandler";
const messages = defineMessages({
deleteProductDialogTitle: {
@ -115,9 +82,6 @@ interface ProductUpdateProps {
export const ProductUpdate: React.FC<ProductUpdateProps> = ({ id, params }) => {
const navigate = useNavigator();
const notify = useNotifier();
const { isSelected, listElements, reset, toggle, toggleAll } = useBulkActions(
params.ids,
);
const intl = useIntl();
const {
loadMore: loadMoreCategories,
@ -153,13 +117,6 @@ export const ProductUpdate: React.FC<ProductUpdateProps> = ({ id, params }) => {
result: searchAttributeValuesOpts,
reset: searchAttributeReset,
} = useAttributeValueSearchHandler(DEFAULT_INITIAL_SEARCH_DATA);
const shop = useShop();
const [updateMetadata] = useUpdateMetadataMutation({});
const [updatePrivateMetadata] = useUpdatePrivateMetadataMutation({});
const [
productVariantCreate,
productVariantCreateOpts,
] = useVariantCreateMutation({});
const { data, loading, refetch } = useProductDetailsQuery({
displayLoader: true,
@ -171,7 +128,7 @@ export const ProductUpdate: React.FC<ProductUpdateProps> = ({ id, params }) => {
const isSimpleProduct = !data?.product?.productType?.hasVariants;
const { availableChannels, channel } = useAppChannel(!isSimpleProduct);
const { availableChannels } = useAppChannel(false);
const limitOpts = useShopLimitsQuery({
variables: {
@ -179,26 +136,6 @@ export const ProductUpdate: React.FC<ProductUpdateProps> = ({ id, params }) => {
},
});
const [uploadFile, uploadFileOpts] = useFileUploadMutation({});
const handleUpdate = (data: ProductUpdateMutation) => {
if (data.productUpdate.errors.length === 0) {
notify({
status: "success",
text: intl.formatMessage(commonMessages.savedChanges),
});
}
};
const [updateProduct, updateProductOpts] = useProductUpdateMutation({
onCompleted: handleUpdate,
});
const [
updateSimpleProduct,
updateSimpleProductOpts,
] = useSimpleProductUpdateMutation({
onCompleted: handleUpdate,
});
const [
reorderProductImages,
reorderProductImagesOpts,
@ -245,107 +182,21 @@ export const ProductUpdate: React.FC<ProductUpdateProps> = ({ id, params }) => {
}),
});
const [
bulkProductVariantDelete,
bulkProductVariantDeleteOpts,
] = useProductVariantBulkDeleteMutation({
onCompleted: data => {
if (data.productVariantBulkDelete.errors.length === 0) {
closeModal();
reset();
refetch();
limitOpts.refetch();
}
},
});
const [openModal, closeModal] = createDialogActionHandlers<
ProductUrlDialog,
ProductUrlQueryParams
>(navigate, params => productUrl(id, params), params);
const [
isEndPreorderModalOpened,
setIsEndPreorderModalOpened,
] = React.useState(false);
const product = data?.product;
// useMemo saves, like, 46 rerenders here
const allChannels: ChannelData[] = React.useMemo(
() =>
createChannelsDataWithPrice(
product,
availableChannels,
).sort((channel, nextChannel) =>
channel.name.localeCompare(nextChannel.name),
),
[product, availableChannels],
);
const [channelsData, setChannelsData] = useStateFromProps(allChannels);
const {
channels: updatedChannels,
channelsWithVariantsData,
setChannelVariantListing,
} = useChannelVariantListings(allChannels);
const productChannelsChoices: ChannelData[] = createSortedChannelsDataFromProduct(
product,
);
const {
channelListElements,
channelsToggle,
currentChannels,
handleChannelsConfirm,
handleChannelsModalClose,
handleChannelsModalOpen,
isChannelSelected,
isChannelsModalOpen,
setCurrentChannels,
toggleAllChannels,
} = useChannels(
productChannelsChoices,
params?.action,
{
closeModal,
openModal,
},
{ formId: PRODUCT_UPDATE_FORM_ID },
);
const warehouses = useWarehouseListQuery({
const getAttributeValuesSuggestions = useSearchAttributeValuesSuggestions();
const warehousesQuery = useWarehouseListQuery({
displayLoader: true,
variables: {
first: 50,
filter: {
channels: currentChannels.map(channel => channel.id),
},
},
});
const [
updateChannels,
updateChannelsOpts,
] = useProductChannelListingUpdateMutation({
onCompleted: data => {
if (!!data.productChannelListingUpdate.errors.length) {
data.productChannelListingUpdate.errors.forEach(error =>
notify({
status: "error",
text: getProductErrorMessage(error, intl),
}),
);
}
},
});
const [
updateVariantChannels,
updateVariantChannelsOpts,
] = useProductVariantChannelListingUpdateMutation({});
const [
createProductMedia,
createProductMediaOpts,
@ -381,41 +232,16 @@ export const ProductUpdate: React.FC<ProductUpdateProps> = ({ id, params }) => {
});
};
const [
deleteAttributeValue,
deleteAttributeValueOpts,
] = useAttributeValueDeleteMutation({});
const onSetDefaultVariant = useOnSetDefaultVariant(
product ? product.id : null,
null,
);
const [
reorderProductVariants,
reorderProductVariantsOpts,
] = useProductVariantReorderMutation({});
const handleBack = () => navigate(productListUrl());
const handleImageDelete = (id: string) => () =>
deleteProductImage({ variables: { id } });
const handleSubmit = createMetadataUpdateHandler(
product,
createUpdateHandler(
product,
allChannels,
variables => uploadFile({ variables }),
variables => updateProduct({ variables }),
variables => updateSimpleProduct({ variables }),
updateChannels,
updateVariantChannels,
productVariantCreate,
variables => deleteAttributeValue({ variables }),
),
variables => updateMetadata({ variables }),
variables => updatePrivateMetadata({ variables }),
const [submit, submitOpts] = useProductUpdateHandler(product);
const warehouses = React.useMemo(
() => mapEdgesToItems(warehousesQuery.data?.warehouses) || [],
[warehousesQuery.data],
);
const handleImageUpload = createImageUploadHandler(id, variables =>
@ -425,22 +251,6 @@ export const ProductUpdate: React.FC<ProductUpdateProps> = ({ id, params }) => {
reorderProductImages({ variables }),
);
const handleVariantReorder = createVariantReorderHandler(product, variables =>
reorderProductVariants({ variables }),
);
const handleDeactivatePreorder = async () => {
await handleDeactivateVariantPreorder(product.variants[0].id);
setIsEndPreorderModalOpened(false);
};
const [
deactivatePreorder,
deactivatePreoderOpts,
] = useProductVariantPreorderDeactivateMutation({});
const handleDeactivateVariantPreorder = (id: string) =>
deactivatePreorder({ variables: { id } });
const handleAssignAttributeReferenceClick = (attribute: AttributeInput) =>
navigate(
productUrl(id, {
@ -451,26 +261,17 @@ export const ProductUpdate: React.FC<ProductUpdateProps> = ({ id, params }) => {
);
const disableFormSave =
uploadFileOpts.loading ||
submitOpts.loading ||
createProductImageOpts.loading ||
deleteProductOpts.loading ||
reorderProductImagesOpts.loading ||
updateProductOpts.loading ||
reorderProductVariantsOpts.loading ||
updateChannelsOpts.loading ||
updateVariantChannelsOpts.loading ||
productVariantCreateOpts.loading ||
deactivatePreoderOpts.loading ||
deleteAttributeValueOpts.loading ||
createProductMediaOpts.loading ||
loading;
const formTransitionState = getMutationState(
updateProductOpts.called || updateSimpleProductOpts.called,
updateProductOpts.loading || updateSimpleProductOpts.loading,
updateProductOpts.data?.productUpdate.errors,
updateSimpleProductOpts.data?.productUpdate.errors,
updateSimpleProductOpts.data?.productVariantUpdate.errors,
submitOpts.called,
submitOpts.loading,
submitOpts.errors,
createProductMediaOpts.data?.productMediaCreate.errors,
);
@ -482,18 +283,6 @@ export const ProductUpdate: React.FC<ProductUpdateProps> = ({ id, params }) => {
const attributeValues =
mapEdgesToItems(searchAttributeValuesOpts?.data?.attribute.choices) || [];
const errors = [
...(updateProductOpts.data?.productUpdate.errors || []),
...(updateSimpleProductOpts.data?.productUpdate.errors || []),
...(productVariantCreateOpts.data?.productVariantCreate.errors || []),
];
const channelsErrors = [
...(updateChannelsOpts?.data?.productChannelListingUpdate?.errors || []),
...(updateVariantChannelsOpts?.data?.productVariantChannelListingUpdate
?.errors || []),
];
const fetchMoreCollections = getSearchFetchMoreProps(
searchCollectionsOpts,
loadMoreCollections,
@ -528,94 +317,43 @@ export const ProductUpdate: React.FC<ProductUpdateProps> = ({ id, params }) => {
return (
<>
<WindowTitle title={data?.product?.name} />
{!!allChannels?.length &&
(isSimpleProduct || product?.variants.length === 0 ? (
<ChannelsAvailabilityDialog
isSelected={isChannelSelected}
channels={allChannels}
onChange={channelsToggle}
onClose={handleChannelsModalClose}
open={isChannelsModalOpen}
title={intl.formatMessage({
id: "Eau5AV",
defaultMessage: "Manage Products Channel Availability",
})}
confirmButtonState="default"
selected={channelListElements.length}
onConfirm={handleChannelsConfirm}
toggleAll={toggleAllChannels}
/>
) : (
<ChannelsWithVariantsAvailabilityDialog
channels={updatedChannels}
variants={product?.variants}
open={params.action === CHANNELS_AVAILIABILITY_MODAL_SELECTOR}
onClose={closeModal}
onConfirm={listings => {
closeModal();
setChannelVariantListing(listings);
}}
/>
))}
<ProductUpdatePage
channels={availableChannels}
productId={id}
isSimpleProduct={isSimpleProduct}
openChannelsModal={handleChannelsModalOpen}
onChannelsChange={setCurrentChannels}
channelsErrors={channelsErrors}
currentChannels={currentChannels}
allChannelsCount={allChannels?.length}
channelsData={channelsData}
setChannelsData={setChannelsData}
channelsErrors={submitOpts.channelsErrors}
categories={categories}
collections={collections}
attributeValues={attributeValues}
channelsWithVariantsData={channelsWithVariantsData}
defaultWeightUnit={shop?.defaultWeightUnit}
disabled={disableFormSave}
onSetDefaultVariant={onSetDefaultVariant}
errors={errors}
errors={submitOpts.errors}
variantListErrors={submitOpts.variantListErrors}
fetchCategories={searchCategories}
fetchCollections={searchCollections}
fetchAttributeValues={searchAttributeValues}
refetch={refetch}
limits={limitOpts.data?.shop.limits}
saveButtonBarState={formTransitionState}
media={data?.product?.media}
header={product?.name}
placeholderImage={placeholderImg}
product={product}
warehouses={mapEdgesToItems(warehouses?.data?.warehouses) || []}
warehouses={warehouses}
taxTypes={data?.taxTypes}
variants={product?.variants}
onDelete={() => openModal("remove")}
onImageReorder={handleImageReorder}
onMediaUrlUpload={handleMediaUrlUpload}
onSubmit={handleSubmit}
onWarehouseConfigure={() => navigate(warehouseAddPath)}
onVariantReorder={handleVariantReorder}
onVariantEndPreorderDialogOpen={() => setIsEndPreorderModalOpened(true)}
onImageUpload={handleImageUpload}
onImageDelete={handleImageDelete}
toolbar={
<IconButton
variant="secondary"
color="primary"
onClick={() =>
openModal("remove-variants", {
ids: listElements,
onSubmit={submit}
onVariantShow={variantId =>
navigate(productVariantEditUrl(product.id, variantId), {
resetScroll: true,
})
}
>
<DeleteIcon />
</IconButton>
}
isChecked={isSelected}
selected={listElements.length}
toggle={toggle}
toggleAll={toggleAll}
onImageUpload={handleImageUpload}
onImageDelete={handleImageDelete}
fetchMoreCategories={fetchMoreCategories}
fetchMoreCollections={fetchMoreCollections}
selectedChannelId={channel?.id}
assignReferencesAttributeId={
params.action === "assign-attribute-value" && params.id
}
@ -631,6 +369,7 @@ export const ProductUpdate: React.FC<ProductUpdateProps> = ({ id, params }) => {
fetchMoreAttributeValues={fetchMoreAttributeValues}
onCloseDialog={() => navigate(productUrl(id), { resetScroll: false })}
onAttributeSelectBlur={searchAttributeReset}
onAttributeValuesSearch={getAttributeValuesSuggestions}
/>
<ActionDialog
open={params.action === "remove"}
@ -647,39 +386,6 @@ export const ProductUpdate: React.FC<ProductUpdateProps> = ({ id, params }) => {
/>
</DialogContentText>
</ActionDialog>
<ActionDialog
open={params.action === "remove-variants"}
onClose={closeModal}
confirmButtonState={bulkProductVariantDeleteOpts.status}
onConfirm={() =>
bulkProductVariantDelete({
variables: {
ids: params.ids,
},
})
}
variant="delete"
title={intl.formatMessage(messages.deleteVariantDialogTitle)}
>
<DialogContentText>
<FormattedMessage
{...messages.deleteVariantDialogSubtitle}
values={{
counter: params?.ids?.length,
displayQuantity: <strong>{params?.ids?.length}</strong>,
}}
/>
</DialogContentText>
</ActionDialog>
{isSimpleProduct && !!product?.variants?.[0]?.preorder && (
<ProductVariantEndPreorderDialog
confirmButtonState={deactivatePreoderOpts.status}
onClose={() => setIsEndPreorderModalOpened(false)}
onConfirm={handleDeactivatePreorder}
open={isEndPreorderModalOpened}
variantGlobalSoldUnits={product.variants[0].preorder.globalSoldUnits}
/>
)}
</>
);
};

View file

@ -1,11 +1,3 @@
import { ChannelWithVariantData } from "./types";
export const CHANNELS_AVAILIABILITY_MODAL_SELECTOR = "open-channels-picker";
export const initialChannelWithVariantData: ChannelWithVariantData = {
variantsIdsToRemove: [],
variantsIdsToAdd: [],
selectedVariantsIds: [],
};
export const PRODUCT_UPDATE_FORM_ID = Symbol();

View file

@ -0,0 +1,120 @@
import { FetchResult } from "@apollo/client";
import {
ProductChannelListingUpdateMutation,
ProductErrorCode,
ProductVariantBulkCreateMutation,
ProductVariantChannelListingUpdateMutation,
ProductVariantChannelListingUpdateMutationVariables,
StockInput,
VariantDatagridStockUpdateMutation,
VariantDatagridStockUpdateMutationVariables,
VariantDatagridUpdateMutation,
VariantDatagridUpdateMutationVariables,
} from "@saleor/graphql";
import { hasMutationErrors } from "@saleor/misc";
export type ProductVariantListError =
| {
__typename: "DatagridError";
attributes: string[] | null;
error: ProductErrorCode;
variantId: string;
type: "variantData";
}
| {
__typename: "DatagridError";
variantId: string;
warehouseId: string;
type: "stock";
}
| {
__typename: "DatagridError";
error: ProductErrorCode;
variantId: string;
channelIds: string[];
type: "channel";
}
| {
__typename: "DatagridError";
error: ProductErrorCode;
index: number;
type: "create";
};
export function getProductVariantListErrors(
productChannelsUpdateResult: FetchResult<ProductChannelListingUpdateMutation>,
variantMutationResults: FetchResult[],
): ProductVariantListError[] {
return [productChannelsUpdateResult, ...variantMutationResults]
.filter(hasMutationErrors)
.flatMap(result => {
if (result.data.productVariantChannelListingUpdate) {
const data = result.data as ProductVariantChannelListingUpdateMutation;
return data.productVariantChannelListingUpdate.errors.map<
ProductVariantListError
>(error => ({
__typename: "DatagridError",
type: "channel",
error: error.code,
variantId: (result.extensions
.variables as ProductVariantChannelListingUpdateMutationVariables)
.id,
channelIds: error.channels,
}));
}
if (result.data.productVariantStocksUpdate) {
const data = result.data as VariantDatagridStockUpdateMutation;
const variables = result.extensions
.variables as VariantDatagridStockUpdateMutationVariables;
return [
...data.productVariantStocksUpdate.errors.map<
ProductVariantListError
>(error => ({
__typename: "DatagridError",
type: "stock",
variantId: (variables as VariantDatagridStockUpdateMutationVariables)
.id,
warehouseId: (variables.stocks as StockInput[])[error.index]
.warehouse,
})),
...data.productVariantStocksDelete.errors.map<
ProductVariantListError
>(() => ({
__typename: "DatagridError",
type: "stock",
variantId: (variables as VariantDatagridStockUpdateMutationVariables)
.id,
warehouseId: null,
})),
];
}
if (result.data.productVariantUpdate) {
const data = result.data as VariantDatagridUpdateMutation;
const variables = result.extensions
.variables as VariantDatagridUpdateMutationVariables;
return data.productVariantUpdate.errors.map<ProductVariantListError>(
error => ({
__typename: "DatagridError",
type: "variantData",
variantId: (variables as VariantDatagridUpdateMutationVariables).id,
error: error.code,
attributes: error.attributes,
}),
);
}
if (result.data.productVariantBulkCreate) {
const data = result.data as ProductVariantBulkCreateMutation;
return data.productVariantBulkCreate.errors.map<
ProductVariantListError
>(error => ({
__typename: "DatagridError",
type: "create",
index: error.index,
error: error.code,
}));
}
});
}

View file

@ -1,218 +1,14 @@
import { FetchResult } from "@apollo/client";
import {
getAttributesAfterFileAttributesUpdate,
mergeAttributeValueDeleteErrors,
mergeFileUploadErrors,
} from "@saleor/attributes/utils/data";
import {
handleDeleteMultipleAttributeValues,
handleUploadMultipleFiles,
prepareAttributesInput,
} from "@saleor/attributes/utils/handlers";
import { ChannelData } from "@saleor/channels/utils";
import { VALUES_PAGINATE_BY } from "@saleor/config";
import {
AttributeErrorFragment,
AttributeValueDeleteMutation,
AttributeValueDeleteMutationVariables,
BulkStockErrorFragment,
FileUploadMutation,
FileUploadMutationVariables,
Node,
ProductChannelListingErrorFragment,
ProductChannelListingUpdateMutation,
ProductChannelListingUpdateMutationVariables,
ProductErrorFragment,
ProductFragment,
ProductMediaCreateMutationVariables,
ProductMediaReorderMutationVariables,
ProductUpdateMutation,
ProductUpdateMutationVariables,
ProductVariantChannelListingUpdateMutation,
ProductVariantChannelListingUpdateMutationVariables,
ProductVariantReorderMutationVariables,
SimpleProductUpdateMutation,
SimpleProductUpdateMutationVariables,
StockErrorFragment,
UploadErrorFragment,
VariantCreateMutation,
VariantCreateMutationVariables,
ProductVariantReorderMutationFn,
} from "@saleor/graphql";
import { ProductUpdatePageSubmitData } from "@saleor/products/components/ProductUpdatePage";
import {
getAttributeInputFromProduct,
mapFormsetStockToStockInput,
} from "@saleor/products/utils/data";
import { ReorderEvent } from "@saleor/types";
import { move } from "@saleor/utils/lists";
import { getParsedDataForJsonStringField } from "@saleor/utils/richText/misc";
import { arrayMove } from "react-sortable-hoc";
import {
getChannelsVariables,
getSimpleChannelsVariables,
getSimpleProductErrors,
getSimpleProductVariables,
getVariantChannelsInput,
} from "./utils";
type SubmitErrors = Array<
| ProductErrorFragment
| StockErrorFragment
| BulkStockErrorFragment
| AttributeErrorFragment
| UploadErrorFragment
| ProductChannelListingErrorFragment
>;
export function createUpdateHandler(
product: ProductFragment,
allChannels: ChannelData[],
uploadFile: (
variables: FileUploadMutationVariables,
) => Promise<FetchResult<FileUploadMutation>>,
updateProduct: (
variables: ProductUpdateMutationVariables,
) => Promise<FetchResult<ProductUpdateMutation>>,
updateSimpleProduct: (
variables: SimpleProductUpdateMutationVariables,
) => Promise<FetchResult<SimpleProductUpdateMutation>>,
updateChannels: (options: {
variables: ProductChannelListingUpdateMutationVariables;
}) => Promise<FetchResult<ProductChannelListingUpdateMutation>>,
updateVariantChannels: (options: {
variables: ProductVariantChannelListingUpdateMutationVariables;
}) => Promise<FetchResult<ProductVariantChannelListingUpdateMutation>>,
productVariantCreate: (options: {
variables: VariantCreateMutationVariables;
}) => Promise<FetchResult<VariantCreateMutation>>,
deleteAttributeValue: (
variables: AttributeValueDeleteMutationVariables,
) => Promise<FetchResult<AttributeValueDeleteMutation>>,
) {
return async (data: ProductUpdatePageSubmitData) => {
let errors: SubmitErrors = [];
const uploadFilesResult = await handleUploadMultipleFiles(
data.attributesWithNewFileValue,
uploadFile,
);
const deleteAttributeValuesResult = await handleDeleteMultipleAttributeValues(
data.attributesWithNewFileValue,
product?.attributes,
deleteAttributeValue,
);
errors = [
...errors,
...mergeFileUploadErrors(uploadFilesResult),
...mergeAttributeValueDeleteErrors(deleteAttributeValuesResult),
];
const updatedFileAttributes = getAttributesAfterFileAttributesUpdate(
data.attributesWithNewFileValue,
uploadFilesResult,
);
const productVariables: ProductUpdateMutationVariables = {
id: product.id,
input: {
attributes: prepareAttributesInput({
attributes: data.attributes,
prevAttributes: getAttributeInputFromProduct(product),
updatedFileAttributes,
}),
category: data.category,
chargeTaxes: data.chargeTaxes,
collections: data.collections,
description: getParsedDataForJsonStringField(data.description),
name: data.name,
rating: data.rating,
seo: {
description: data.seoDescription,
title: data.seoTitle,
},
slug: data.slug,
taxCode: data.changeTaxCode ? data.taxCode : null,
},
firstValues: VALUES_PAGINATE_BY,
};
if (product.productType.hasVariants) {
const result = await updateProduct(productVariables);
errors = [...errors, ...result.data.productUpdate.errors];
if (product.variants.length === 0) {
await updateChannels(getSimpleChannelsVariables(data, product));
} else {
await updateChannels(getChannelsVariables(product, allChannels, data));
}
} else {
if (!product.variants.length) {
const productVariantResult = await productVariantCreate({
variables: {
input: {
attributes:
product.productType.variantAttributes?.map(attribute => ({
id: attribute.id,
values: attribute.choices.edges.map(value => value.node.slug),
})) || [],
product: product.id,
sku: data.sku,
stocks: data.updateStocks.map(mapFormsetStockToStockInput),
},
},
});
errors = [
...errors,
...productVariantResult.data.productVariantCreate.errors,
];
const variantId =
productVariantResult.data.productVariantCreate?.productVariant?.id;
if (variantId) {
updateVariantChannels({
variables: {
id: variantId,
input: getVariantChannelsInput(data),
},
});
await updateChannels(
getChannelsVariables(product, allChannels, data),
);
const result = await updateSimpleProduct(
getSimpleProductVariables(productVariables, data, variantId),
);
errors = [...errors, ...getSimpleProductErrors(result.data)];
}
} else {
const result = await updateSimpleProduct(
getSimpleProductVariables(
productVariables,
data,
product.variants[0].id,
),
);
errors = [...errors, ...getSimpleProductErrors(result.data)];
await updateChannels(getSimpleChannelsVariables(data, product));
updateVariantChannels({
variables: {
id: product.variants[0].id,
input: getVariantChannelsInput(data),
},
});
}
}
return errors;
};
}
export function createImageUploadHandler(
id: string,
createProductImage: (variables: ProductMediaCreateMutationVariables) => void,
@ -247,30 +43,37 @@ function areVariantsEqual(a: Node, b: Node) {
export function createVariantReorderHandler<
T extends { id: string; variants: any[] }
>(
product: T,
reorderProductVariants: (
variables: ProductVariantReorderMutationVariables,
) => void,
) {
>(product: T, reorderProductVariants: ProductVariantReorderMutationFn) {
return ({ newIndex, oldIndex }: ReorderEvent) => {
const oldVariantOrder = [...product.variants];
product.variants = [
reorderProductVariants({
variables: {
move: {
id: oldVariantOrder[oldIndex].id,
sortOrder: newIndex - oldIndex,
},
productId: product.id,
},
optimisticResponse: () => ({
__typename: "Mutation",
productVariantReorder: {
__typename: "ProductVariantReorder",
errors: [],
product: {
__typename: "Product",
id: product.id,
variants: [
...move<T["variants"][0]>(
product.variants[oldIndex],
product!.variants,
areVariantsEqual,
newIndex,
),
];
reorderProductVariants({
move: {
id: oldVariantOrder[oldIndex].id,
sortOrder: newIndex - oldIndex,
],
},
productId: product.id,
},
}),
});
};
}

View file

@ -0,0 +1,241 @@
import { FetchResult } from "@apollo/client";
import {
mergeAttributeValueDeleteErrors,
mergeFileUploadErrors,
} from "@saleor/attributes/utils/data";
import {
handleDeleteMultipleAttributeValues,
handleUploadMultipleFiles,
} from "@saleor/attributes/utils/handlers";
import {
AttributeErrorFragment,
BulkProductErrorFragment,
MetadataErrorFragment,
ProductChannelListingErrorFragment,
ProductErrorFragment,
ProductErrorWithAttributesFragment,
ProductFragment,
UploadErrorFragment,
useAttributeValueDeleteMutation,
useFileUploadMutation,
useProductChannelListingUpdateMutation,
useProductUpdateMutation,
useProductVariantBulkCreateMutation,
useProductVariantBulkDeleteMutation,
useUpdateMetadataMutation,
useUpdatePrivateMetadataMutation,
useVariantDatagridChannelListingUpdateMutation,
useVariantDatagridStockUpdateMutation,
useVariantDatagridUpdateMutation,
} from "@saleor/graphql";
import useNotifier from "@saleor/hooks/useNotifier";
import { commonMessages } from "@saleor/intl";
import { ProductUpdateSubmitData } from "@saleor/products/components/ProductUpdatePage/types";
import { getVariantChannelsInputs } from "@saleor/products/components/ProductVariants/datagrid/getVariantChannelsInputs";
import {
getStockInputs,
getStocks,
getVariantChannels,
getVariantInput,
getVariantInputs,
} from "@saleor/products/components/ProductVariants/utils";
import { getProductErrorMessage } from "@saleor/utils/errors";
import createMetadataUpdateHandler from "@saleor/utils/handlers/metadataUpdateHandler";
import { useState } from "react";
import { useIntl } from "react-intl";
import { getProductVariantListErrors, ProductVariantListError } from "./errors";
import {
getProductChannelsUpdateVariables,
getProductUpdateVariables,
} from "./utils";
export type UseProductUpdateHandlerError =
| ProductErrorWithAttributesFragment
| ProductErrorFragment
| BulkProductErrorFragment
| AttributeErrorFragment
| UploadErrorFragment
| ProductChannelListingErrorFragment
| ProductVariantListError;
type UseProductUpdateHandler = (
data: ProductUpdateSubmitData,
) => Promise<Array<UseProductUpdateHandlerError | MetadataErrorFragment>>;
interface UseProductUpdateHandlerOpts {
called: boolean;
loading: boolean;
errors: ProductErrorWithAttributesFragment[];
variantListErrors: ProductVariantListError[];
channelsErrors: ProductChannelListingErrorFragment[];
}
export function useProductUpdateHandler(
product: ProductFragment,
): [UseProductUpdateHandler, UseProductUpdateHandlerOpts] {
const intl = useIntl();
const notify = useNotifier();
const [variantListErrors, setVariantListErrors] = useState<
ProductVariantListError[]
>([]);
const [called, setCalled] = useState(false);
const [loading, setLoading] = useState(false);
const [updateMetadata] = useUpdateMetadataMutation({});
const [updatePrivateMetadata] = useUpdatePrivateMetadataMutation({});
const [updateStocks] = useVariantDatagridStockUpdateMutation({});
const [updateVariant] = useVariantDatagridUpdateMutation();
const [createVariants] = useProductVariantBulkCreateMutation();
const [deleteVariants] = useProductVariantBulkDeleteMutation();
const [uploadFile] = useFileUploadMutation();
const [updateProduct, updateProductOpts] = useProductUpdateMutation();
const [
updateChannels,
updateChannelsOpts,
] = useProductChannelListingUpdateMutation({
onCompleted: data => {
if (!!data.productChannelListingUpdate.errors.length) {
data.productChannelListingUpdate.errors.forEach(error =>
notify({
status: "error",
text: getProductErrorMessage(error, intl),
}),
);
}
},
});
const [
updateVariantChannels,
] = useVariantDatagridChannelListingUpdateMutation();
const [deleteAttributeValue] = useAttributeValueDeleteMutation();
const sendMutations = async (
data: ProductUpdateSubmitData,
): Promise<UseProductUpdateHandlerError[]> => {
let errors: UseProductUpdateHandlerError[] = [];
const uploadFilesResult = await handleUploadMultipleFiles(
data.attributesWithNewFileValue,
variables => uploadFile({ variables }),
);
const deleteAttributeValuesResult = await handleDeleteMultipleAttributeValues(
data.attributesWithNewFileValue,
product?.attributes,
variables => deleteAttributeValue({ variables }),
);
errors = [
...errors,
...mergeFileUploadErrors(uploadFilesResult),
...mergeAttributeValueDeleteErrors(deleteAttributeValuesResult),
];
if (data.variants.removed.length > 0) {
errors.push(
...(
await deleteVariants({
variables: {
ids: data.variants.removed.map(
index => product.variants[index].id,
),
},
})
).data.productVariantBulkDelete.errors,
);
}
const result = await updateProduct({
variables: getProductUpdateVariables(product, data, uploadFilesResult),
});
errors = [...errors, ...result.data.productUpdate.errors];
const productChannelsUpdateResult = await updateChannels({
variables: getProductChannelsUpdateVariables(product, data),
});
const mutations: Array<Promise<FetchResult>> = [
...getStocks(product.variants, data.variants).map(variables =>
updateStocks({ variables }),
),
...getVariantInputs(product.variants, data.variants).map(variables =>
updateVariant({ variables }),
),
...getVariantChannels(product.variants, data.variants).map(variables =>
updateVariantChannels({
variables,
}),
),
];
if (data.variants.added.length > 0) {
mutations.push(
createVariants({
variables: {
id: product.id,
inputs: data.variants.added.map(index => ({
...getVariantInput(data.variants, index),
channelListings: getVariantChannelsInputs(data.variants, index),
stocks: getStockInputs(data.variants, index).stocks,
})),
},
}),
);
}
const variantMutationResults = await Promise.all<FetchResult>(mutations);
const variantErrors = getProductVariantListErrors(
productChannelsUpdateResult,
variantMutationResults,
);
errors = [...errors, ...variantErrors];
setVariantListErrors(variantErrors);
return errors;
};
const submit = async (data: ProductUpdateSubmitData) => {
setCalled(true);
setLoading(true);
const errors = await createMetadataUpdateHandler(
product,
sendMutations,
variables => updateMetadata({ variables }),
variables => updatePrivateMetadata({ variables }),
)(data);
setLoading(false);
if (errors.length === 0) {
notify({
status: "success",
text: intl.formatMessage(commonMessages.savedChanges),
});
}
return errors;
};
const errors = updateProductOpts.data?.productUpdate.errors ?? [];
const channelsErrors =
updateChannelsOpts?.data?.productChannelListingUpdate?.errors ?? [];
return [
submit,
{
called,
loading,
channelsErrors,
errors,
variantListErrors,
},
];
}

View file

@ -1,201 +1,137 @@
import { FetchResult } from "@apollo/client";
import { getAttributesAfterFileAttributesUpdate } from "@saleor/attributes/utils/data";
import { prepareAttributesInput } from "@saleor/attributes/utils/handlers";
import { VALUES_PAGINATE_BY } from "@saleor/config";
import {
ChannelData,
createSortedChannelsDataFromProduct,
} from "@saleor/channels/utils";
import {
FileUploadMutation,
ProductChannelListingAddInput,
ProductDetailsVariantFragment,
ProductChannelListingUpdateMutationVariables,
ProductFragment,
ProductUpdateMutationVariables,
SimpleProductUpdateMutation,
} from "@saleor/graphql";
import { weight } from "@saleor/misc";
import { getById } from "@saleor/orders/components/OrderReturnPage/utils";
import { ProductUpdatePageSubmitData } from "@saleor/products/components/ProductUpdatePage";
import { ProductUpdateSubmitData } from "@saleor/products/components/ProductUpdatePage/form";
import { mapFormsetStockToStockInput } from "@saleor/products/utils/data";
import { getAvailabilityVariables } from "@saleor/products/utils/handlers";
import { arrayDiff } from "@saleor/utils/arrays";
import isEqual from "lodash/isEqual";
import { ProductUpdateSubmitData } from "@saleor/products/components/ProductUpdatePage/types";
import { getColumnChannelAvailability } from "@saleor/products/components/ProductVariants/datagrid/columnData";
import { getAttributeInputFromProduct } from "@saleor/products/utils/data";
import { getParsedDataForJsonStringField } from "@saleor/utils/richText/misc";
import pick from "lodash/pick";
import uniq from "lodash/uniq";
import { ChannelsWithVariantsData, ChannelWithVariantData } from "../types";
import { getParsedChannelsWithVariantsDataFromChannels } from "../utils";
export const getSimpleProductVariables = (
productVariables: ProductUpdateMutationVariables,
data: ProductUpdatePageSubmitData,
productId: string,
) => ({
...productVariables,
addStocks: data.addStocks.map(mapFormsetStockToStockInput),
deleteStocks: data.removeStocks,
input: {
...productVariables.input,
weight: weight(data.weight),
},
productVariantId: productId,
productVariantInput: {
sku: data.sku,
trackInventory: data.trackInventory,
preorder: data.isPreorder
? {
globalThreshold: data.globalThreshold
? parseInt(data.globalThreshold, 10)
: null,
endDate: data.preorderEndDateTime,
}
: undefined,
},
updateStocks: data.updateStocks.map(mapFormsetStockToStockInput),
});
export const getSimpleProductErrors = (data: SimpleProductUpdateMutation) => [
...data.productUpdate.errors,
...data.productVariantStocksCreate.errors,
...data.productVariantStocksDelete.errors,
...data.productVariantStocksUpdate.errors,
];
export const getChannelListingBaseInputData = ({
id: channelId,
isPublished,
publicationDate,
isAvailableForPurchase,
availableForPurchase,
visibleInListings,
}: ChannelData) => ({
channelId,
isPublished,
publicationDate,
visibleInListings,
isAvailableForPurchase,
availableForPurchaseDate: availableForPurchase,
});
export const getChannelListingUpdateInputFromData = (
{ variantsIdsToAdd, variantsIdsToRemove }: ChannelWithVariantData,
{ selectedVariantsIds: initialSelectedVariantsIds }: ChannelWithVariantData,
basicChannelData: ChannelData,
) => ({
...getChannelListingBaseInputData(basicChannelData),
addVariants: arrayDiff(initialSelectedVariantsIds, variantsIdsToAdd).added,
removeVariants: variantsIdsToRemove,
});
const getParsedChannelsData = (
channelsWithVariants: ChannelsWithVariantsData,
initialChannelWithVariants: ChannelsWithVariantsData,
channelsData: ChannelData[],
): ProductChannelListingAddInput[] =>
channelsData.map(({ id, ...rest }) =>
getChannelListingUpdateInputFromData(
channelsWithVariants[id],
initialChannelWithVariants[id],
{ id, ...rest },
),
);
const shouldRemoveChannel = (allVariants: ProductDetailsVariantFragment[]) => ({
removeVariants,
}: ProductChannelListingAddInput) =>
isRemovingAllVariants(allVariants, removeVariants);
const isRemovingAllVariants = (
allVariants: ProductDetailsVariantFragment[],
removeVariants: string[],
) => !!removeVariants.length && removeVariants.length === allVariants.length;
const shouldUpdateChannel = (
initialChannelWithVariantData,
allVariants: ProductDetailsVariantFragment[],
allChannels: ChannelData[],
) => ({
removeVariants,
addVariants,
channelId,
...rest
}: ProductChannelListingAddInput) => {
const initialDataInput = getChannelListingUpdateInputFromData(
initialChannelWithVariantData[channelId],
initialChannelWithVariantData[channelId],
allChannels.find(getById(channelId)),
);
const hasDataChanged = !isEqual(
{ removeVariants, addVariants, channelId, ...rest },
initialDataInput,
);
const isRemovingChannel = isRemovingAllVariants(allVariants, removeVariants);
return hasDataChanged && !isRemovingChannel;
};
export const getChannelsVariables = (
{ id, variants }: ProductFragment,
allChannels: ChannelData[],
{ channelsWithVariants, channelsData }: ProductUpdateSubmitData,
) => {
const initialChannelWithVariants = getParsedChannelsWithVariantsDataFromChannels(
channelsData,
);
const channelsToBeUpdated = getParsedChannelsData(
channelsWithVariants,
initialChannelWithVariants,
channelsData,
).filter(
shouldUpdateChannel(initialChannelWithVariants, variants, allChannels),
);
const channelsIdsToBeRemoved = getParsedChannelsData(
channelsWithVariants,
initialChannelWithVariants,
channelsData,
)
.filter(shouldRemoveChannel(variants))
.map(({ channelId }) => channelId);
return {
variables: {
id,
input: {
updateChannels: channelsToBeUpdated,
removeChannels: channelsIdsToBeRemoved,
},
},
};
};
export const getSimpleChannelsVariables = (
data: ProductUpdatePageSubmitData,
export function getProductUpdateVariables(
product: ProductFragment,
) => {
const productChannels = createSortedChannelsDataFromProduct(product);
const existingChannelIDs = productChannels.map(channel => channel.id);
const modifiedChannelIDs = data.channelListings.map(channel => channel.id);
const removedChannelIDs = existingChannelIDs.filter(
x => !modifiedChannelIDs.includes(x),
data: ProductUpdateSubmitData,
uploadFilesResult: Array<FetchResult<FileUploadMutation>>,
) {
const updatedFileAttributes = getAttributesAfterFileAttributesUpdate(
data.attributesWithNewFileValue,
uploadFilesResult,
);
return {
variables: {
id: product.id,
input: {
updateChannels: getAvailabilityVariables(data.channelListings),
removeChannels: removedChannelIDs,
attributes: prepareAttributesInput({
attributes: data.attributes,
prevAttributes: getAttributeInputFromProduct(product),
updatedFileAttributes,
}),
category: data.category,
chargeTaxes: data.chargeTaxes,
collections: data.collections,
description: getParsedDataForJsonStringField(data.description),
name: data.name,
rating: data.rating,
seo: {
description: data.seoDescription,
title: data.seoTitle,
},
slug: data.slug,
taxCode: data.changeTaxCode ? data.taxCode : null,
},
firstValues: VALUES_PAGINATE_BY,
};
}
const hasChannel = (
channelId: string,
variant?: ProductFragment["variants"][number],
) => {
if (!variant) {
return false;
}
return variant.channelListings.some(c => c.channel.id === channelId);
};
export const getVariantChannelsInput = ({
channelListings,
}: ProductUpdatePageSubmitData) =>
channelListings.map(listing => ({
channelId: listing.id,
costPrice: listing.costPrice || null,
price: listing.price,
preorderThreshold: listing.preorderThreshold,
export function getProductChannelsUpdateVariables(
product: ProductFragment,
data: ProductUpdateSubmitData,
): ProductChannelListingUpdateMutationVariables {
const channels = uniq([
...product.channelListings.map(listing => listing.channel.id),
...data.channels.updateChannels.map(listing => listing.channelId),
]);
const dataUpdated = new Map<string, ProductChannelListingAddInput>();
data.channels.updateChannels
.map(listing =>
pick(
listing,
// Filtering it here so we send only fields defined in input schema
[
"availableForPurchaseAt",
"availableForPurchaseDate",
"channelId",
"isAvailableForPurchase",
"isPublished",
"publicationDate",
"publishedAt",
"visibleInListings",
] as Array<keyof ProductChannelListingAddInput>,
),
)
.forEach(listing => dataUpdated.set(listing.channelId, listing));
const variantsUpdates = new Map<string, ProductChannelListingAddInput>();
channels
.map(channelId => ({
channelId,
addVariants: data.variants.updates
.filter(
change =>
!data.variants.added.includes(change.row) &&
!hasChannel(channelId, product.variants[change.row]) &&
channelId === getColumnChannelAvailability(change.column) &&
change.data,
)
.map(change => product.variants[change.row].id),
removeVariants: data.variants.updates
.filter(
change =>
product.variants[change.row] &&
channelId === getColumnChannelAvailability(change.column) &&
!change.data,
)
.map(change => product.variants[change.row].id),
}))
.filter(
listing =>
listing.addVariants.length > 0 || listing.removeVariants.length > 0,
)
.forEach(listing => variantsUpdates.set(listing.channelId, listing));
const updateChannels = channels
.filter(
channelId => dataUpdated.has(channelId) || variantsUpdates.has(channelId),
)
.map(channelId => ({
...dataUpdated.get(channelId),
...variantsUpdates.get(channelId),
}));
return {
id: product.id,
input: {
...data.channels,
updateChannels,
},
};
}

View file

@ -1,19 +0,0 @@
export interface ChannelWithVariantData {
selectedVariantsIds: string[];
variantsIdsToRemove: string[];
variantsIdsToAdd: string[];
}
export type ChannelVariantListing = Record<string, string[]>;
export type ChannelsWithVariantsData = Record<string, ChannelWithVariantData>;
export interface UseChannelsWithProductVariants {
addVariantToChannel: (channelId: string, variantId: string) => void;
removeVariantFromChannel: (channelId: string, variantId: string) => void;
channelsWithVariantsData: ChannelsWithVariantsData;
toggleAllChannels: () => void;
toggleAllChannelVariants: (channelId: string) => void;
channelVariantListing: ChannelVariantListing;
setChannelVariantListing: (listings: ChannelVariantListing) => void;
reset: () => void;
}

View file

@ -1,54 +0,0 @@
import { ChannelData } from "@saleor/channels/utils";
import useStateFromProps from "@saleor/hooks/useStateFromProps";
import { arrayDiff } from "@saleor/utils/arrays";
import { useMemo } from "react";
import { ChannelsWithVariantsData } from "./types";
import { createFromChannels, createUpdatedChannels } from "./utils";
function useChannelVariantListings(channels: ChannelData[]) {
const initialChannelVariantListing = useMemo(
() => createFromChannels(channels, ({ variantsIds }) => variantsIds),
[channels],
);
const [
updatedChannelVariantListing,
setUpdatedChannelVariantListing,
] = useStateFromProps(initialChannelVariantListing);
const channelsWithVariantsData = useMemo<ChannelsWithVariantsData>(
() =>
createFromChannels(channels, channel => {
const diff = arrayDiff(
initialChannelVariantListing[channel.id],
updatedChannelVariantListing[channel.id],
);
return {
selectedVariantsIds: updatedChannelVariantListing[channel.id],
variantsIdsToAdd: diff.added,
variantsIdsToRemove: diff.removed,
};
}),
[updatedChannelVariantListing],
);
const reset = () =>
setUpdatedChannelVariantListing(initialChannelVariantListing);
const updatedChannels: ChannelData[] = useMemo(
() => createUpdatedChannels(channels, updatedChannelVariantListing),
[channels, updatedChannelVariantListing],
);
return {
channels: updatedChannels,
channelsWithVariantsData,
channelVariantListing: updatedChannelVariantListing,
setChannelVariantListing: setUpdatedChannelVariantListing,
reset,
};
}
export default useChannelVariantListings;

View file

@ -1,178 +0,0 @@
import { ChannelData } from "@saleor/channels/utils";
import { act, renderHook } from "@testing-library/react-hooks";
import useChannelsWithProductVariants from "./useChannelsWithProductVariants";
const channels: ChannelData[] = [
{
id: "channel1",
name: "Channel 1",
variantsIds: ["variant1", "variant2"],
},
{
id: "channel2",
name: "Channel 2",
variantsIds: [],
},
];
const variants = ["variant1", "variant2", "variant3", "variant4", "variant5"];
const setupHook = () =>
renderHook(() => useChannelsWithProductVariants(channels, variants));
describe("useChannelsWithProductVariants", () => {
it("properly initializes state", () => {
const { result } = setupHook();
expect(
result.current.channelsWithVariantsData.channel1.selectedVariantsIds,
).toHaveLength(2);
expect(
result.current.channelsWithVariantsData.channel1.variantsIdsToAdd,
).toHaveLength(0);
expect(
result.current.channelsWithVariantsData.channel1.variantsIdsToRemove,
).toHaveLength(0);
expect(
result.current.channelsWithVariantsData.channel2.selectedVariantsIds,
).toHaveLength(0);
expect(
result.current.channelsWithVariantsData.channel2.variantsIdsToAdd,
).toHaveLength(0);
expect(
result.current.channelsWithVariantsData.channel2.variantsIdsToRemove,
).toHaveLength(0);
});
it("properly adds variants", () => {
const { result } = setupHook();
act(() => result.current.addVariantToChannel("channel1", "variant3"));
expect(
result.current.channelsWithVariantsData.channel1.selectedVariantsIds,
).toHaveLength(3);
expect(
result.current.channelsWithVariantsData.channel1.variantsIdsToAdd,
).toHaveLength(1);
expect(
result.current.channelsWithVariantsData.channel1.variantsIdsToRemove,
).toHaveLength(0);
expect(
result.current.channelsWithVariantsData.channel2.selectedVariantsIds,
).toHaveLength(0);
expect(
result.current.channelsWithVariantsData.channel2.variantsIdsToAdd,
).toHaveLength(0);
expect(
result.current.channelsWithVariantsData.channel2.variantsIdsToRemove,
).toHaveLength(0);
});
it("properly removes variants", () => {
const { result } = setupHook();
act(() => result.current.removeVariantFromChannel("channel1", "variant2"));
expect(
result.current.channelsWithVariantsData.channel1.selectedVariantsIds,
).toHaveLength(1);
expect(
result.current.channelsWithVariantsData.channel1.variantsIdsToAdd,
).toHaveLength(0);
expect(
result.current.channelsWithVariantsData.channel1.variantsIdsToRemove,
).toHaveLength(1);
expect(
result.current.channelsWithVariantsData.channel2.selectedVariantsIds,
).toHaveLength(0);
expect(
result.current.channelsWithVariantsData.channel2.variantsIdsToAdd,
).toHaveLength(0);
expect(
result.current.channelsWithVariantsData.channel2.variantsIdsToRemove,
).toHaveLength(0);
});
it("properly toggles all variants in channel", () => {
const { result } = setupHook();
// Deselect all because it's partially selected
act(() => result.current.toggleAllChannelVariants("channel1"));
expect(
result.current.channelsWithVariantsData.channel1.selectedVariantsIds,
).toHaveLength(0);
expect(
result.current.channelsWithVariantsData.channel1.variantsIdsToAdd,
).toHaveLength(0);
expect(
result.current.channelsWithVariantsData.channel1.variantsIdsToRemove,
).toHaveLength(2);
// Select all
act(() => result.current.toggleAllChannelVariants("channel1"));
expect(
result.current.channelsWithVariantsData.channel1.selectedVariantsIds,
).toHaveLength(5);
expect(
result.current.channelsWithVariantsData.channel1.variantsIdsToAdd,
).toHaveLength(3);
expect(
result.current.channelsWithVariantsData.channel1.variantsIdsToRemove,
).toHaveLength(0);
});
it("properly toggles all", () => {
const { result } = setupHook();
// Select all
act(result.current.toggleAllChannels);
expect(
result.current.channelsWithVariantsData.channel1.selectedVariantsIds,
).toHaveLength(5);
expect(
result.current.channelsWithVariantsData.channel1.variantsIdsToAdd,
).toHaveLength(3);
expect(
result.current.channelsWithVariantsData.channel1.variantsIdsToRemove,
).toHaveLength(0);
expect(
result.current.channelsWithVariantsData.channel2.selectedVariantsIds,
).toHaveLength(5);
expect(
result.current.channelsWithVariantsData.channel2.variantsIdsToAdd,
).toHaveLength(5);
expect(
result.current.channelsWithVariantsData.channel2.variantsIdsToRemove,
).toHaveLength(0);
// Deselect all
act(result.current.toggleAllChannels);
expect(
result.current.channelsWithVariantsData.channel1.selectedVariantsIds,
).toHaveLength(0);
expect(
result.current.channelsWithVariantsData.channel1.variantsIdsToAdd,
).toHaveLength(0);
expect(
result.current.channelsWithVariantsData.channel1.variantsIdsToRemove,
).toHaveLength(2);
expect(
result.current.channelsWithVariantsData.channel2.selectedVariantsIds,
).toHaveLength(0);
expect(
result.current.channelsWithVariantsData.channel2.variantsIdsToAdd,
).toHaveLength(0);
expect(
result.current.channelsWithVariantsData.channel2.variantsIdsToRemove,
).toHaveLength(0);
});
});

View file

@ -1,62 +0,0 @@
import { ChannelData } from "@saleor/channels/utils";
import uniq from "lodash/uniq";
import { UseChannelsWithProductVariants } from "./types";
import useChannelVariantListings from "./useChannelVariantListings";
import {
addAllVariantsToAllChannels,
getChannelVariantToggleData,
} from "./utils";
const useChannelsWithProductVariants = (
channels: ChannelData[],
variants: string[],
): UseChannelsWithProductVariants => {
const {
channelsWithVariantsData,
setChannelVariantListing,
channelVariantListing,
reset,
} = useChannelVariantListings(channels);
const handleAddVariant = (channelId: string, variantId: string) =>
setChannelVariantListing(listings => ({
...listings,
[channelId]: uniq([...listings[channelId], variantId]),
}));
const handleRemoveVariant = (channelId: string, variantId: string) =>
setChannelVariantListing(listings => ({
...listings,
[channelId]: listings[channelId].filter(
selectedVariantId => selectedVariantId !== variantId,
),
}));
const toggleAllChannelVariants = (channelId: string) => {
const isChannelSelected = channelVariantListing[channelId].length > 0;
setChannelVariantListing({
...channelVariantListing,
[channelId]: getChannelVariantToggleData(variants, isChannelSelected),
});
};
const toggleAllChannels = () =>
setChannelVariantListing(listings =>
addAllVariantsToAllChannels(listings, variants),
);
return {
channelsWithVariantsData,
channelVariantListing,
addVariantToChannel: handleAddVariant,
removeVariantFromChannel: handleRemoveVariant,
toggleAllChannelVariants,
toggleAllChannels,
setChannelVariantListing,
reset,
};
};
export default useChannelsWithProductVariants;

View file

@ -1,122 +0,0 @@
import { ChannelData } from "@saleor/channels/utils";
import every from "lodash/every";
import reduce from "lodash/reduce";
import { initialChannelWithVariantData } from "./consts";
import {
ChannelsWithVariantsData,
ChannelVariantListing,
ChannelWithVariantData,
} from "./types";
export function createFromChannels<T>(
channels: ChannelData[],
cb: (channel: ChannelData) => T,
): Record<string, T> {
return channels?.reduce(
(result: Record<string, T>, channel) => ({
...result,
[channel.id]: cb(channel),
}),
{},
);
}
export function createUpdatedChannels(
channels: ChannelData[],
listing: ChannelVariantListing,
): ChannelData[] {
return reduce(
listing,
(acc, variantsIds, channelId) => [
...acc,
{
id: channelId,
name: channels.find(channel => channel.id === channelId).name,
variantsIds,
} as ChannelData,
],
[],
);
}
export const getParsedChannelsWithVariantsDataFromChannels = (
channels: ChannelData[],
): ChannelsWithVariantsData =>
createFromChannels(
channels,
({ variantsIds }) =>
({
...initialChannelWithVariantData,
selectedVariantsIds: variantsIds,
} as ChannelWithVariantData),
);
export const getChannelVariantToggleData = (
variants: string[],
isSelected: boolean,
): string[] => (isSelected ? [] : variants);
export const areAllVariantsAtAllChannelsSelected = (
variants: string[] = [],
channelsWithVariants: ChannelVariantListing = {},
) =>
every(channelsWithVariants, channelWithVariantsData =>
areAllChannelVariantsSelected(variants, channelWithVariantsData),
);
export const areAllChannelVariantsSelected = (
variants: string[] = [],
selectedVariants: string[],
) => selectedVariants.length === variants.length;
export const areAnyChannelVariantsSelected = (
channelsWithVariantsData: ChannelWithVariantData,
) => channelsWithVariantsData?.selectedVariantsIds.length > 0;
export const getTotalSelectedChannelsCount = (
channelsWithVariantsData: ChannelsWithVariantsData,
) =>
Object.values(channelsWithVariantsData).filter(
channel => channel.selectedVariantsIds.length > 0,
).length;
export const addAllVariantsToAllChannels = (
listings: ChannelVariantListing,
variants: string[],
): ChannelVariantListing => {
const areAllChannelsSelected = areAllVariantsAtAllChannelsSelected(
variants,
listings,
);
const updatedListing = reduce(
listings,
(result: ChannelVariantListing, _, channelId) => ({
...result,
[channelId]: getChannelVariantToggleData(
variants,
areAllChannelsSelected,
),
}),
{},
);
return updatedListing;
};
export const channelVariantListingDiffToDict = (
listing: ChannelsWithVariantsData,
): ChannelVariantListing =>
reduce(
listing,
(
listingDict: ChannelVariantListing,
{ selectedVariantsIds },
channelId,
) => ({
...listingDict,
[channelId]: selectedVariantsIds,
}),
{},
);

View file

@ -167,7 +167,7 @@ export const ProductVariant: React.FC<ProductUpdateProps> = ({
const handleVariantReorder = createVariantReorderHandler(
variant?.product,
variables => reorderProductVariants({ variables }),
reorderProductVariants,
);
const disableFormSave =

View file

@ -96,8 +96,9 @@ export const ProductVariant: React.FC<ProductVariantCreateProps> = ({
reorderProductVariantsOpts,
] = useProductVariantReorderMutation({});
const handleVariantReorder = createVariantReorderHandler(product, variables =>
reorderProductVariants({ variables }),
const handleVariantReorder = createVariantReorderHandler(
product,
reorderProductVariants,
);
const handleCreate = async (formData: ProductVariantCreateData) => {

View file

@ -1,10 +1,11 @@
import { gql } from "@apollo/client";
import { gql, useApolloClient } from "@apollo/client";
import {
SearchAttributeValuesDocument,
SearchAttributeValuesQuery,
SearchAttributeValuesQueryVariables,
} from "@saleor/graphql";
import makeSearch from "@saleor/hooks/makeSearch";
import { mapEdgesToItems } from "@saleor/utils/maps";
export const searchAttributeValues = gql`
query SearchAttributeValues(
@ -29,6 +30,27 @@ export const searchAttributeValues = gql`
}
`;
export function useSearchAttributeValuesSuggestions() {
const client = useApolloClient();
return (id: string, query: string) =>
client
.query<SearchAttributeValuesQuery, SearchAttributeValuesQueryVariables>({
query: SearchAttributeValuesDocument,
variables: {
id,
first: 10,
query,
},
})
.then(({ data }) =>
mapEdgesToItems(data.attribute.choices).map(({ name, slug }) => ({
label: name,
value: slug,
})),
);
}
export default makeSearch<
SearchAttributeValuesQuery,
SearchAttributeValuesQueryVariables

View file

@ -4,6 +4,7 @@ import initStoryshots from "@storybook/addon-storyshots";
import { configure, render } from "enzyme";
import Adapter from "enzyme-adapter-react-16";
import toJSON from "enzyme-to-json";
import { mockRandomForEach } from "jest-mock-random";
import React from "react";
// Fixes useLayoutEffect warnings
@ -19,6 +20,10 @@ jest.mock("@material-ui/styles/createGenerateClassName");
initStoryshots({
configPath: "src/storybook/",
test({ story }) {
mockRandomForEach(
Array.from(Array(25).keys()).map(x => parseFloat(`0.${x + 1}`)),
);
const result = render(story.render() as any);
expect(toJSON(result)).toMatchSnapshot();
},

File diff suppressed because it is too large Load diff

View file

@ -1,19 +1,13 @@
import placeholderImage from "@assets/images/placeholder255x255.png";
import { channelsList } from "@saleor/channels/fixtures";
import { createChannelsData } from "@saleor/channels/utils";
import { collections } from "@saleor/collections/fixtures";
import {
fetchMoreProps,
limits,
limitsReached,
listActionsProps,
} from "@saleor/fixtures";
import { fetchMoreProps, limits, limitsReached } from "@saleor/fixtures";
import { ProductErrorCode } from "@saleor/graphql";
import ProductUpdatePage, {
ProductUpdatePageProps,
} from "@saleor/products/components/ProductUpdatePage";
import { ProductUpdateFormData } from "@saleor/products/components/ProductUpdatePage/types";
import { product as productFixture } from "@saleor/products/fixtures";
import { ProductUpdatePageFormData } from "@saleor/products/utils/data";
import { warehouseList } from "@saleor/warehouses/fixtures";
import { storiesOf } from "@storybook/react";
import React from "react";
@ -22,28 +16,23 @@ import Decorator from "../../Decorator";
import { taxTypes } from "../taxes/fixtures";
const product = productFixture(placeholderImage);
const channels = createChannelsData(channelsList);
const props: ProductUpdatePageProps = {
...listActionsProps,
channels: channelsList,
variantListErrors: [
{
__typename: "DatagridError",
variantId: product.variants[0].id,
type: "channel",
channelIds: [channelsList[1].id],
error: ProductErrorCode.ALREADY_EXISTS,
},
],
productId: "123",
allChannelsCount: 5,
onChannelsChange: () => undefined,
currentChannels: [],
isSimpleProduct: false,
categories: [product.category],
channelsWithVariantsData: {
channel1: {
selectedVariantsIds: ["variantA"],
variantsIdsToRemove: ["variantB"],
variantsIdsToAdd: [],
},
},
setChannelsData: () => undefined,
channelsData: channels,
channelsErrors: [],
collections,
defaultWeightUnit: "kg",
disabled: false,
errors: [],
fetchCategories: () => undefined,
@ -56,24 +45,21 @@ const props: ProductUpdatePageProps = {
header: product.name,
media: product.media,
limits,
onAttributeValuesSearch: () => Promise.resolve([]),
onAssignReferencesClick: () => undefined,
onCloseDialog: () => undefined,
onDelete: () => undefined,
onImageDelete: () => undefined,
onImageUpload: () => undefined,
onMediaUrlUpload: () => undefined,
onSetDefaultVariant: () => undefined,
onSubmit: () => undefined,
onVariantReorder: () => undefined,
onVariantEndPreorderDialogOpen: () => undefined,
onWarehouseConfigure: () => undefined,
openChannelsModal: () => undefined,
onVariantShow: () => undefined,
refetch: () => undefined,
placeholderImage,
product,
referencePages: [],
referenceProducts: [],
saveButtonBarState: "default",
selectedChannelId: "123",
taxTypes,
variants: product.variants,
warehouses: warehouseList,
@ -180,23 +166,16 @@ storiesOf("Views / Products / Product edit", module)
"seoTitle",
"sku",
"stockQuantity",
] as Array<keyof ProductUpdatePageFormData | "attributes">).map(
field => ({
] as Array<keyof ProductUpdateFormData | "attributes">).map(field => ({
__typename: "ProductError",
attributes:
field === "attributes"
? [product.attributes[0].attribute.id]
: null,
field === "attributes" ? [product.attributes[0].attribute.id] : null,
code: ProductErrorCode.INVALID,
field,
message: "Attributes invalid",
}),
)}
}))}
/>
))
.add("with channels", () => (
<ProductUpdatePage {...props} currentChannels={channels} />
))
.add("no limits", () => <ProductUpdatePage {...props} limits={undefined} />)
.add("limits reached", () => (
<ProductUpdatePage {...props} limits={limitsReached} />

View file

@ -1,4 +1,4 @@
type List<TData> = TData[];
type List<TData> = readonly TData[];
type Compare<TData> = (a: TData, b: TData) => boolean;
export function isSelected<TData>(

View file

@ -24,17 +24,17 @@ if (!!analyze) {
}
const pathsPlugin = new TsconfigPathsPlugin({
configFile: "./tsconfig.json"
configFile: "./tsconfig.json",
});
const checkerPlugin = new CheckerPlugin({
eslint: true,
reportFiles: ["src/**/*.{ts,tsx}"]
reportFiles: ["src/**/*.{ts,tsx}"],
});
const htmlWebpackPlugin = new HtmlWebpackPlugin({
filename: "index.html",
hash: true,
template: "./src/index.html"
template: "./src/index.html",
});
const environmentPlugin = new webpack.EnvironmentPlugin({
API_URI: "",
@ -45,7 +45,7 @@ const environmentPlugin = new webpack.EnvironmentPlugin({
GTM_ID: "",
SENTRY_DSN: "",
SW_INTERVAL: "300", // Fetch SW every 300 seconds
IS_CLOUD_INSTANCE: false
IS_CLOUD_INSTANCE: false,
});
const dashboardBuildPath = "build/dashboard/";
@ -66,7 +66,7 @@ module.exports = speedMeasureWrapper((env, argv) => {
chunkFilename: "[name].[chunkhash].js",
filename: "[name].[chunkhash].js",
path: resolve(dashboardBuildPath),
publicPath
publicPath,
};
fileLoaderPath = "file-loader?name=[name].[hash].[ext]";
} else {
@ -74,7 +74,7 @@ module.exports = speedMeasureWrapper((env, argv) => {
chunkFilename: "[name].js",
filename: "[name].js",
path: resolve(dashboardBuildPath),
publicPath
publicPath,
};
fileLoaderPath = "file-loader?name=[name].[ext]";
}
@ -90,7 +90,7 @@ module.exports = speedMeasureWrapper((env, argv) => {
) {
sentryPlugin = new SentryWebpackPlugin({
include: "./build/dashboard/",
urlPrefix: process.env.SENTRY_URL_PREFIX
urlPrefix: process.env.SENTRY_URL_PREFIX,
});
}
@ -100,7 +100,7 @@ module.exports = speedMeasureWrapper((env, argv) => {
swSrc: "./src/sw.js",
swDest: "sw.js",
maximumFileSizeToCacheInBytes: 5000000,
webpackCompilationPlugins: [checkerPlugin]
webpackCompilationPlugins: [checkerPlugin],
});
}
@ -110,11 +110,11 @@ module.exports = speedMeasureWrapper((env, argv) => {
contentBase: path.join(__dirname, dashboardBuildPath),
historyApiFallback: true,
hot: true,
port: 9000
port: 9000,
},
devtool: devMode ? "cheap-module-source-map" : "source-map",
entry: {
dashboard: "./src/index.tsx"
dashboard: "./src/index.tsx",
},
module: {
rules: [
@ -125,27 +125,31 @@ module.exports = speedMeasureWrapper((env, argv) => {
loader: "esbuild-loader",
options: {
loader: "tsx",
target: "es2015"
}
}
]
target: "es2015",
},
},
],
},
{
test: /\.css$/i,
use: ["style-loader", "css-loader"],
},
{
include: [
resolve("node_modules"),
resolve("assets/fonts"),
resolve("assets/images"),
resolve("assets/favicons")
resolve("assets/favicons"),
],
loader: fileLoaderPath,
test: /\.(eot|otf|png|svg|jpg|ttf|woff|woff2)(\?v=[0-9.]+)?$/
}
]
test: /\.(eot|otf|png|svg|jpg|ttf|woff|woff2)(\?v=[0-9.]+)?$/,
},
],
},
optimization: {
removeAvailableModules: false,
removeEmptyChunks: false,
splitChunks: false
splitChunks: false,
},
output,
plugins: [
@ -154,7 +158,7 @@ module.exports = speedMeasureWrapper((env, argv) => {
htmlWebpackPlugin,
sentryPlugin,
manifestPlugin,
bundleAnalyzerPlugin
bundleAnalyzerPlugin,
].filter(Boolean),
resolve: {
// Resolve macaw ui's peer dependencies to our own node_modules
@ -165,11 +169,11 @@ module.exports = speedMeasureWrapper((env, argv) => {
"@material-ui/core": path.resolve("./node_modules/@material-ui/core"),
"@material-ui/icons": path.resolve("./node_modules/@material-ui/icons"),
"@material-ui/styles": path.resolve(
"./node_modules/@material-ui/styles"
)
"./node_modules/@material-ui/styles",
),
},
extensions: [".js", ".jsx", ".ts", ".tsx"],
plugins: [pathsPlugin]
}
plugins: [pathsPlugin],
},
};
});