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:
parent
b9c3f720ff
commit
31e0c11fea
89 changed files with 14462 additions and 10239 deletions
|
@ -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}`);
|
||||
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);
|
||||
|
||||
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,
|
||||
});
|
||||
getProductVariants(createdProduct.id, defaultChannel.slug);
|
||||
})
|
||||
.then(([firstVariant, secondVariant]) => {
|
||||
expect(firstVariant).to.have.property("price", variants[0].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");
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
|
@ -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']",
|
||||
};
|
||||
|
|
|
@ -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']",
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
}));
|
||||
});
|
||||
};
|
||||
|
|
|
@ -19,7 +19,6 @@ export function createVariant({
|
|||
costPrice = price,
|
||||
quantity = 10,
|
||||
}) {
|
||||
cy.get(PRODUCT_DETAILS.addVariantButton).click();
|
||||
fillUpVariantDetails({
|
||||
attributeName,
|
||||
sku,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
"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,
|
||||
|
|
|
@ -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
134
package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
122
schema.graphql
122
schema.graphql
|
@ -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!]
|
||||
}
|
||||
|
||||
"""
|
||||
|
|
|
@ -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;
|
|
@ -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} />);
|
|
@ -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;
|
|
@ -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} />);
|
|
@ -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;
|
|
@ -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;
|
|
@ -1,2 +0,0 @@
|
|||
export * from "./ChannelsWithVariantsAvailabilityDialog";
|
||||
export { default } from "./ChannelsWithVariantsAvailabilityDialog";
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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)}
|
||||
>
|
||||
|
|
151
src/components/Datagrid/Datagrid.stories.tsx
Normal file
151
src/components/Datagrid/Datagrid.stories.tsx
Normal 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 />);
|
329
src/components/Datagrid/Datagrid.tsx
Normal file
329
src/components/Datagrid/Datagrid.tsx
Normal 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;
|
128
src/components/Datagrid/DropdownCell.tsx
Normal file
128
src/components/Datagrid/DropdownCell.tsx
Normal 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,
|
||||
}),
|
||||
};
|
117
src/components/Datagrid/MoneyCell.tsx
Normal file
117
src/components/Datagrid/MoneyCell.tsx
Normal 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),
|
||||
}),
|
||||
});
|
74
src/components/Datagrid/NumberCell.tsx
Normal file
74
src/components/Datagrid/NumberCell.tsx
Normal 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,
|
||||
}),
|
||||
});
|
82
src/components/Datagrid/cells.ts
Normal file
82
src/components/Datagrid/cells.ts
Normal 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,
|
||||
};
|
||||
}
|
74
src/components/Datagrid/datagrid.md
Normal file
74
src/components/Datagrid/datagrid.md
Normal 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.
|
172
src/components/Datagrid/fixtures.ts
Normal file
172
src/components/Datagrid/fixtures.ts
Normal 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",
|
||||
},
|
||||
},
|
||||
];
|
211
src/components/Datagrid/styles.ts
Normal file
211
src/components/Datagrid/styles.ts
Normal 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;
|
6
src/components/Datagrid/types.ts
Normal file
6
src/components/Datagrid/types.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export interface AvailableColumn {
|
||||
id: string;
|
||||
title: string;
|
||||
width: number;
|
||||
group?: string;
|
||||
}
|
23
src/components/Datagrid/useCells.ts
Normal file
23
src/components/Datagrid/useCells.ts
Normal 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;
|
85
src/components/Datagrid/useColumns.ts
Normal file
85
src/components/Datagrid/useColumns.ts
Normal 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;
|
131
src/components/Datagrid/useDatagridChange.test.tsx
Normal file
131
src/components/Datagrid/useDatagridChange.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
147
src/components/Datagrid/useDatagridChange.ts
Normal file
147
src/components/Datagrid/useDatagridChange.ts
Normal 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;
|
|
@ -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" },
|
||||
);
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
60
src/components/PriceField/usePriceField.ts
Normal file
60
src/components/PriceField/usePriceField.ts
Normal 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,
|
||||
};
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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({
|
||||
credentials: "include",
|
||||
uri: API_URI,
|
||||
fetch: createFetch(),
|
||||
});
|
||||
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({
|
||||
|
|
|
@ -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,26 +11988,12 @@ 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}`;
|
||||
${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
|
@ -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>({
|
||||
|
|
35
src/hooks/usePreventHistoryBack.ts
Normal file
35
src/hooks/usePreventHistoryBack.ts
Normal 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]);
|
||||
};
|
|
@ -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>
|
||||
|
|
|
@ -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 ? (
|
||||
|
|
|
@ -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"],
|
||||
|
|
|
@ -24,7 +24,7 @@ export enum OrderRefundAmountCalculationMode {
|
|||
}
|
||||
|
||||
export interface OrderRefundData {
|
||||
amount: number | string;
|
||||
amount: string;
|
||||
type: OrderRefundType;
|
||||
refundShipmentCosts: boolean;
|
||||
amountCalculationMode: OrderRefundAmountCalculationMode;
|
||||
|
|
|
@ -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[];
|
||||
|
|
|
@ -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;
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
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}
|
||||
warehouses={warehouses}
|
||||
onChange={handlers.changeStock}
|
||||
onFormDataChange={change}
|
||||
onChangePreorderEndDate={handlers.changePreorderEndDate}
|
||||
onWarehouseStockAdd={handlers.addStock}
|
||||
onWarehouseStockDelete={handlers.deleteStock}
|
||||
onWarehouseConfigure={onWarehouseConfigure}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<ProductVariants
|
||||
errors={variantListErrors}
|
||||
channels={listings}
|
||||
limits={limits}
|
||||
variants={variants}
|
||||
variantAttributes={product?.productType.variantAttributes}
|
||||
warehouses={warehouses}
|
||||
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}
|
||||
/>
|
||||
) : 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}
|
||||
/>
|
||||
)}
|
||||
<ChannelsAvailabilityCard
|
||||
{...availabilityCommonProps}
|
||||
channels={listings}
|
||||
/>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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<
|
||||
ProductUpdateData,
|
||||
ProductUpdateHandlers
|
||||
>,
|
||||
RichTextProps {
|
||||
formErrors: FormErrors<ProductUpdateSubmitData>;
|
||||
}
|
||||
|
||||
export type UseProductUpdateFormRenderProps = Omit<
|
||||
import { useProductChannelListingsForm } from "./formChannels";
|
||||
import {
|
||||
ProductUpdateData,
|
||||
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}>
|
||||
<RichTextContext.Provider value={richText}>
|
||||
{children(props)}
|
||||
</RichTextContext.Provider>
|
||||
<DatagridChangeStateContext.Provider value={datagrid}>
|
||||
<RichTextContext.Provider value={richText}>
|
||||
{children(props)}
|
||||
</RichTextContext.Provider>
|
||||
</DatagridChangeStateContext.Provider>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
|
86
src/products/components/ProductUpdatePage/formChannels.ts
Normal file
86
src/products/components/ProductUpdatePage/formChannels.ts
Normal 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,
|
||||
};
|
||||
}
|
153
src/products/components/ProductUpdatePage/types.ts
Normal file
153
src/products/components/ProductUpdatePage/types.ts
Normal 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;
|
||||
}
|
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
variants,
|
||||
product,
|
||||
onVariantReorder,
|
||||
onSetDefaultVariant,
|
||||
isChecked,
|
||||
selected,
|
||||
selectedChannelId,
|
||||
toggle,
|
||||
toggleAll,
|
||||
toolbar,
|
||||
} = props;
|
||||
const classes = useStyles(props);
|
||||
|
||||
export const ProductVariants: React.FC<ProductVariantsProps> = ({
|
||||
channels,
|
||||
errors,
|
||||
variants,
|
||||
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",
|
||||
})}
|
||||
toolbar={
|
||||
<Button
|
||||
disabled={limitReached}
|
||||
href={productVariantAddUrl(productId)}
|
||||
variant="tertiary"
|
||||
data-test-id="button-add-variant"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="3VyHbJ"
|
||||
defaultMessage="Create variant"
|
||||
description="button"
|
||||
/>
|
||||
</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>
|
||||
<Datagrid
|
||||
addButtonLabel={intl.formatMessage({
|
||||
defaultMessage: "Add variant",
|
||||
id: "3C3Nj5",
|
||||
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>
|
||||
)}
|
||||
|
||||
{variants.length > 0 ? (
|
||||
<CardContent className={classes.warehouseSelectContainer}>
|
||||
<Typography className={classes.warehouseLabel}>
|
||||
<FormattedMessage
|
||||
id="FSinkL"
|
||||
defaultMessage="Available inventory at:"
|
||||
description="variant stock status"
|
||||
/>
|
||||
</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>
|
||||
title={intl.formatMessage(messages.title)}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
ProductVariants.displayName = "ProductVariants";
|
||||
|
|
|
@ -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:(.*)/);
|
|
@ -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);
|
||||
}
|
46
src/products/components/ProductVariants/messages.ts
Normal file
46
src/products/components/ProductVariants/messages.ts
Normal 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;
|
370
src/products/components/ProductVariants/utils.tsx
Normal file
370
src/products/components/ProductVariants/utils.tsx
Normal 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}`);
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
{
|
||||
__typename: "ProductMedia",
|
||||
id: "pi92838",
|
||||
type: ProductMediaType.IMAGE,
|
||||
oembedData: "{}",
|
||||
url: placeholderImage,
|
||||
attribute: {
|
||||
id: "QXR0cmlidXRlOjE1",
|
||||
name: "Bottle Size",
|
||||
__typename: "Attribute",
|
||||
},
|
||||
values: [
|
||||
{
|
||||
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: {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 &
|
||||
|
|
|
@ -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, ""),
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
@ -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)}
|
||||
onSubmit={submit}
|
||||
onVariantShow={variantId =>
|
||||
navigate(productVariantEditUrl(product.id, variantId), {
|
||||
resetScroll: true,
|
||||
})
|
||||
}
|
||||
onImageUpload={handleImageUpload}
|
||||
onImageDelete={handleImageDelete}
|
||||
toolbar={
|
||||
<IconButton
|
||||
variant="secondary"
|
||||
color="primary"
|
||||
onClick={() =>
|
||||
openModal("remove-variants", {
|
||||
ids: listElements,
|
||||
})
|
||||
}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
}
|
||||
isChecked={isSelected}
|
||||
selected={listElements.length}
|
||||
toggle={toggle}
|
||||
toggleAll={toggleAll}
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
|
|
120
src/products/views/ProductUpdate/handlers/errors.ts
Normal file
120
src/products/views/ProductUpdate/handlers/errors.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
|
@ -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 = [
|
||||
...move<T["variants"][0]>(
|
||||
product.variants[oldIndex],
|
||||
product!.variants,
|
||||
areVariantsEqual,
|
||||
newIndex,
|
||||
),
|
||||
];
|
||||
|
||||
reorderProductVariants({
|
||||
move: {
|
||||
id: oldVariantOrder[oldIndex].id,
|
||||
sortOrder: newIndex - oldIndex,
|
||||
variables: {
|
||||
move: {
|
||||
id: oldVariantOrder[oldIndex].id,
|
||||
sortOrder: newIndex - oldIndex,
|
||||
},
|
||||
productId: product.id,
|
||||
},
|
||||
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,
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
}
|
|
@ -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,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -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,
|
||||
}),
|
||||
{},
|
||||
);
|
|
@ -167,7 +167,7 @@ export const ProductVariant: React.FC<ProductUpdateProps> = ({
|
|||
|
||||
const handleVariantReorder = createVariantReorderHandler(
|
||||
variant?.product,
|
||||
variables => reorderProductVariants({ variables }),
|
||||
reorderProductVariants,
|
||||
);
|
||||
|
||||
const disableFormSave =
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
@ -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 => ({
|
||||
__typename: "ProductError",
|
||||
attributes:
|
||||
field === "attributes"
|
||||
? [product.attributes[0].attribute.id]
|
||||
: null,
|
||||
code: ProductErrorCode.INVALID,
|
||||
field,
|
||||
message: "Attributes invalid",
|
||||
}),
|
||||
)}
|
||||
] as Array<keyof ProductUpdateFormData | "attributes">).map(field => ({
|
||||
__typename: "ProductError",
|
||||
attributes:
|
||||
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} />
|
||||
|
|
|
@ -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>(
|
||||
|
|
|
@ -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],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue