Bulk product export to CMS providers (#351)

* Add sync all channel products feature

* Implement batch create and delete product variants CMSes sync methods

* Fix pnpm-lock file

* Update UI

* Update imports

* Add fetch rate limit to Contentful provider

* Small refactor of functions

* Update logging
This commit is contained in:
Dawid 2023-04-12 16:10:32 +02:00 committed by GitHub
parent 9730edb971
commit bec8d812e8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 1197 additions and 266 deletions

View file

@ -81,7 +81,7 @@ const payloadOperations: CreateOperations<PayloadConfig> = (config) => {
} // This is where you write logic for all the supported operations (e.g. creating a product). This function runs only if the config was successfully validated.
export default createProvider(payloadOperations, payloadConfigSchema); // `createProvider` combines everything together.
export const payloadProvider = createProvider(payloadOperations, payloadConfigSchema); // `createProvider` combines everything together.
```
5. Implement the operations:

View file

@ -12,3 +12,4 @@ Here is the list of the tokens and instructions on how to obtain them:
- `spaceId`: id of the Contentful space. To find it, go to _Settings -> General settings_ in the Contentful dashboard.
- `contentId`: the id of the content model. To obtain it, go to _Content model_ and to the view of a single product in your Contentful dashboard. Your URL may look something like: "https://app.contentful.com/spaces/xxxx/content_types/product/fields". Then, look to the right side of the screen. You will find a copyable "CONTENT TYPE ID" box there.
- `locale`: the localization code for your content. E.g.: `en-US`.
- `apiRequestsPerSecond`: API rate limits (API requests per second). The default is 7. Used in bulk products variants sync. Higher rate limits may speed up a little products variants bulk sync. Higher rate limit may apply depending on different Contentful plan, learn more at https://www.contentful.com/developers/docs/references/content-management-api/#/introduction/api-rate-limits.

View file

@ -0,0 +1,13 @@
query ProductsDataForImport($first: Int, $channel: String, $after: String) {
products(first: $first, channel: $channel, after: $after) {
pageInfo {
hasNextPage
endCursor
}
edges {
node {
...WebhookProduct
}
}
}
}

View file

@ -21,6 +21,7 @@
"@material-ui/icons": "^4.11.3",
"@material-ui/lab": "4.0.0-alpha.61",
"@saleor/app-sdk": "0.37.1",
"@saleor/apps-shared": "workspace:*",
"@saleor/macaw-ui": "^0.6.7",
"@sentry/nextjs": "^7.43.0",
"@urql/exchange-auth": "^1.0.0",
@ -38,8 +39,7 @@
"usehooks-ts": "^2.9.1",
"uuid": "^9.0.0",
"vite": "^4.1.4",
"zod": "^3.19.1",
"@saleor/apps-shared": "workspace:*"
"zod": "^3.19.1"
},
"devDependencies": {
"@graphql-codegen/cli": "2.13.3",

View file

@ -2,7 +2,7 @@ import {
ProductVariantUpdatedWebhookPayloadFragment,
WebhookProductVariantFragment,
} from "../../../../generated/graphql";
import { CmsClientOperations } from "../types";
import { CmsClientBatchOperations, CmsClientOperations, ProductResponseSuccess } from "../types";
import { getCmsIdFromSaleorItem } from "./metadata";
import { logger as pinoLogger } from "../../logger";
@ -104,6 +104,121 @@ const executeCmsClientOperation = async ({
}
};
interface CmsClientBatchOperationResult {
createdCmsIds?: ProductResponseSuccess["data"][];
deletedCmsIds?: ProductResponseSuccess["data"][];
error?: string;
}
export const executeCmsClientBatchOperation = async ({
cmsClient,
productsVariants,
verifyIfProductVariantIsAvailableInOtherChannelEnabledForSelectedProviderInstance,
}: {
cmsClient: CmsClientBatchOperations;
productsVariants: WebhookProductVariantFragment[];
/**
* Lookup function with purposely long name like in Java Spring ORM to verify condition against unintended deletion of product variant from CMS.
* On purpose passed as an argument, for inversion of control.
*/
verifyIfProductVariantIsAvailableInOtherChannelEnabledForSelectedProviderInstance: (
productVariant: WebhookProductVariantFragment
) => boolean;
}): Promise<CmsClientBatchOperationResult | undefined> => {
const logger = pinoLogger.child({ cmsClient });
logger.debug({ operations: cmsClient.operations }, "Execute CMS client operation called");
if (cmsClient.operationType === "createBatchProducts") {
const productsVariansToCreate = productsVariants.reduce<WebhookProductVariantFragment[]>(
(productsVariansToCreate, productVariant) => {
const cmsId = getCmsIdFromSaleorItem(productVariant, cmsClient.cmsProviderInstanceId);
if (!cmsId) {
return [...productsVariansToCreate, productVariant];
}
return productsVariansToCreate;
},
[] as WebhookProductVariantFragment[]
);
if (productsVariansToCreate.length) {
logger.debug("CMS creating batch items called");
try {
const createBatchProductsResponse = await cmsClient.operations.createBatchProducts({
input: productsVariansToCreate.map((productVariant) => ({
saleorId: productVariant.id,
sku: productVariant.sku,
name: productVariant.name,
image: productVariant.product.media?.[0]?.url ?? "",
productId: productVariant.product.id,
productName: productVariant.product.name,
productSlug: productVariant.product.slug,
channels: productVariant.channelListings?.map((cl) => cl.channel.slug) || [],
})),
});
return {
createdCmsIds:
createBatchProductsResponse
?.filter((item) => item.ok && "data" in item)
.map((item) => (item as ProductResponseSuccess).data) || [],
};
} catch (error) {
logger.error({ error }, "Error creating batch items");
return {
error: "Error creating batch items.",
};
}
}
}
if (cmsClient.operationType === "deleteBatchProducts") {
const CMSIdsToRemove = productsVariants.reduce((CMSIdsToRemove, productVariant) => {
const cmsId = getCmsIdFromSaleorItem(productVariant, cmsClient.cmsProviderInstanceId);
const productVariantIsAvailableInOtherChannelEnabledForSelectedProviderInstance =
verifyIfProductVariantIsAvailableInOtherChannelEnabledForSelectedProviderInstance(
productVariant
);
if (cmsId && !productVariantIsAvailableInOtherChannelEnabledForSelectedProviderInstance) {
return [
...CMSIdsToRemove,
{
id: cmsId,
saleorId: productVariant.id,
},
];
}
return CMSIdsToRemove;
}, [] as ProductResponseSuccess["data"][]);
if (CMSIdsToRemove.length) {
logger.debug("CMS removing batch items called");
try {
await cmsClient.operations.deleteBatchProducts({
ids: CMSIdsToRemove.map((item) => item.id),
});
return {
deletedCmsIds: CMSIdsToRemove,
};
} catch (error) {
logger.error({ error }, "Error removing batch items");
return {
error: "Error removing batch items.",
};
}
}
}
};
export const executeCmsOperations = async ({
cmsOperations,
productVariant,

View file

@ -33,8 +33,10 @@ export const createCmsOperations = async ({
const settingsManager = createSettingsManager(client);
const channelsSettingsParsed = await getChannelsSettings(settingsManager);
const providerInstancesSettingsParsed = await getProviderInstancesSettings(settingsManager);
const [channelsSettingsParsed, providerInstancesSettingsParsed] = await Promise.all([
getChannelsSettings(settingsManager),
getProviderInstancesSettings(settingsManager),
]);
const productVariantCmsProviderInstances = productVariantCmsKeys.map((cmsKey) =>
getCmsIdFromSaleorItemKey(cmsKey)

View file

@ -1,3 +1,3 @@
export { createCmsOperations } from "./clients-operations";
export { executeCmsOperations } from "./clients-execution";
export { executeMetadataUpdate } from "./metadata-execution";
export { updateMetadata, batchUpdateMetadata } from "./metadata-execution";

View file

@ -1,4 +1,5 @@
import { NextWebhookApiHandler } from "@saleor/app-sdk/handlers/next";
import { Client } from "urql";
import {
DeleteMetadataDocument,
UpdateMetadataDocument,
@ -9,7 +10,45 @@ import { createCmsKeyForSaleorItem } from "./metadata";
type WebhookContext = Parameters<NextWebhookApiHandler>["2"];
export const executeMetadataUpdate = async ({
export type MetadataRecord = Record<string, string>;
const executeMetadataUpdateMutation = async ({
apiClient,
itemId,
cmsProviderInstanceIdsToCreate = {},
cmsProviderInstanceIdsToDelete = {},
}: {
apiClient: Client;
itemId: string;
cmsProviderInstanceIdsToCreate?: Record<string, string>;
cmsProviderInstanceIdsToDelete?: Record<string, string>;
}) => {
if (Object.keys(cmsProviderInstanceIdsToCreate).length) {
await apiClient
.mutation(UpdateMetadataDocument, {
id: itemId,
input: Object.entries(cmsProviderInstanceIdsToCreate).map(
([cmsProviderInstanceId, cmsProductVariantId]) => ({
key: createCmsKeyForSaleorItem(cmsProviderInstanceId),
value: cmsProductVariantId,
})
),
})
.toPromise();
}
if (Object.keys(cmsProviderInstanceIdsToDelete).length) {
await apiClient
.mutation(DeleteMetadataDocument, {
id: itemId,
keys: Object.entries(cmsProviderInstanceIdsToDelete).map(([cmsProviderInstanceId]) =>
createCmsKeyForSaleorItem(cmsProviderInstanceId)
),
})
.toPromise();
}
};
export const updateMetadata = async ({
context,
productVariant,
cmsProviderInstanceIdsToCreate,
@ -23,27 +62,70 @@ export const executeMetadataUpdate = async ({
const { token, saleorApiUrl } = context.authData;
const apiClient = createClient(saleorApiUrl, async () => ({ token }));
if (Object.keys(cmsProviderInstanceIdsToCreate).length) {
await apiClient
.mutation(UpdateMetadataDocument, {
id: productVariant.id,
input: Object.entries(cmsProviderInstanceIdsToCreate).map(
([cmsProviderInstanceId, cmsProductVariantId]) => ({
key: createCmsKeyForSaleorItem(cmsProviderInstanceId),
value: cmsProductVariantId,
})
),
})
.toPromise();
}
if (Object.keys(cmsProviderInstanceIdsToDelete).length) {
await apiClient
.mutation(DeleteMetadataDocument, {
id: productVariant.id,
keys: Object.entries(cmsProviderInstanceIdsToDelete).map(([cmsProviderInstanceId]) =>
createCmsKeyForSaleorItem(cmsProviderInstanceId)
),
})
.toPromise();
}
await executeMetadataUpdateMutation({
apiClient,
itemId: productVariant.id,
cmsProviderInstanceIdsToCreate,
cmsProviderInstanceIdsToDelete,
});
};
type ItemMetadataRecord = {
id: string;
cmsProviderInstanceIds: MetadataRecord;
};
export const batchUpdateMetadata = async ({
context,
variantCMSProviderInstanceIdsToCreate,
variantCMSProviderInstanceIdsToDelete,
}: {
context: Pick<WebhookContext, "authData">;
variantCMSProviderInstanceIdsToCreate: ItemMetadataRecord[];
variantCMSProviderInstanceIdsToDelete: ItemMetadataRecord[];
}) => {
const { token, saleorApiUrl } = context.authData;
const apiClient = createClient(saleorApiUrl, async () => ({ token }));
const variantCMSProviderInstanceIdsToCreateMap = variantCMSProviderInstanceIdsToCreate.reduce(
(acc, { id, cmsProviderInstanceIds }) => ({
...acc,
[id]: {
...(acc[id] || {}),
...cmsProviderInstanceIds,
},
}),
{} as Record<string, MetadataRecord>
);
const variantCMSProviderInstanceIdsToDeleteMap = variantCMSProviderInstanceIdsToDelete.reduce(
(acc, { id, cmsProviderInstanceIds }) => ({
...acc,
[id]: {
...(acc[id] || {}),
...cmsProviderInstanceIds,
},
}),
{} as Record<string, MetadataRecord>
);
const mutationsToExecute = [
Object.entries(variantCMSProviderInstanceIdsToCreateMap).map(
([itemId, cmsProviderInstanceIdsToCreate]) =>
executeMetadataUpdateMutation({
apiClient,
itemId,
cmsProviderInstanceIdsToCreate,
})
),
Object.entries(variantCMSProviderInstanceIdsToDeleteMap).map(
([itemId, cmsProviderInstanceIdsToDelete]) =>
executeMetadataUpdateMutation({
apiClient,
itemId,
cmsProviderInstanceIdsToDelete,
})
),
];
await Promise.all(mutationsToExecute);
};

View file

@ -10,6 +10,7 @@ export type ChannelCommonSchema = z.infer<typeof channelCommonSchema>;
export const channelSchema = z
.object({
enabledProviderInstances: z.array(z.string()),
requireSyncProviderInstances: z.array(z.string()).optional(),
})
.merge(channelCommonSchema);

View file

@ -67,6 +67,12 @@ export const providersConfig = {
helpText:
"Content management API URL of your Contentful project. If you leave this blank, default https://api.contentful.com will be used.",
},
{
name: "apiRequestsPerSecond",
label: "API requests per second",
helpText:
"API rate limits. Default 7. Used in bulk products variants sync. Higher rate limits may speed up a little products variants bulk sync. Higher rate limit may apply depending on different Contentful plan, learn more at https://www.contentful.com/developers/docs/references/content-management-api/#/introduction/api-rate-limits.",
},
],
},
strapi: {
@ -152,6 +158,7 @@ export const contentfulConfigSchema = z.object({
locale: z.string().min(1),
contentId: z.string().min(1),
baseUrl: z.string(),
apiRequestsPerSecond: z.string(),
});
export const datocmsConfigSchema = z.object({

View file

@ -0,0 +1,16 @@
export const fetchWithRateLimit = async <A, R>(
args: A[],
fun: (arg: A) => Promise<R>,
requestPerSecondLimit: number
) => {
const delay = 1000 / requestPerSecondLimit;
const results: Promise<R>[] = [];
for (const arg of args) {
const result = fun(arg);
results.push(result);
await new Promise((resolve) => setTimeout(resolve, delay));
}
return await Promise.all(results);
};

View file

@ -2,8 +2,9 @@ import { v4 as uuidv4 } from "uuid";
import { ContentfulConfig, contentfulConfigSchema } from "../config";
import { logger as pinoLogger } from "../../logger";
import { CreateOperations, CreateProductResponse, ProductInput } from "../types";
import { CreateOperations, ProductResponse, ProductInput } from "../types";
import { createProvider } from "./create";
import { fetchWithRateLimit } from "../data-sync";
const contentfulFetch = (endpoint: string, config: ContentfulConfig, options?: RequestInit) => {
const baseUrl = config.baseUrl || "https://api.contentful.com";
@ -30,6 +31,8 @@ type ContentfulResponse = {
id: string;
version?: number;
};
statusCode: number;
input: ProductInput;
};
const transformInputToBody = ({
@ -64,7 +67,7 @@ const transformInputToBody = ({
return body;
};
const transformCreateProductResponse = (response: ContentfulResponse): CreateProductResponse => {
const transformCreateProductResponse = (response: ContentfulResponse): ProductResponse => {
if (response.message) {
return {
ok: false,
@ -76,6 +79,7 @@ const transformCreateProductResponse = (response: ContentfulResponse): CreatePro
ok: true,
data: {
id: response.sys.id,
saleorId: response.input.saleorId,
},
};
};
@ -93,66 +97,140 @@ const getEntryEndpoint = ({
const contentfulOperations: CreateOperations<ContentfulConfig> = (config) => {
const logger = pinoLogger.child({ cms: "strapi" });
const { environment, spaceId, contentId, locale } = config;
const { environment, spaceId, contentId, locale, apiRequestsPerSecond } = config;
const requestPerSecondLimit = Number(apiRequestsPerSecond || 7);
const createProductInCMS = async (input: ProductInput): Promise<ContentfulResponse> => {
// Contentful API does not auto generate resource ID during creation, it has to be provided.
const resourceId = uuidv4();
const body = transformInputToBody({ input, locale });
const endpoint = getEntryEndpoint({
resourceId,
environment,
spaceId,
});
const response = await contentfulFetch(endpoint, config, {
method: "PUT",
body: JSON.stringify(body),
headers: {
"X-Contentful-Content-Type": contentId,
},
});
logger.debug({ response }, "createProduct response");
const json = await response.json();
return {
...json,
statusCode: response.status,
input,
};
};
const updateProductInCMS = async (id: string, input: ProductInput) => {
const body = transformInputToBody({ input, locale });
const endpoint = getEntryEndpoint({
resourceId: id,
environment,
spaceId,
});
const getEntryResponse = await contentfulFetch(endpoint, config, { method: "GET" });
logger.debug({ getEntryResponse }, "updateProduct getEntryResponse");
const entry = await getEntryResponse.json();
logger.debug({ entry }, "updateProduct entry");
const response = await contentfulFetch(endpoint, config, {
method: "PUT",
body: JSON.stringify(body),
headers: {
"X-Contentful-Version": entry.sys.version,
},
});
logger.debug({ response }, "updateProduct response");
const json = await response.json();
return {
...json,
statusCode: response.status,
};
};
const deleteProductInCMS = async (id: string) => {
const endpoint = getEntryEndpoint({ resourceId: id, environment, spaceId });
return await contentfulFetch(endpoint, config, { method: "DELETE" });
};
const createBatchProductsInCMS = async (input: ProductInput[]) => {
// Contentful doesn't support batch creation of items, so we need to create them one by one
// Take into account rate limit
const firstResults = await fetchWithRateLimit(input, createProductInCMS, requestPerSecondLimit);
const failedWithLimitResults = firstResults.filter((result) => result.statusCode === 429);
// Retry with delay x2 if by any chance hit rate limit with HTTP 429
let secondResults: ContentfulResponse[] = [];
if (failedWithLimitResults.length > 0) {
logger.debug("createBatchProductsInCMS retrying failed by rate limit with delay x2");
secondResults = await fetchWithRateLimit(
failedWithLimitResults,
(result) => createProductInCMS(result.input),
requestPerSecondLimit / 2
);
}
return [...firstResults.filter((result) => result.statusCode !== 429), ...secondResults];
};
const deleteBatchProductsInCMS = async (ids: string[]) => {
// Contentful doesn't support batch deletion of items, so we need to delete them one by one
// Take into account rate limit
const firstResults = await fetchWithRateLimit(ids, deleteProductInCMS, requestPerSecondLimit);
const failedWithLimitResults = firstResults.filter((result) => result.status === 429);
// Retry with delay x2 if by any chance hit rate limit with HTTP 429
let secondResults: Response[] = [];
if (failedWithLimitResults.length > 0) {
logger.debug("deleteBatchProductsInCMS retrying failed by rate limit with delay x2");
secondResults = await fetchWithRateLimit(
failedWithLimitResults,
(result) => deleteProductInCMS(result.url),
requestPerSecondLimit / 2
);
}
return [...firstResults.filter((result) => result.status !== 429), ...secondResults];
};
return {
createProduct: async (params) => {
// Contentful API does not auto generate resource ID during creation, it has to be provided.
const resourceId = uuidv4();
const body = transformInputToBody({ input: params.input, locale });
const endpoint = getEntryEndpoint({
resourceId,
environment,
spaceId,
});
const response = await contentfulFetch(endpoint, config, {
method: "PUT",
body: JSON.stringify(body),
headers: {
"X-Contentful-Content-Type": contentId,
},
});
logger.debug("createProduct response", { response });
const result = await response.json();
logger.debug("createProduct result", { result });
createProduct: async ({ input }) => {
const result = await createProductInCMS(input);
logger.debug({ result }, "createProduct result");
return transformCreateProductResponse(result);
},
updateProduct: async ({ id, input }) => {
const body = transformInputToBody({ input, locale });
const endpoint = getEntryEndpoint({
resourceId: id,
environment,
spaceId,
});
const getEntryResponse = await contentfulFetch(endpoint, config, { method: "GET" });
logger.debug("updateProduct getEntryResponse", { getEntryResponse });
const entry = await getEntryResponse.json();
logger.debug("updateProduct entry", { entry });
const response = await contentfulFetch(endpoint, config, {
method: "PUT",
body: JSON.stringify(body),
headers: {
"X-Contentful-Version": entry.sys.version,
},
});
logger.debug("updateProduct response", { response });
const result = await response.json();
logger.debug("updateProduct result", { result });
const result = await updateProductInCMS(id, input);
logger.debug({ result }, "updateProduct result");
return result;
},
deleteProduct: async ({ id }) => {
const endpoint = getEntryEndpoint({ resourceId: id, environment, spaceId });
const response = await contentfulFetch(endpoint, config, { method: "DELETE" });
logger.debug("deleteProduct response", { response });
const response = await deleteProductInCMS(id);
logger.debug({ response }, "deleteProduct response");
return response;
},
createBatchProducts: async ({ input }) => {
const results = await createBatchProductsInCMS(input);
logger.debug({ results }, "createBatchProducts results");
return results.map((result) => transformCreateProductResponse(result));
},
deleteBatchProducts: async ({ ids }) => {
const results = await deleteBatchProductsInCMS(ids);
logger.debug({ results }, "deleteBatchProducts results");
},
};
};

View file

@ -1,5 +1,5 @@
import { createProvider } from "./create";
import { CreateOperations, CreateProductResponse } from "../types";
import { CreateOperations, ProductInput, ProductResponse } from "../types";
import { logger as pinoLogger } from "../../logger";
import { ApiError, buildClient, SimpleSchemaTypes } from "@datocms/cma-client-node";
@ -18,7 +18,7 @@ const datocmsClient = (config: DatocmsConfig, options?: RequestInit) => {
});
};
const transformResponseError = (error: unknown): CreateProductResponse => {
const transformResponseError = (error: unknown): ProductResponse => {
if (error instanceof ApiError) {
return {
ok: false,
@ -32,11 +32,15 @@ const transformResponseError = (error: unknown): CreateProductResponse => {
}
};
const transformResponseItem = (item: SimpleSchemaTypes.Item): CreateProductResponse => {
const transformResponseItem = (
item: SimpleSchemaTypes.Item,
input: ProductInput
): ProductResponse => {
return {
ok: true,
data: {
id: item.id,
saleorId: input.saleorId,
},
};
};
@ -44,48 +48,76 @@ const transformResponseItem = (item: SimpleSchemaTypes.Item): CreateProductRespo
const datocmsOperations: CreateOperations<DatocmsConfig> = (config) => {
const logger = pinoLogger.child({ cms: "strapi" });
const client = datocmsClient(config);
const createProductInCMS = async (input: ProductInput) =>
client.items.create({
item_type: {
id: config.itemTypeId,
type: "item_type",
},
saleor_id: input.saleorId,
name: input.name,
channels: JSON.stringify(input.channels),
product_id: input.productId,
product_name: input.productName,
product_slug: input.productSlug,
});
const updateProductInCMS = async (id: string, input: ProductInput) =>
client.items.update(id, {
saleor_id: input.saleorId,
name: input.name,
channels: JSON.stringify(input.channels),
product_id: input.productId,
product_name: input.productName,
product_slug: input.productSlug,
});
const deleteProductInCMS = async (id: string) => client.items.destroy(id);
const createBatchProductsInCMS = async (input: ProductInput[]) =>
// DatoCMS doesn't support batch creation of items, so we need to create them one by one
Promise.all(
input.map(async (item) => ({
id: await createProductInCMS(item),
input: item,
}))
);
const deleteBatchProductsInCMS = async (ids: string[]) =>
client.items.bulkDestroy({
items: ids.map((id) => ({ id, type: "item" })),
});
return {
createProduct: async ({ input }) => {
const client = datocmsClient(config);
try {
const item = await client.items.create({
item_type: {
id: config.itemTypeId,
type: "item_type",
},
saleor_id: input.saleorId,
name: input.name,
channels: JSON.stringify(input.channels),
product_id: input.productId,
product_name: input.productName,
product_slug: input.productSlug,
});
logger.debug("createProduct response", { item });
const item = await createProductInCMS(input);
logger.debug({ item }, "createProduct response");
return transformResponseItem(item);
return transformResponseItem(item, input);
} catch (error) {
return transformResponseError(error);
}
},
updateProduct: async ({ id, input }) => {
const client = datocmsClient(config);
const item = await client.items.update(id, {
saleor_id: input.saleorId,
name: input.name,
channels: JSON.stringify(input.channels),
product_id: input.productId,
product_name: input.productName,
product_slug: input.productSlug,
});
logger.debug("updateProduct response", { item });
const item = await updateProductInCMS(id, input);
logger.debug({ item }, "updateProduct response");
},
deleteProduct: async ({ id }) => {
const client = datocmsClient(config);
const item = await deleteProductInCMS(id);
logger.debug({ item }, "deleteProduct response");
},
createBatchProducts: async ({ input }) => {
const items = await createBatchProductsInCMS(input);
logger.debug({ items }, "createBatchProducts response");
const item = await client.items.destroy(id);
logger.debug("deleteProduct response", { item });
return items.map((item) => transformResponseItem(item.id, item.input));
},
deleteBatchProducts: async ({ ids }) => {
const items = await deleteBatchProductsInCMS(ids);
logger.debug({ items }, "deleteBatchProducts response");
},
};
};

View file

@ -1,5 +1,5 @@
import { StrapiConfig, strapiConfigSchema } from "../config";
import { CmsOperations, CreateOperations, CreateProductResponse, ProductInput } from "../types";
import { CreateOperations, ProductResponse, ProductInput } from "../types";
import { createProvider } from "./create";
import { logger as pinoLogger } from "../../logger";
@ -20,7 +20,7 @@ type StrapiBody = {
data: Record<string, any> & { saleor_id: string };
};
const transformInputToBody = ({ input }: { input: ProductInput }): StrapiBody => {
const transformInputToBody = (input: ProductInput): StrapiBody => {
const body = {
data: {
saleor_id: input.saleorId,
@ -55,7 +55,10 @@ type StrapiResponse =
error: null;
};
const transformCreateProductResponse = (response: StrapiResponse): CreateProductResponse => {
const transformCreateProductResponse = (
response: StrapiResponse,
input: ProductInput
): ProductResponse => {
if (response.error) {
return {
ok: false,
@ -67,47 +70,86 @@ const transformCreateProductResponse = (response: StrapiResponse): CreateProduct
ok: true,
data: {
id: response.data.id,
saleorId: input.saleorId,
},
};
};
type CreateStrapiOperations = CreateOperations<StrapiConfig>;
export const strapiOperations: CreateStrapiOperations = (config): CmsOperations => {
export const strapiOperations: CreateStrapiOperations = (config) => {
const logger = pinoLogger.child({ cms: "strapi" });
const { contentTypeId } = config;
const createProductInCMS = async (input: ProductInput): Promise<StrapiResponse> => {
const body = transformInputToBody(input);
const response = await strapiFetch(`/${contentTypeId}`, config, {
method: "POST",
body: JSON.stringify(body),
});
logger.debug({ response }, "createProduct response");
return await response.json();
};
const updateProductInCMS = async (id: string, input: ProductInput) => {
const body = transformInputToBody(input);
return await strapiFetch(`/${contentTypeId}/${id}`, config, {
method: "PUT",
body: JSON.stringify(body),
});
};
const deleteProductInCMS = async (id: string) => {
return await strapiFetch(`/${contentTypeId}/${id}`, config, { method: "DELETE" });
};
const createBatchProductsInCMS = async (input: ProductInput[]) => {
// Strapi doesn't support batch creation of items, so we need to create them one by one
return await Promise.all(
input.map(async (product) => ({
response: await createProductInCMS(product),
input: product,
}))
);
};
const deleteBatchProductsInCMS = async (ids: string[]) => {
// Strapi doesn't support batch deletion of items, so we need to delete them one by one
return await Promise.all(ids.map((id) => deleteProductInCMS(id)));
};
return {
createProduct: async (params) => {
const body = transformInputToBody(params);
const response = await strapiFetch(`/${contentTypeId}`, config, {
method: "POST",
body: JSON.stringify(body),
});
logger.debug("createProduct response", { response });
createProduct: async ({ input }) => {
const result = await createProductInCMS(input);
logger.debug({ result }, "createProduct result");
const result = await response.json();
logger.debug("createProduct result", { result });
return transformCreateProductResponse(result);
return transformCreateProductResponse(result, input);
},
updateProduct: async ({ id, input }) => {
const body = transformInputToBody({ input });
const response = await strapiFetch(`/${contentTypeId}/${id}`, config, {
method: "PUT",
body: JSON.stringify(body),
});
logger.debug("updateProduct response", { response });
const response = await updateProductInCMS(id, input);
logger.debug({ response }, "updateProduct response");
return response;
},
deleteProduct: async ({ id }) => {
const response = await strapiFetch(`/${contentTypeId}/${id}`, config, { method: "DELETE" });
logger.debug("deleteProduct response", { response });
const response = await deleteProductInCMS(id);
logger.debug({ response }, "deleteProduct response");
return response;
},
createBatchProducts: async ({ input }) => {
const results = await createBatchProductsInCMS(input);
logger.debug({ results }, "createBatchProducts results");
return results.map((result) => transformCreateProductResponse(result.response, result.input));
},
deleteBatchProducts: async ({ ids }) => {
const responses = await deleteBatchProductsInCMS(ids);
logger.debug({ responses }, "deleteBatchProducts responses");
return responses;
},
};
};

View file

@ -11,24 +11,35 @@ export type ProductInput = Record<string, any> & {
image?: string;
};
export type CreateProductResponse =
| { ok: true; data: { id: string } }
| { ok: false; error: string };
export type ProductResponseSuccess = { ok: true; data: { id: string; saleorId: string } };
export type ProductResponseError = { ok: false; error: string };
export type ProductResponse = ProductResponseSuccess | ProductResponseError;
export type CmsOperations = {
getAllProducts?: () => Promise<Response>;
getProduct?: ({ id }: { id: string }) => Promise<Response>;
createProduct: ({ input }: { input: ProductInput }) => Promise<CreateProductResponse>;
createProduct: ({ input }: { input: ProductInput }) => Promise<ProductResponse>;
updateProduct: ({ id, input }: { id: string; input: ProductInput }) => Promise<Response | void>;
deleteProduct: ({ id }: { id: string }) => Promise<Response | void>;
};
export type CmsBatchOperations = {
getAllProducts?: () => Promise<Response>;
createBatchProducts: ({ input }: { input: ProductInput[] }) => Promise<ProductResponse[]>;
deleteBatchProducts: ({ ids }: { ids: string[] }) => Promise<Response[] | void>;
};
export type CmsClientOperations = {
cmsProviderInstanceId: string;
operations: CmsOperations;
operationType: keyof CmsOperations;
};
export type CmsClientBatchOperations = {
cmsProviderInstanceId: string;
operations: CmsBatchOperations;
operationType: keyof CmsBatchOperations;
};
export type GetProviderTokens<TProviderName extends keyof typeof providersConfig> =
(typeof providersConfig)[TProviderName]["tokens"][number];
@ -43,7 +54,9 @@ export type CreateProviderConfig<TProviderName extends keyof typeof providersCon
> &
BaseConfig;
export type CreateOperations<TConfig extends BaseConfig> = (config: TConfig) => CmsOperations;
export type CreateOperations<TConfig extends BaseConfig> = (
config: TConfig
) => CmsOperations & CmsBatchOperations;
export type Provider<TConfig extends BaseConfig> = {
create: CreateOperations<TConfig>;

View file

@ -10,39 +10,41 @@ interface IAuthState {
token: string;
}
const getExchanges = (getAuth: AuthConfig<IAuthState>["getAuth"]) => [
dedupExchange,
cacheExchange,
authExchange<IAuthState>({
addAuthToOperation: ({ authState, operation }) => {
if (!authState || !authState?.token) {
return operation;
}
const fetchOptions =
typeof operation.context.fetchOptions === "function"
? operation.context.fetchOptions()
: operation.context.fetchOptions || {};
return {
...operation,
context: {
...operation.context,
fetchOptions: {
...fetchOptions,
headers: {
...fetchOptions.headers,
"Authorization-Bearer": authState.token,
},
},
},
};
},
getAuth,
}),
fetchExchange,
];
export const createClient = (url: string, getAuth: AuthConfig<IAuthState>["getAuth"]) =>
urqlCreateClient({
url,
exchanges: [
dedupExchange,
cacheExchange,
authExchange<IAuthState>({
addAuthToOperation: ({ authState, operation }) => {
if (!authState || !authState?.token) {
return operation;
}
const fetchOptions =
typeof operation.context.fetchOptions === "function"
? operation.context.fetchOptions()
: operation.context.fetchOptions || {};
return {
...operation,
context: {
...operation.context,
fetchOptions: {
...fetchOptions,
headers: {
...fetchOptions.headers,
"Authorization-Bearer": authState.token,
},
},
},
};
},
getAuth,
}),
fetchExchange,
],
exchanges: getExchanges(getAuth),
});

View file

@ -9,6 +9,9 @@ import {
ListItem,
ListItemCell,
makeStyles,
Notification,
Alert,
IconButton,
} from "@saleor/macaw-ui";
import React from "react";
import { useForm } from "react-hook-form";
@ -20,19 +23,30 @@ import {
SingleProviderSchema,
} from "../../../lib/cms/config";
import { ProviderIcon } from "../../provider-instances/ui/provider-icon";
import { ChannelsLoading } from "./types";
const useStyles = makeStyles((theme) => {
return {
item: {
height: "auto !important",
display: "grid",
gridTemplateColumns: "1fr 80px",
gridTemplateColumns: "1fr 80px 80px",
},
itemCell: {
display: "flex",
alignItems: "center",
gap: theme.spacing(2),
},
itemCellCenter: {
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing(2),
},
itemCellProgress: {
padding: theme.spacing(0, 4),
gridColumn: "1/5",
},
footer: {
display: "flex",
justifyContent: "flex-end",
@ -48,8 +62,9 @@ const useStyles = makeStyles((theme) => {
interface ChannelConfigurationFormProps {
channel?: MergedChannelSchema | null;
providerInstances: SingleProviderSchema[];
loading: boolean;
loading: ChannelsLoading;
onSubmit: (channel: SingleChannelSchema) => any;
onSync: (providerInstanceId: string) => any;
}
export const ChannelConfigurationForm = ({
@ -57,6 +72,7 @@ export const ChannelConfigurationForm = ({
providerInstances,
loading,
onSubmit,
onSync,
}: ChannelConfigurationFormProps) => {
const styles = useStyles();
@ -85,6 +101,9 @@ export const ChannelConfigurationForm = ({
resetField("enabledProviderInstances", {
defaultValue: channel?.enabledProviderInstances || [],
});
resetField("requireSyncProviderInstances", {
defaultValue: channel?.requireSyncProviderInstances || [],
});
}, [channel, providerInstances]);
const errors = formState.errors;
@ -97,56 +116,92 @@ export const ChannelConfigurationForm = ({
</Typography>
)}
<input type="hidden" {...register("channelSlug")} value={channel?.channelSlug} />
<List gridTemplate={["1fr", "checkbox"]}>
<List gridTemplate={["1fr", "80px", "checkbox"]}>
<ListHeader>
<ListItem className={styles.item}>
<ListItemCell>CMS provider configuration</ListItemCell>
<ListItemCell>Active</ListItemCell>
<ListItemCell className={styles.itemCellCenter}>Active</ListItemCell>
<ListItemCell className={styles.itemCellCenter}>Sync</ListItemCell>
</ListItem>
</ListHeader>
<ListBody>
{providerInstances.map((providerInstance) => (
<ListItem key={providerInstance.name} className={styles.item}>
<ListItemCell className={styles.itemCell}>
<ProviderIcon providerName={providerInstance.providerName} />
{providerInstance.name}
</ListItemCell>
<ListItemCell padding="checkbox">
<FormControl
{...register("enabledProviderInstances")}
name="enabledProviderInstances"
checked={watch("enabledProviderInstances")?.some(
(formOption) => formOption === providerInstance.id
)}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
const valueCopy = getValues("enabledProviderInstances")
? [...getValues("enabledProviderInstances")]
: [];
if (event.target.checked) {
valueCopy.push(providerInstance.id);
} else {
const idx = valueCopy.findIndex(
(formOption) => formOption === providerInstance.id
);
valueCopy.splice(idx, 1);
{providerInstances.map((providerInstance) => {
const enabledProviderInstances = watch("enabledProviderInstances");
const requireSyncProviderInstances = watch("requireSyncProviderInstances");
const isEnabled = enabledProviderInstances?.some(
(formOption) => formOption === providerInstance.id
);
const requireSync = requireSyncProviderInstances?.some(
(formOption) => formOption === providerInstance.id
);
return (
<ListItem key={providerInstance.name} className={styles.item}>
<ListItemCell className={styles.itemCell}>
<ProviderIcon providerName={providerInstance.providerName} />
{providerInstance.name}
</ListItemCell>
<ListItemCell padding="checkbox" className={styles.itemCellCenter}>
<FormControl
{...register("enabledProviderInstances")}
name="enabledProviderInstances"
checked={isEnabled}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
const valueCopy = getValues("enabledProviderInstances")
? [...getValues("enabledProviderInstances")]
: [];
if (event.target.checked) {
valueCopy.push(providerInstance.id);
} else {
const idx = valueCopy.findIndex(
(formOption) => formOption === providerInstance.id
);
valueCopy.splice(idx, 1);
}
resetField("enabledProviderInstances", {
defaultValue: valueCopy,
});
}}
value={providerInstance.name}
component={(props) => <Checkbox {...props} />}
/>
</ListItemCell>
<ListItemCell className={styles.itemCellCenter}>
<Button
variant="primary"
disabled={
!requireSync || !!loading.productsVariantsSync.syncingProviderInstanceId
}
resetField("enabledProviderInstances", {
defaultValue: valueCopy,
});
}}
value={providerInstance.name}
component={(props) => <Checkbox {...props} />}
/>
</ListItemCell>
</ListItem>
))}
onClick={() => onSync(providerInstance.id)}
>
Sync
</Button>
</ListItemCell>
{loading.productsVariantsSync.syncingProviderInstanceId === providerInstance.id && (
<ListItemCell className={styles.itemCellProgress}>
Syncing products...
<progress
value={loading.productsVariantsSync.currentProductIndex}
max={loading.productsVariantsSync.totalProductsCount}
style={{
height: "30px",
width: "500px",
maxWidth: "100%",
}}
/>
</ListItemCell>
)}
</ListItem>
);
})}
{/* </>
)}
/> */}
</ListBody>
<ListFooter className={styles.footer}>
<Button variant="primary" disabled={loading} type="submit">
{loading ? "..." : "Save"}
<Button variant="primary" disabled={loading.channels.saving} type="submit">
{loading.channels.saving ? "..." : "Save"}
</Button>
</ListFooter>
</List>

View file

@ -7,9 +7,10 @@ import {
SingleChannelSchema,
SingleProviderSchema,
} from "../../../lib/cms/config";
import { ChannelsErrors, ChannelsLoading } from "./types";
import { ChannelsLoading } from "./types";
import { makeStyles } from "@saleor/macaw-ui";
import { AppTabNavButton } from "../../ui/app-tab-nav-button";
import { ChannelsDataErrors } from "./hooks/useChannels";
const useStyles = makeStyles((theme) => ({
textCenter: {
@ -55,20 +56,22 @@ interface ChannelConfigurationProps {
activeChannel?: MergedChannelSchema | null;
providerInstances: SingleProviderSchema[];
saveChannel: (channel: SingleChannelSchema) => any;
syncChannelProviderInstance: (providerInstanceId: string) => any;
loading: ChannelsLoading;
errors: ChannelsErrors;
errors: ChannelsDataErrors;
}
export const ChannelConfiguration = ({
activeChannel,
providerInstances,
saveChannel,
syncChannelProviderInstance,
loading,
errors,
}: ChannelConfigurationProps) => {
const styles = useStyles();
if (loading.fetching || loading.saving) {
if (loading.channels.fetching || loading.channels.saving) {
return <ChannelConfigurationSkeleton />;
}
@ -110,8 +113,9 @@ export const ChannelConfiguration = ({
<ChannelConfigurationForm
channel={activeChannel}
providerInstances={providerInstances}
loading={loading.saving}
loading={loading}
onSubmit={saveChannel}
onSync={syncChannelProviderInstance}
/>
</Paper>
);

View file

@ -2,8 +2,9 @@ import { Skeleton } from "@material-ui/lab";
import { MergedChannelSchema } from "../../../lib/cms";
import { AppPaper } from "../../ui/app-paper";
import { ChannelsErrors, ChannelsLoading } from "./types";
import { ChannelsLoading } from "./types";
import { ChannelsSelect } from "./channels-select";
import { ChannelsDataErrors } from "./hooks/useChannels";
const ChannelsListSkeleton = () => {
return (
@ -18,7 +19,7 @@ interface ChannelsListProps {
activeChannel?: MergedChannelSchema | null;
setActiveChannel: (channel: MergedChannelSchema | null) => void;
loading: ChannelsLoading;
errors: ChannelsErrors;
errors: ChannelsDataErrors;
}
export const ChannelsList = ({
@ -28,7 +29,7 @@ export const ChannelsList = ({
loading,
errors,
}: ChannelsListProps) => {
if (loading.fetching) {
if (loading.channels.fetching) {
return <ChannelsListSkeleton />;
}

View file

@ -1,11 +1,16 @@
import { makeStyles } from "@saleor/macaw-ui";
import { useEffect, useState } from "react";
import { MergedChannelSchema } from "../../../lib/cms/config";
import { MergedChannelSchema, SingleChannelSchema } from "../../../lib/cms/config";
import {
useProductsVariantsSync,
ProductsVariantsSyncOperation,
} from "../../cms/hooks/useProductsVariantsSync";
import { useProviderInstances } from "../../provider-instances/ui/hooks/useProviderInstances";
import { AppTabs } from "../../ui/app-tabs";
import { ChannelConfiguration } from "./channel-configuration";
import { ChannelsList } from "./channels-list";
import { useChannels } from "./hooks/useChannels";
import { AppTabs } from "../../ui/app-tabs";
import { makeStyles } from "@saleor/macaw-ui";
import { ChannelsLoading } from "./types";
const useStyles = makeStyles({
wrapper: {
@ -17,7 +22,7 @@ const useStyles = makeStyles({
export const Channels = () => {
const styles = useStyles();
const { channels, saveChannel, loading, errors } = useChannels();
const { channels, saveChannel, loading: loadingChannels, errors } = useChannels();
const { providerInstances } = useProviderInstances();
const [activeChannelSlug, setActiveChannelSlug] = useState<string | null>(
@ -36,6 +41,40 @@ export const Channels = () => {
}
}, [channels]);
const handleOnSyncCompleted = (providerInstanceId: string) => {
if (!activeChannel) {
return;
}
saveChannel({
...activeChannel,
requireSyncProviderInstances: activeChannel.requireSyncProviderInstances?.filter(
(id) => id !== providerInstanceId
),
});
};
const { sync, loading: loadingProductsVariantsSync } = useProductsVariantsSync(
activeChannelSlug,
handleOnSyncCompleted
);
const handleSync = async (providerInstanceId: string) => {
if (!activeChannel) {
return;
}
const operation: ProductsVariantsSyncOperation =
activeChannel.enabledProviderInstances.includes(providerInstanceId) ? "ADD" : "DELETE";
return sync(providerInstanceId, operation);
};
const loading: ChannelsLoading = {
channels: loadingChannels,
productsVariantsSync: loadingProductsVariantsSync,
};
return (
<>
<AppTabs activeTab="channels" />
@ -52,6 +91,7 @@ export const Channels = () => {
activeChannel={activeChannel}
providerInstances={providerInstances}
saveChannel={saveChannel}
syncChannelProviderInstance={handleSync}
loading={loading}
errors={errors}
/>

View file

@ -1,10 +1,19 @@
import { useChannelsFetch } from "./useChannelsFetch";
import { MergedChannelSchema, SingleChannelSchema } from "../../../../lib/cms/config";
import { ChannelsErrors, ChannelsLoading } from "../types";
import { useChannelsQuery } from "../../../../../generated/graphql";
import { useIsMounted } from "usehooks-ts";
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
export interface ChannelsDataLoading {
fetching: boolean;
saving: boolean;
}
export interface ChannelsDataErrors {
fetching?: Error | null;
saving?: Error | null;
}
export const useChannels = () => {
const { appBridge } = useAppBridge();
const isMounted = useIsMounted();
@ -19,10 +28,33 @@ export const useChannels = () => {
isFetching,
} = useChannelsFetch();
const saveChannel = (channelToSave: SingleChannelSchema) => {
const saveChannel = async (channelToSave: SingleChannelSchema) => {
console.log("saveChannel", channelToSave);
saveChannelFetch(channelToSave).then(() => {
const currentlyEnabledProviderInstances =
settings?.[`${channelToSave.channelSlug}`]?.enabledProviderInstances || [];
const toEnableProviderInstances = channelToSave.enabledProviderInstances || [];
const changedSyncProviderInstances = [
...currentlyEnabledProviderInstances.filter(
(instance) => !toEnableProviderInstances.includes(instance)
),
...toEnableProviderInstances.filter(
(instance) => !currentlyEnabledProviderInstances.includes(instance)
),
];
const fetchResult = await saveChannelFetch({
...channelToSave,
requireSyncProviderInstances: [
...(channelToSave.requireSyncProviderInstances || []),
...changedSyncProviderInstances.filter(
(instance) => !(channelToSave.requireSyncProviderInstances || []).includes(instance)
),
],
});
if (fetchResult.success) {
appBridge?.dispatch(
actions.Notification({
title: "Success",
@ -30,15 +62,23 @@ export const useChannels = () => {
text: "Configuration saved",
})
);
});
} else {
appBridge?.dispatch(
actions.Notification({
title: "Error",
status: "error",
text: "Error while saving configuration",
})
);
}
};
const loading: ChannelsLoading = {
const loading: ChannelsDataLoading = {
fetching: isFetching || channelsQueryData.fetching,
saving: isSaving,
};
const errors: ChannelsErrors = {
const errors: ChannelsDataErrors = {
fetching: fetchingError ? Error(fetchingError) : null,
saving: null,
};
@ -51,6 +91,9 @@ export const useChannels = () => {
enabledProviderInstances: settings
? settings[`${channel.slug}`]?.enabledProviderInstances
: [],
requireSyncProviderInstances: settings
? settings[`${channel.slug}`]?.requireSyncProviderInstances
: [],
channel: channel,
} as MergedChannelSchema)
) || [];

View file

@ -64,9 +64,19 @@ export const useChannelsFetch = () => {
console.log("saveSettings config", config);
setConfig(config);
return {
success: true,
};
} else {
throw new Error();
}
} catch (error) {
console.log(error);
return {
success: false,
};
}
};

View file

@ -1,9 +1,7 @@
export interface ChannelsLoading {
fetching: boolean;
saving: boolean;
}
import { ProductsVariantsSyncLoading } from "../../cms/hooks/useProductsVariantsSync";
import { ChannelsDataLoading } from "./hooks/useChannels";
export interface ChannelsErrors {
fetching?: Error | null;
saving?: Error | null;
export interface ChannelsLoading {
channels: ChannelsDataLoading;
productsVariantsSync: ProductsVariantsSyncLoading;
}

View file

@ -0,0 +1,138 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "@saleor/app-sdk/const";
import { useCallback, useEffect, useState } from "react";
import { WebhookProductVariantFragment } from "../../../../generated/graphql";
import { Products, useQueryAllProducts } from "./useQueryAllProducts";
export interface ProductsVariantsSyncLoading {
syncingProviderInstanceId?: string;
currentProductIndex?: number;
totalProductsCount?: number;
}
export type ProductsVariantsSyncOperation = "ADD" | "DELETE";
const BATCH_SIZE = 100;
interface UseProductsVariantsSyncHandlers {
sync: (providerInstanceId: string, operation: ProductsVariantsSyncOperation) => void;
loading: ProductsVariantsSyncLoading;
}
export const useProductsVariantsSync = (
channelSlug: string | null,
onSyncCompleted: (providerInstanceId: string) => void
): UseProductsVariantsSyncHandlers => {
const { appBridgeState } = useAppBridge();
const [startedProviderInstanceId, setStartedProviderInstanceId] = useState<string>();
const [startedOperation, setStartedOperation] = useState<ProductsVariantsSyncOperation>();
const [currentProductIndex, setCurrentProductIndex] = useState(0);
const [isImporting, setIsImporting] = useState(false);
const { products, fetchCompleted } = useQueryAllProducts(!startedProviderInstanceId, channelSlug);
const sync = (providerInstanceId: string, operation: ProductsVariantsSyncOperation) => {
setStartedProviderInstanceId(providerInstanceId);
setStartedOperation(operation);
setCurrentProductIndex(0);
};
const syncFetch = async (
providerInstanceId: string,
operation: ProductsVariantsSyncOperation,
productsBatch: Products
) => {
const productsVariants = productsBatch.reduce((acc, product) => {
const variants = product.variants?.map((variant) => {
const { variants: _, ...productFields } = product;
return {
product: productFields,
...variant,
};
});
return variants ? [...acc, ...variants] : acc;
}, [] as WebhookProductVariantFragment[]);
try {
const syncResponse = await fetch("/api/sync-products-variants", {
method: "POST",
headers: [
["content-type", "application/json"],
[SALEOR_API_URL_HEADER, appBridgeState?.saleorApiUrl!],
[SALEOR_AUTHORIZATION_BEARER_HEADER, appBridgeState?.token!],
],
body: JSON.stringify({
providerInstanceId,
productsVariants,
operation,
}),
});
const syncResult = await syncResponse.json();
return syncResult;
} catch (error) {
console.error("useProductsVariantsSync syncFetch error", error);
}
};
useEffect(() => {
if (
products.length <= currentProductIndex &&
fetchCompleted &&
startedProviderInstanceId &&
startedOperation
) {
const completedProviderInstanceIdSync = startedProviderInstanceId;
setStartedProviderInstanceId(undefined);
setStartedOperation(undefined);
setCurrentProductIndex(0);
onSyncCompleted(completedProviderInstanceIdSync);
}
}, [products.length, currentProductIndex, fetchCompleted]);
useEffect(() => {
if (!startedProviderInstanceId || !startedOperation) {
return;
}
if (products.length <= currentProductIndex) {
return;
}
if (isImporting) {
return;
}
(async () => {
setIsImporting(true);
const productsBatchStartIndex = currentProductIndex;
const productsBatchEndIndex = Math.min(currentProductIndex + BATCH_SIZE, products.length);
const productsBatch = products.slice(productsBatchStartIndex, productsBatchEndIndex);
// temporary solution, cannot use directly backend methods without fetch, due to non-browser Node dependency, like await cmsProvider.updatedBatchProducts(productsBatch);
await syncFetch(startedProviderInstanceId, startedOperation, productsBatch);
setIsImporting(false);
setCurrentProductIndex(productsBatchEndIndex);
})();
}, [
startedProviderInstanceId,
startedOperation,
currentProductIndex,
isImporting,
products.length,
]);
const loading: ProductsVariantsSyncLoading = {
syncingProviderInstanceId: startedProviderInstanceId,
currentProductIndex,
totalProductsCount: products.length,
};
return {
sync,
loading,
};
};

View file

@ -0,0 +1,81 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { useEffect, useState } from "react";
import {
ProductsDataForImportDocument,
ProductsDataForImportQuery,
} from "../../../../generated/graphql";
import { createClient } from "../../../lib/graphql";
const PER_PAGE = 100;
export type Products = NonNullable<
ProductsDataForImportQuery["products"]
>["edges"][number]["node"][];
export const useQueryAllProducts = (paused: boolean, channelSlug: string | null) => {
const { appBridgeState } = useAppBridge();
const saleorApiUrl = appBridgeState?.saleorApiUrl!;
const [products, setProducts] = useState<Products>([]);
const [fetchCompleted, setFetchCompleted] = useState(false);
useEffect(() => {
if (paused) {
setProducts([]);
setFetchCompleted(false);
}
}, [paused]);
useEffect(() => {
if (paused || !channelSlug || !appBridgeState?.token) {
return;
}
const token = appBridgeState.token;
const client = createClient(saleorApiUrl, () => Promise.resolve({ token }));
if (!client) {
return;
}
const getProducts = async (channelSlug: string, cursor: string): Promise<void> => {
const response = await client
.query(
ProductsDataForImportDocument,
{
after: cursor,
first: PER_PAGE,
channel: channelSlug!,
},
{
requestPolicy: "network-only", // Invalidate products data, because it could contain legacy products variants metadata that indicates these products variants existance in CMS providers
}
)
.toPromise();
const newProducts = response?.data?.products?.edges.map((e) => e.node) ?? [];
if (newProducts.length > 0) {
setProducts((ps) => [...ps, ...newProducts]);
}
if (
response?.data?.products?.pageInfo.hasNextPage &&
response?.data?.products?.pageInfo.endCursor
) {
// get next page of products
return getProducts(channelSlug, response.data.products?.pageInfo.endCursor);
} else {
setFetchCompleted(true);
}
};
(async () => {
await getProducts(channelSlug, "");
})();
}, [appBridgeState?.token, saleorApiUrl, paused, channelSlug]);
return {
products,
fetchCompleted,
};
};

View file

@ -0,0 +1,169 @@
import { createProtectedHandler, NextProtectedApiHandler } from "@saleor/app-sdk/handlers/next";
import { NextApiRequest, NextApiResponse } from "next";
import { WebhookProductVariantFragment } from "../../../generated/graphql";
import { saleorApp } from "../../../saleor-app";
import { executeCmsClientBatchOperation } from "../../lib/cms/client/clients-execution";
import { getChannelsSettings, getProviderInstancesSettings } from "../../lib/cms/client/settings";
import { providersSchemaSet } from "../../lib/cms/config/providers";
import { cmsProviders, CMSProvider } from "../../lib/cms/providers";
import { logger as pinoLogger } from "../../lib/logger";
import { createClient } from "../../lib/graphql";
import { createSettingsManager } from "../../lib/metadata";
import { batchUpdateMetadata, MetadataRecord } from "../../lib/cms/client/metadata-execution";
import { CmsBatchOperations } from "../../lib/cms/types";
export interface SyncProductsVariantsApiPayload {
channelSlug: string;
providerInstanceId: string;
productsVariants: WebhookProductVariantFragment[];
operation: "ADD" | "DELETE";
}
export interface SyncProductsVariantsApiResponse {
success: boolean;
data?: {
createdCMSIds: MetadataRecord[];
deletedCMSIds: MetadataRecord[];
};
error?: string;
}
const handler: NextProtectedApiHandler = async (
req: NextApiRequest,
res: NextApiResponse<SyncProductsVariantsApiResponse>,
context
) => {
const { authData } = context;
const logger = pinoLogger.child({
endpoint: "sync-products-variants",
});
logger.debug("Called endpoint sync-products-variants");
const client = createClient(authData.saleorApiUrl, async () => ({
token: authData.token,
}));
if (req.method !== "POST") {
return res.status(405).json({
success: false,
});
}
// todo: change to zod validation
const { providerInstanceId, productsVariants, operation } =
req.body as SyncProductsVariantsApiPayload;
if (!providerInstanceId) {
return res.status(400).json({
success: false,
error: "The provider instance id is missing.",
});
}
if (!productsVariants || productsVariants?.length === 0) {
return res.status(400).json({
success: false,
error: "The products variants are missing.",
});
}
if (!operation || (operation !== "ADD" && operation !== "DELETE")) {
return res.status(400).json({
success: false,
error: "The operation is missing or invalid. Allowed operations: ADD, DELETE.",
});
}
const operationType: keyof CmsBatchOperations =
operation === "ADD" ? "createBatchProducts" : "deleteBatchProducts";
const settingsManager = createSettingsManager(client);
const [providerInstancesSettingsParsed, channelsSettingsParsed] = await Promise.all([
getProviderInstancesSettings(settingsManager),
getChannelsSettings(settingsManager),
]);
const providerInstanceSettings = providerInstancesSettingsParsed[providerInstanceId];
const provider = cmsProviders[
providerInstanceSettings.providerName as CMSProvider
] as (typeof cmsProviders)[keyof typeof cmsProviders];
const validation =
providersSchemaSet[providerInstanceSettings.providerName as CMSProvider].safeParse(
providerInstanceSettings
);
logger.debug({ provider }, "The provider instance settings provider.");
if (!validation.success) {
// todo: use instead: throw new Error(validation.error.message);
// continue with other provider instances
logger.error(
{ error: validation.error.message },
"The provider instance settings validation failed."
);
return res.status(400).json({
success: false,
});
}
const config = validation.data;
logger.debug({ config }, "The provider instance settings validated config.");
const enabledChannelsForSelectedProviderInstance = Object.entries(channelsSettingsParsed).reduce(
(enabledChannels, [channelSlug, channelSettingsParsed]) => {
if (channelSettingsParsed.enabledProviderInstances.includes(providerInstanceId)) {
return [...enabledChannels, channelSlug];
}
return enabledChannels;
},
[] as string[]
);
// todo: make it later a method of kinda ChannelsSettingsRepository instantiated only once
const verifyIfProductVariantIsAvailableInOtherChannelEnabledForSelectedProviderInstance = (
productVariant: WebhookProductVariantFragment
) => {
const variantAvailableChannels = productVariant.channelListings?.map((cl) => cl.channel.slug);
const isAvailable = variantAvailableChannels?.some((channel) =>
enabledChannelsForSelectedProviderInstance.includes(channel)
);
return !!isAvailable;
};
const syncResult = await executeCmsClientBatchOperation({
cmsClient: {
cmsProviderInstanceId: providerInstanceId,
operationType,
operations: provider.create(config as any),
},
productsVariants,
verifyIfProductVariantIsAvailableInOtherChannelEnabledForSelectedProviderInstance,
});
await batchUpdateMetadata({
context,
variantCMSProviderInstanceIdsToCreate:
syncResult?.createdCmsIds?.map((cmsId) => ({
id: cmsId.saleorId,
cmsProviderInstanceIds: { [providerInstanceId]: cmsId.id },
})) || [],
variantCMSProviderInstanceIdsToDelete:
syncResult?.deletedCmsIds?.map((cmsId) => ({
id: cmsId.saleorId,
cmsProviderInstanceIds: { [providerInstanceId]: cmsId.id },
})) || [],
});
return res.status(200).json({
success: true,
data: {
createdCMSIds: syncResult?.createdCmsIds || [],
deletedCMSIds: syncResult?.deletedCmsIds || [],
},
error: syncResult?.error,
});
};
export default createProtectedHandler(handler, saleorApp.apl, ["MANAGE_APPS"]);

View file

@ -7,11 +7,7 @@ import {
import { saleorApp } from "../../../../saleor-app";
import { getCmsKeysFromSaleorItem } from "../../../lib/cms/client/metadata";
import { getChannelsSlugsFromSaleorItem } from "../../../lib/cms/client/channels";
import {
createCmsOperations,
executeCmsOperations,
executeMetadataUpdate,
} from "../../../lib/cms/client";
import { createCmsOperations, executeCmsOperations, updateMetadata } from "../../../lib/cms/client";
import { logger as pinoLogger } from "../../../lib/logger";
import { createClient } from "../../../lib/graphql";
import { fetchProductVariantMetadata } from "../../../lib/metadata";
@ -109,7 +105,7 @@ export const handler: NextWebhookApiHandler<ProductUpdatedWebhookPayloadFragment
allCMSErrors.push(...cmsErrors);
await executeMetadataUpdate({
await updateMetadata({
context,
productVariant,
cmsProviderInstanceIdsToCreate: cmsProviderInstanceProductVariantIdsToCreate,
@ -120,7 +116,8 @@ export const handler: NextWebhookApiHandler<ProductUpdatedWebhookPayloadFragment
if (!allCMSErrors.length) {
return res.status(200).end();
} else {
return res.status(500).json({ errors: allCMSErrors });
// Due to undesired webhook events deliveries retries on HTTP 500, we need to return 200 status code instead of 500.
return res.status(200).json({ errors: allCMSErrors });
}
};

View file

@ -6,11 +6,7 @@ import {
} from "../../../../generated/graphql";
import { saleorApp } from "../../../../saleor-app";
import { getChannelsSlugsFromSaleorItem } from "../../../lib/cms/client/channels";
import {
createCmsOperations,
executeCmsOperations,
executeMetadataUpdate,
} from "../../../lib/cms/client";
import { createCmsOperations, executeCmsOperations, updateMetadata } from "../../../lib/cms/client";
import { logger as pinoLogger } from "../../../lib/logger";
import { createClient } from "../../../lib/graphql";
import { fetchProductVariantMetadata } from "../../../lib/metadata";
@ -93,7 +89,7 @@ export const handler: NextWebhookApiHandler<ProductVariantCreatedWebhookPayloadF
productVariant,
});
await executeMetadataUpdate({
await updateMetadata({
context,
productVariant,
cmsProviderInstanceIdsToCreate: cmsProviderInstanceProductVariantIdsToCreate,
@ -103,7 +99,8 @@ export const handler: NextWebhookApiHandler<ProductVariantCreatedWebhookPayloadF
if (!cmsErrors.length) {
return res.status(200).end();
} else {
return res.status(500).json({ errors: cmsErrors });
// Due to undesired webhook events deliveries retries on HTTP 500, we need to return 200 status code instead of 500.
return res.status(200).json({ errors: cmsErrors });
}
};

View file

@ -6,11 +6,7 @@ import {
} from "../../../../generated/graphql";
import { saleorApp } from "../../../../saleor-app";
import { getCmsKeysFromSaleorItem } from "../../../lib/cms/client/metadata";
import {
createCmsOperations,
executeCmsOperations,
executeMetadataUpdate,
} from "../../../lib/cms/client";
import { createCmsOperations, executeCmsOperations, updateMetadata } from "../../../lib/cms/client";
import { logger as pinoLogger } from "../../../lib/logger";
import { createClient } from "../../../lib/graphql";
@ -89,7 +85,7 @@ export const handler: NextWebhookApiHandler<ProductVariantDeletedWebhookPayloadF
productVariant,
});
await executeMetadataUpdate({
await updateMetadata({
context,
productVariant,
cmsProviderInstanceIdsToCreate: cmsProviderInstanceProductVariantIdsToCreate,
@ -99,7 +95,8 @@ export const handler: NextWebhookApiHandler<ProductVariantDeletedWebhookPayloadF
if (!cmsErrors.length) {
return res.status(200).end();
} else {
return res.status(500).json({ errors: cmsErrors });
// Due to undesired webhook events deliveries retries on HTTP 500, we need to return 200 status code instead of 500.
return res.status(200).json({ errors: cmsErrors });
}
};

View file

@ -7,11 +7,7 @@ import {
import { saleorApp } from "../../../../saleor-app";
import { getCmsKeysFromSaleorItem } from "../../../lib/cms/client/metadata";
import { getChannelsSlugsFromSaleorItem } from "../../../lib/cms/client/channels";
import {
createCmsOperations,
executeCmsOperations,
executeMetadataUpdate,
} from "../../../lib/cms/client";
import { createCmsOperations, executeCmsOperations, updateMetadata } from "../../../lib/cms/client";
import { logger as pinoLogger } from "../../../lib/logger";
import { createClient } from "../../../lib/graphql";
import { fetchProductVariantMetadata } from "../../../lib/metadata";
@ -93,7 +89,7 @@ export const handler: NextWebhookApiHandler<ProductVariantUpdatedWebhookPayloadF
productVariant,
});
await executeMetadataUpdate({
await updateMetadata({
context,
productVariant,
cmsProviderInstanceIdsToCreate: cmsProviderInstanceProductVariantIdsToCreate,
@ -103,7 +99,8 @@ export const handler: NextWebhookApiHandler<ProductVariantUpdatedWebhookPayloadF
if (!cmsErrors.length) {
return res.status(200).end();
} else {
return res.status(500).json({ errors: cmsErrors });
// Due to undesired webhook events deliveries retries on HTTP 500, we need to return 200 status code instead of 500.
return res.status(200).json({ errors: cmsErrors });
}
};