Product Feed: Add attribute mapping (#838)
* Add attribute mapping * Improve release note * Log the error * Add pattern attribute
This commit is contained in:
parent
8e33f59e0c
commit
aece07338e
18 changed files with 804 additions and 1 deletions
5
.changeset/nine-dryers-wink.md
Normal file
5
.changeset/nine-dryers-wink.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"saleor-app-products-feed": minor
|
||||
---
|
||||
|
||||
Added configuration for choosing which product attributes should be used for generating Google Product Feed. Supported feed attributes: Brand, Color, Size, Material, Pattern.
|
|
@ -0,0 +1,5 @@
|
|||
fragment AttributeWithMappingFragment on Attribute {
|
||||
id
|
||||
name
|
||||
slug
|
||||
}
|
|
@ -11,12 +11,30 @@ fragment GoogleFeedProductVariant on ProductVariant {
|
|||
}
|
||||
}
|
||||
quantityAvailable
|
||||
attributes {
|
||||
attribute {
|
||||
id
|
||||
}
|
||||
values {
|
||||
value
|
||||
name
|
||||
}
|
||||
}
|
||||
product {
|
||||
id
|
||||
name
|
||||
slug
|
||||
description
|
||||
seoDescription
|
||||
attributes{
|
||||
attribute{
|
||||
id
|
||||
}
|
||||
values{
|
||||
value
|
||||
name
|
||||
}
|
||||
}
|
||||
thumbnail {
|
||||
url
|
||||
}
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
query FetchAttributesWithMapping($cursor: String){
|
||||
attributes(first: 100, after: $cursor){
|
||||
pageInfo{
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
edges{
|
||||
node{
|
||||
...AttributeWithMappingFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -6,7 +6,11 @@ describe("AppConfig", function () {
|
|||
it("Constructs empty state", () => {
|
||||
const instance = new AppConfig();
|
||||
|
||||
expect(instance.getRootConfig()).toEqual({ channelConfig: {}, s3: null });
|
||||
expect(instance.getRootConfig()).toEqual({
|
||||
channelConfig: {},
|
||||
s3: null,
|
||||
attributeMapping: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("Constructs from initial state", () => {
|
||||
|
@ -25,6 +29,13 @@ describe("AppConfig", function () {
|
|||
},
|
||||
},
|
||||
},
|
||||
attributeMapping: {
|
||||
brandAttributeIds: [],
|
||||
colorAttributeIds: [],
|
||||
patternAttributeIds: [],
|
||||
materialAttributeIds: [],
|
||||
sizeAttributeIds: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(instance.getRootConfig()).toEqual({
|
||||
|
@ -42,6 +53,13 @@ describe("AppConfig", function () {
|
|||
},
|
||||
},
|
||||
},
|
||||
attributeMapping: {
|
||||
brandAttributeIds: [],
|
||||
colorAttributeIds: [],
|
||||
patternAttributeIds: [],
|
||||
materialAttributeIds: [],
|
||||
sizeAttributeIds: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -64,6 +82,13 @@ describe("AppConfig", function () {
|
|||
secretAccessKey: "secret",
|
||||
},
|
||||
channelConfig: {},
|
||||
attributeMapping: {
|
||||
brandAttributeIds: [],
|
||||
colorAttributeIds: [],
|
||||
patternAttributeIds: [],
|
||||
materialAttributeIds: [],
|
||||
sizeAttributeIds: [],
|
||||
},
|
||||
});
|
||||
|
||||
const serialized = instance1.serialize();
|
||||
|
@ -78,6 +103,13 @@ describe("AppConfig", function () {
|
|||
secretAccessKey: "secret",
|
||||
},
|
||||
channelConfig: {},
|
||||
attributeMapping: {
|
||||
brandAttributeIds: [],
|
||||
colorAttributeIds: [],
|
||||
patternAttributeIds: [],
|
||||
materialAttributeIds: [],
|
||||
sizeAttributeIds: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -98,6 +130,13 @@ describe("AppConfig", function () {
|
|||
},
|
||||
},
|
||||
},
|
||||
attributeMapping: {
|
||||
brandAttributeIds: [],
|
||||
colorAttributeIds: [],
|
||||
patternAttributeIds: [],
|
||||
materialAttributeIds: [],
|
||||
sizeAttributeIds: ["size-id"],
|
||||
},
|
||||
});
|
||||
|
||||
it("getRootConfig returns root config data", () => {
|
||||
|
@ -116,6 +155,13 @@ describe("AppConfig", function () {
|
|||
},
|
||||
},
|
||||
},
|
||||
attributeMapping: {
|
||||
brandAttributeIds: [],
|
||||
colorAttributeIds: [],
|
||||
patternAttributeIds: [],
|
||||
materialAttributeIds: [],
|
||||
sizeAttributeIds: ["size-id"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -136,6 +182,16 @@ describe("AppConfig", function () {
|
|||
secretAccessKey: "secret",
|
||||
});
|
||||
});
|
||||
|
||||
it("getAttributeMapping gets attribute data", () => {
|
||||
expect(instance.getAttributeMapping()).toEqual({
|
||||
brandAttributeIds: [],
|
||||
colorAttributeIds: [],
|
||||
patternAttributeIds: [],
|
||||
materialAttributeIds: [],
|
||||
sizeAttributeIds: ["size-id"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("setters", () => {
|
||||
|
|
|
@ -1,5 +1,13 @@
|
|||
import { z } from "zod";
|
||||
|
||||
const attributeMappingSchema = z.object({
|
||||
brandAttributeIds: z.array(z.string()),
|
||||
colorAttributeIds: z.array(z.string()),
|
||||
sizeAttributeIds: z.array(z.string()),
|
||||
materialAttributeIds: z.array(z.string()),
|
||||
patternAttributeIds: z.array(z.string()),
|
||||
});
|
||||
|
||||
const s3ConfigSchema = z.object({
|
||||
bucketName: z.string().min(1),
|
||||
secretAccessKey: z.string().min(1),
|
||||
|
@ -14,6 +22,7 @@ const urlConfigurationSchema = z.object({
|
|||
|
||||
const rootAppConfigSchema = z.object({
|
||||
s3: s3ConfigSchema.nullable(),
|
||||
attributeMapping: attributeMappingSchema.nullable(),
|
||||
channelConfig: z.record(z.object({ storefrontUrls: urlConfigurationSchema })),
|
||||
});
|
||||
|
||||
|
@ -21,6 +30,7 @@ export const AppConfigSchema = {
|
|||
root: rootAppConfigSchema,
|
||||
s3Bucket: s3ConfigSchema,
|
||||
channelUrls: urlConfigurationSchema,
|
||||
attributeMapping: attributeMappingSchema,
|
||||
};
|
||||
|
||||
export type RootConfig = z.infer<typeof rootAppConfigSchema>;
|
||||
|
@ -31,6 +41,7 @@ export class AppConfig {
|
|||
private rootData: RootConfig = {
|
||||
channelConfig: {},
|
||||
s3: null,
|
||||
attributeMapping: null,
|
||||
};
|
||||
|
||||
constructor(initialData?: RootConfig) {
|
||||
|
@ -63,6 +74,18 @@ export class AppConfig {
|
|||
}
|
||||
}
|
||||
|
||||
setAttributeMapping(attributeMapping: z.infer<typeof attributeMappingSchema>) {
|
||||
try {
|
||||
this.rootData.attributeMapping = attributeMappingSchema.parse(attributeMapping);
|
||||
|
||||
return this;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
||||
throw new Error("Invalid mapping config provided");
|
||||
}
|
||||
}
|
||||
|
||||
setChannelUrls(channelSlug: string, urlsConfig: z.infer<typeof urlConfigurationSchema>) {
|
||||
try {
|
||||
const parsedConfig = urlConfigurationSchema.parse(urlsConfig);
|
||||
|
@ -88,4 +111,8 @@ export class AppConfig {
|
|||
getS3Config() {
|
||||
return this.rootData.s3;
|
||||
}
|
||||
|
||||
getAttributeMapping() {
|
||||
return this.rootData.attributeMapping;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import { z } from "zod";
|
|||
import { createS3ClientFromConfiguration } from "../file-storage/s3/create-s3-client-from-configuration";
|
||||
import { checkBucketAccess } from "../file-storage/s3/check-bucket-access";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { AttributeFetcher } from "./attribute-fetcher";
|
||||
|
||||
export const appConfigurationRouter = router({
|
||||
/**
|
||||
|
@ -116,4 +117,39 @@ export const appConfigurationRouter = router({
|
|||
return null;
|
||||
}
|
||||
),
|
||||
setAttributeMapping: protectedClientProcedure
|
||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||
.input(AppConfigSchema.attributeMapping)
|
||||
.mutation(
|
||||
async ({
|
||||
ctx: { getConfig, apiClient, saleorApiUrl, appConfigMetadataManager, logger },
|
||||
input,
|
||||
}) => {
|
||||
const config = await getConfig();
|
||||
|
||||
config.setAttributeMapping(input);
|
||||
|
||||
await appConfigMetadataManager.set(config.serialize());
|
||||
|
||||
return null;
|
||||
}
|
||||
),
|
||||
|
||||
getAttributes: protectedClientProcedure
|
||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||
.query(async ({ ctx: { logger, apiClient } }) => {
|
||||
const fetcher = new AttributeFetcher(apiClient);
|
||||
|
||||
const result = await fetcher.fetchAllAttributes().catch((e) => {
|
||||
logger.error(e, "Can't fetch the attributes");
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Can't fetch the attributes",
|
||||
});
|
||||
});
|
||||
|
||||
logger.debug("Returning attributes");
|
||||
|
||||
return result;
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
import { Client } from "urql";
|
||||
import {
|
||||
AttributeWithMappingFragmentFragment,
|
||||
FetchAttributesWithMappingDocument,
|
||||
} from "../../../generated/graphql";
|
||||
|
||||
export class AttributeFetcher {
|
||||
constructor(private apiClient: Pick<Client, "query">) {}
|
||||
|
||||
private async fetchRecursivePage(
|
||||
accumulator: AttributeWithMappingFragmentFragment[],
|
||||
cursor?: string
|
||||
): Promise<AttributeWithMappingFragmentFragment[]> {
|
||||
const result = await this.apiClient
|
||||
.query(FetchAttributesWithMappingDocument, {
|
||||
cursor,
|
||||
})
|
||||
.toPromise();
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error.message);
|
||||
}
|
||||
|
||||
if (!result.data) {
|
||||
// todo sentry
|
||||
throw new Error("Empty attributes data");
|
||||
}
|
||||
|
||||
accumulator = [...accumulator, ...(result.data.attributes?.edges.map((c) => c.node) ?? [])];
|
||||
|
||||
const hasNextPage = result.data.attributes?.pageInfo.hasNextPage;
|
||||
const endCursor = result.data.attributes?.pageInfo.endCursor;
|
||||
|
||||
if (hasNextPage && endCursor) {
|
||||
return this.fetchRecursivePage(accumulator, endCursor);
|
||||
} else {
|
||||
return accumulator;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all attribute pages - standard page is max 100 items
|
||||
*/
|
||||
async fetchAllAttributes(): Promise<AttributeWithMappingFragmentFragment[]> {
|
||||
let attributes: AttributeWithMappingFragmentFragment[] = [];
|
||||
|
||||
return this.fetchRecursivePage(attributes, undefined);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,137 @@
|
|||
import { AppConfigSchema, RootConfig } from "./app-config";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
||||
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { Multiselect } from "@saleor/react-hook-form-macaw";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { trpcClient } from "../trpc/trpc-client";
|
||||
import { useDashboardNotification } from "@saleor/apps-shared";
|
||||
import { AttributeWithMappingFragmentFragment } from "../../../generated/graphql";
|
||||
|
||||
type AttributeMappingConfiguration = Exclude<RootConfig["attributeMapping"], null>;
|
||||
|
||||
type Props = {
|
||||
initialData: AttributeMappingConfiguration;
|
||||
attributes: AttributeWithMappingFragmentFragment[];
|
||||
onSubmit(data: AttributeMappingConfiguration): Promise<void>;
|
||||
};
|
||||
|
||||
export const AttributeMappingConfigurationForm = (props: Props) => {
|
||||
const { handleSubmit, control } = useForm<AttributeMappingConfiguration>({
|
||||
defaultValues: props.initialData,
|
||||
resolver: zodResolver(AppConfigSchema.attributeMapping),
|
||||
});
|
||||
|
||||
const options = props.attributes.map((a) => ({ value: a.id, label: a.name || a.id })) || [];
|
||||
|
||||
return (
|
||||
<Box
|
||||
as={"form"}
|
||||
display={"flex"}
|
||||
gap={5}
|
||||
flexDirection={"column"}
|
||||
onSubmit={handleSubmit((data) => {
|
||||
props.onSubmit(data);
|
||||
})}
|
||||
>
|
||||
<Multiselect
|
||||
control={control}
|
||||
name="brandAttributeIds"
|
||||
label="Brand attributes"
|
||||
options={options}
|
||||
/>
|
||||
<Multiselect
|
||||
control={control}
|
||||
name="colorAttributeIds"
|
||||
label="Color attributes"
|
||||
options={options}
|
||||
/>
|
||||
<Multiselect
|
||||
control={control}
|
||||
name="materialAttributeIds"
|
||||
label="Material attributes"
|
||||
options={options}
|
||||
/>
|
||||
<Multiselect
|
||||
control={control}
|
||||
name="patternAttributeIds"
|
||||
label="Pattern attributes"
|
||||
options={options}
|
||||
/>
|
||||
<Multiselect
|
||||
control={control}
|
||||
name="sizeAttributeIds"
|
||||
label="Size attributes"
|
||||
options={options}
|
||||
/>
|
||||
<Box display={"flex"} flexDirection={"row"} gap={4} justifyContent={"flex-end"}>
|
||||
<Button type="submit" variant="primary">
|
||||
Save mapping
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const ConnectedAttributeMappingForm = () => {
|
||||
const { notifyError, notifySuccess } = useDashboardNotification();
|
||||
|
||||
const { data: attributes, isLoading: isAttributesLoading } =
|
||||
trpcClient.appConfiguration.getAttributes.useQuery();
|
||||
|
||||
const { data, isLoading: isConfigurationLoading } = trpcClient.appConfiguration.fetch.useQuery();
|
||||
|
||||
const isLoading = isAttributesLoading || isConfigurationLoading;
|
||||
|
||||
const { mutate } = trpcClient.appConfiguration.setAttributeMapping.useMutation({
|
||||
onSuccess() {
|
||||
notifySuccess("Success", "Updated attribute mapping");
|
||||
},
|
||||
onError() {
|
||||
notifyError("Error", "Failed to update, please refresh and try again");
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (data: AttributeMappingConfiguration) => {
|
||||
mutate(data);
|
||||
},
|
||||
[mutate]
|
||||
);
|
||||
|
||||
const formData: AttributeMappingConfiguration = useMemo(() => {
|
||||
if (data?.attributeMapping) {
|
||||
return data.attributeMapping;
|
||||
}
|
||||
|
||||
return {
|
||||
colorAttributeIds: [],
|
||||
sizeAttributeIds: [],
|
||||
brandAttributeIds: [],
|
||||
patternAttributeIds: [],
|
||||
materialAttributeIds: [],
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Text>Loading...</Text>;
|
||||
}
|
||||
|
||||
const showForm = !isLoading && attributes?.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showForm ? (
|
||||
<AttributeMappingConfigurationForm
|
||||
onSubmit={handleSubmit}
|
||||
initialData={formData}
|
||||
attributes={attributes}
|
||||
/>
|
||||
) : (
|
||||
<Box>Loading</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,268 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { GoogleFeedProductVariantFragment } from "../../../generated/graphql";
|
||||
import { attributeArrayToValueString, getMappedAttributes } from "./attribute-mapping";
|
||||
|
||||
const productBase: GoogleFeedProductVariantFragment["product"] = {
|
||||
name: "Product",
|
||||
__typename: "Product",
|
||||
id: "product-id",
|
||||
category: {
|
||||
id: "cat-id",
|
||||
__typename: "Category",
|
||||
name: "Category Name",
|
||||
googleCategoryId: "1",
|
||||
},
|
||||
description: "Product description",
|
||||
seoDescription: "Seo description",
|
||||
slug: "product-slug",
|
||||
thumbnail: { __typename: "Image", url: "" },
|
||||
attributes: [
|
||||
{
|
||||
attribute: {
|
||||
id: "main-color",
|
||||
},
|
||||
values: [{ name: "Black" }],
|
||||
},
|
||||
{
|
||||
attribute: {
|
||||
id: "accent-color",
|
||||
},
|
||||
values: [{ name: "Red" }],
|
||||
},
|
||||
{
|
||||
attribute: {
|
||||
id: "size",
|
||||
},
|
||||
values: [{ name: "XL" }],
|
||||
},
|
||||
{
|
||||
attribute: {
|
||||
id: "pattern",
|
||||
},
|
||||
values: [{ name: "plain" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const priceBase: GoogleFeedProductVariantFragment["pricing"] = {
|
||||
__typename: "VariantPricingInfo",
|
||||
price: {
|
||||
__typename: "TaxedMoney",
|
||||
gross: {
|
||||
__typename: "Money",
|
||||
amount: 1,
|
||||
currency: "USD",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("attribute-mapping", () => {
|
||||
describe("attributeArrayToValueString", () => {
|
||||
it("Return undefined, when no attributes", () => {
|
||||
expect(attributeArrayToValueString([])).toStrictEqual(undefined);
|
||||
});
|
||||
|
||||
it("Return value, when attribute have value assigned", () => {
|
||||
expect(
|
||||
attributeArrayToValueString([
|
||||
{
|
||||
attribute: {
|
||||
id: "1",
|
||||
},
|
||||
values: [
|
||||
{
|
||||
name: "Red",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
attribute: {
|
||||
id: "2",
|
||||
},
|
||||
values: [],
|
||||
},
|
||||
])
|
||||
).toStrictEqual("Red");
|
||||
});
|
||||
|
||||
it("Return all values, when attribute have multiple value assigned", () => {
|
||||
expect(
|
||||
attributeArrayToValueString([
|
||||
{
|
||||
attribute: {
|
||||
id: "1",
|
||||
},
|
||||
values: [
|
||||
{
|
||||
name: "Red",
|
||||
},
|
||||
{
|
||||
name: "Blue",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
attribute: {
|
||||
id: "2",
|
||||
},
|
||||
values: [
|
||||
{
|
||||
name: "Yellow",
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
).toStrictEqual("Red/Blue/Yellow");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMappedAttributes", () => {
|
||||
it("Return undefined, when no mapping is passed", () => {
|
||||
expect(
|
||||
getMappedAttributes({
|
||||
variant: {
|
||||
id: "id1",
|
||||
__typename: "ProductVariant",
|
||||
sku: "sku1",
|
||||
quantityAvailable: 1,
|
||||
pricing: priceBase,
|
||||
name: "Product variant",
|
||||
product: productBase,
|
||||
attributes: [],
|
||||
},
|
||||
})
|
||||
).toStrictEqual(undefined);
|
||||
});
|
||||
|
||||
it("Return empty values, when variant has no related attributes", () => {
|
||||
expect(
|
||||
getMappedAttributes({
|
||||
variant: {
|
||||
id: "id1",
|
||||
__typename: "ProductVariant",
|
||||
sku: "sku1",
|
||||
quantityAvailable: 1,
|
||||
pricing: priceBase,
|
||||
name: "Product variant",
|
||||
product: productBase,
|
||||
attributes: [],
|
||||
},
|
||||
attributeMapping: {
|
||||
brandAttributeIds: ["brand-id"],
|
||||
colorAttributeIds: ["color-id"],
|
||||
patternAttributeIds: ["pattern-id"],
|
||||
materialAttributeIds: ["material-id"],
|
||||
sizeAttributeIds: ["size-id"],
|
||||
},
|
||||
})
|
||||
).toStrictEqual({
|
||||
material: undefined,
|
||||
color: undefined,
|
||||
size: undefined,
|
||||
brand: undefined,
|
||||
pattern: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("Return attribute values, when variant has attributes used by mapping", () => {
|
||||
expect(
|
||||
getMappedAttributes({
|
||||
variant: {
|
||||
id: "id1",
|
||||
__typename: "ProductVariant",
|
||||
sku: "sku1",
|
||||
quantityAvailable: 1,
|
||||
pricing: priceBase,
|
||||
name: "Product variant",
|
||||
product: productBase,
|
||||
attributes: [
|
||||
{
|
||||
attribute: {
|
||||
id: "should be ignored",
|
||||
},
|
||||
values: [
|
||||
{
|
||||
name: "ignored",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
attribute: {
|
||||
id: "brand-id",
|
||||
},
|
||||
values: [
|
||||
{
|
||||
name: "Saleor",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
attribute: {
|
||||
id: "size-id",
|
||||
},
|
||||
values: [
|
||||
{
|
||||
name: "XL",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
attribute: {
|
||||
id: "color-base-id",
|
||||
},
|
||||
values: [
|
||||
{
|
||||
name: "Red",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
attribute: {
|
||||
id: "color-secondary-id",
|
||||
},
|
||||
values: [
|
||||
{
|
||||
name: "Black",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
attribute: {
|
||||
id: "material-id",
|
||||
},
|
||||
values: [
|
||||
{
|
||||
name: "Cotton",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
attribute: {
|
||||
id: "pattern-id",
|
||||
},
|
||||
values: [
|
||||
{
|
||||
name: "Plain",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
attributeMapping: {
|
||||
brandAttributeIds: ["brand-id"],
|
||||
colorAttributeIds: ["color-base-id", "color-secondary-id"],
|
||||
materialAttributeIds: ["material-id"],
|
||||
sizeAttributeIds: ["size-id"],
|
||||
patternAttributeIds: ["pattern-id"],
|
||||
},
|
||||
})
|
||||
).toStrictEqual({
|
||||
material: "Cotton",
|
||||
color: "Red/Black",
|
||||
size: "XL",
|
||||
brand: "Saleor",
|
||||
pattern: "Plain",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,69 @@
|
|||
import { GoogleFeedProductVariantFragment } from "../../../generated/graphql";
|
||||
import { RootConfig } from "../app-configuration/app-config";
|
||||
|
||||
interface GetMappedAttributesArgs {
|
||||
variant: GoogleFeedProductVariantFragment;
|
||||
attributeMapping?: RootConfig["attributeMapping"];
|
||||
}
|
||||
|
||||
export const attributeArrayToValueString = (
|
||||
attributes?: GoogleFeedProductVariantFragment["attributes"]
|
||||
) => {
|
||||
if (!attributes?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
return attributes
|
||||
.map((a) => a.values)
|
||||
.flat() // Multiple values can be assigned to the attribute
|
||||
.map((v) => v.name) // get value to display
|
||||
.filter((v) => !!v) // filter out empty values
|
||||
.join("/"); // Format of multi value attribute recommended by Google
|
||||
};
|
||||
|
||||
export const getMappedAttributes = ({
|
||||
variant,
|
||||
attributeMapping: mapping,
|
||||
}: GetMappedAttributesArgs) => {
|
||||
/*
|
||||
* We have to take in account both product and variant attributes since we use flat
|
||||
* model in the feed
|
||||
*/
|
||||
if (!mapping) {
|
||||
return;
|
||||
}
|
||||
const attributes = variant.attributes.concat(variant.product.attributes);
|
||||
|
||||
const materialAttributes = attributes.filter((a) =>
|
||||
mapping.materialAttributeIds.includes(a.attribute.id)
|
||||
);
|
||||
const materialValue = attributeArrayToValueString(materialAttributes);
|
||||
|
||||
const brandAttributes = attributes.filter((a) =>
|
||||
mapping.brandAttributeIds.includes(a.attribute.id)
|
||||
);
|
||||
const brandValue = attributeArrayToValueString(brandAttributes);
|
||||
|
||||
const colorAttributes = attributes.filter((a) =>
|
||||
mapping.colorAttributeIds.includes(a.attribute.id)
|
||||
);
|
||||
const colorValue = attributeArrayToValueString(colorAttributes);
|
||||
|
||||
const patternAttributes = attributes.filter((a) =>
|
||||
mapping.patternAttributeIds.includes(a.attribute.id)
|
||||
);
|
||||
const patternValue = attributeArrayToValueString(patternAttributes);
|
||||
|
||||
const sizeAttributes = attributes.filter((a) =>
|
||||
mapping.sizeAttributeIds.includes(a.attribute.id)
|
||||
);
|
||||
const sizeValue = attributeArrayToValueString(sizeAttributes);
|
||||
|
||||
return {
|
||||
material: materialValue,
|
||||
brand: brandValue,
|
||||
color: colorValue,
|
||||
size: sizeValue,
|
||||
pattern: patternValue,
|
||||
};
|
||||
};
|
|
@ -3,11 +3,14 @@ import { GoogleFeedProductVariantFragment } from "../../../generated/graphql";
|
|||
import { productToProxy } from "./product-to-proxy";
|
||||
import { shopDetailsToProxy } from "./shop-details-to-proxy";
|
||||
import { EditorJsPlaintextRenderer } from "../editor-js/editor-js-plaintext-renderer";
|
||||
import { RootConfig } from "../app-configuration/app-config";
|
||||
import { getMappedAttributes } from "./attribute-mapping";
|
||||
|
||||
interface GenerateGoogleXmlFeedArgs {
|
||||
productVariants: GoogleFeedProductVariantFragment[];
|
||||
storefrontUrl: string;
|
||||
productStorefrontUrl: string;
|
||||
attributeMapping?: RootConfig["attributeMapping"];
|
||||
shopName: string;
|
||||
shopDescription?: string;
|
||||
}
|
||||
|
@ -29,6 +32,7 @@ const formatCurrency = (currency: string, amount: number) => {
|
|||
};
|
||||
|
||||
export const generateGoogleXmlFeed = ({
|
||||
attributeMapping,
|
||||
productVariants,
|
||||
storefrontUrl,
|
||||
productStorefrontUrl,
|
||||
|
@ -36,6 +40,11 @@ export const generateGoogleXmlFeed = ({
|
|||
shopDescription,
|
||||
}: GenerateGoogleXmlFeedArgs) => {
|
||||
const items = productVariants.map((variant) => {
|
||||
const attributes = getMappedAttributes({
|
||||
attributeMapping: attributeMapping,
|
||||
variant,
|
||||
});
|
||||
|
||||
const currency = variant.pricing?.price?.gross.currency;
|
||||
const amount = variant.pricing?.price?.gross.amount;
|
||||
|
||||
|
@ -55,6 +64,11 @@ export const generateGoogleXmlFeed = ({
|
|||
googleProductCategory: variant.product.category?.googleCategoryId || "",
|
||||
price: price,
|
||||
imageUrl: variant.product.thumbnail?.url || "",
|
||||
material: attributes?.material,
|
||||
color: attributes?.color,
|
||||
brand: attributes?.brand,
|
||||
pattern: attributes?.pattern,
|
||||
size: attributes?.size,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -26,6 +26,7 @@ describe("GoogleFeedSettingsFetcher", () => {
|
|||
region: "region",
|
||||
secretAccessKey: "secretAccessKey",
|
||||
},
|
||||
attributeMapping: null,
|
||||
});
|
||||
|
||||
return appConfig.serialize();
|
||||
|
@ -48,6 +49,7 @@ describe("GoogleFeedSettingsFetcher", () => {
|
|||
accessKeyId: "accessKeyId",
|
||||
region: "region",
|
||||
},
|
||||
attributeMapping: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -84,6 +84,7 @@ export class GoogleFeedSettingsFetcher {
|
|||
storefrontUrl,
|
||||
productStorefrontUrl,
|
||||
s3BucketConfiguration: appConfig.getS3Config(),
|
||||
attributeMapping: appConfig.getAttributeMapping(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -104,6 +104,57 @@ export const productToProxy = (p: ProductEntry) => {
|
|||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (p.material) {
|
||||
item.push({
|
||||
"g:material": [
|
||||
{
|
||||
"#text": p.material,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (p.brand) {
|
||||
item.push({
|
||||
"g:brand": [
|
||||
{
|
||||
"#text": p.brand,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (p.color) {
|
||||
item.push({
|
||||
"g:color": [
|
||||
{
|
||||
"#text": p.color,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (p.size) {
|
||||
item.push({
|
||||
"g:size": [
|
||||
{
|
||||
"#text": p.size,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (p.pattern) {
|
||||
item.push({
|
||||
"g:pattern": [
|
||||
{
|
||||
"#text": p.pattern,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
item,
|
||||
};
|
||||
|
|
|
@ -12,6 +12,11 @@ export type ProductEntry = {
|
|||
googleProductCategory?: string;
|
||||
availability: "in_stock" | "out_of_stock" | "preorder" | "backorder";
|
||||
category: string;
|
||||
material?: string;
|
||||
color?: string;
|
||||
size?: string;
|
||||
brand?: string;
|
||||
pattern?: string;
|
||||
};
|
||||
|
||||
export type ShopDetailsEntry = {
|
||||
|
|
|
@ -80,6 +80,7 @@ export const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||
let storefrontUrl: string;
|
||||
let productStorefrontUrl: string;
|
||||
let bucketConfiguration: RootConfig["s3"] | undefined;
|
||||
let attributeMapping: RootConfig["attributeMapping"] | undefined;
|
||||
|
||||
try {
|
||||
const settingsFetcher = GoogleFeedSettingsFetcher.createFromAuthData(authData);
|
||||
|
@ -88,6 +89,7 @@ export const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||
storefrontUrl = settings.storefrontUrl;
|
||||
productStorefrontUrl = settings.productStorefrontUrl;
|
||||
bucketConfiguration = settings.s3BucketConfiguration;
|
||||
attributeMapping = settings.attributeMapping;
|
||||
} catch (error) {
|
||||
logger.warn("The application has not been configured");
|
||||
|
||||
|
@ -181,6 +183,7 @@ export const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||
storefrontUrl,
|
||||
productStorefrontUrl,
|
||||
productVariants,
|
||||
attributeMapping,
|
||||
});
|
||||
|
||||
logger.debug("Feed generated. Returning formatted XML");
|
||||
|
|
|
@ -10,6 +10,7 @@ import { ConnectedS3ConfigurationForm } from "../modules/app-configuration/s3-co
|
|||
import { ChannelsConfigAccordion } from "../modules/app-configuration/channels-config-accordion";
|
||||
import { useRouter } from "next/router";
|
||||
import { CategoryMappingPreview } from "../modules/category-mapping/ui/category-mapping-preview";
|
||||
import { ConnectedAttributeMappingForm } from "../modules/app-configuration/attribute-mapping-form";
|
||||
|
||||
const ConfigurationPage: NextPage = () => {
|
||||
useChannelsExistenceChecking();
|
||||
|
@ -145,6 +146,49 @@ const ConfigurationPage: NextPage = () => {
|
|||
</Box>
|
||||
}
|
||||
/>
|
||||
<AppSection
|
||||
data-testid={"attributes-mapping-section"}
|
||||
__marginBottom="100px"
|
||||
includePadding
|
||||
heading={"Attributes mapping"}
|
||||
mainContent={<ConnectedAttributeMappingForm />}
|
||||
sideContent={
|
||||
<Box>
|
||||
<Paragraph size={"small"}>
|
||||
Choose which product attributes should be used for the feed. If product has multiple
|
||||
attribute values, for example "Primary color" and "Secondary
|
||||
color", both values will be used according to Google guidelines:
|
||||
</Paragraph>
|
||||
<ul>
|
||||
<li>
|
||||
<TextLink href="https://support.google.com/merchants/answer/6324351" newTab>
|
||||
Brand
|
||||
</TextLink>
|
||||
</li>
|
||||
<li>
|
||||
<TextLink href="https://support.google.com/merchants/answer/6324487" newTab>
|
||||
Color
|
||||
</TextLink>
|
||||
</li>
|
||||
<li>
|
||||
<TextLink href="https://support.google.com/merchants/answer/6324410" newTab>
|
||||
Material
|
||||
</TextLink>
|
||||
</li>
|
||||
<li>
|
||||
<TextLink href="https://support.google.com/merchants/answer/6324483" newTab>
|
||||
Pattern
|
||||
</TextLink>
|
||||
</li>
|
||||
<li>
|
||||
<TextLink href="https://support.google.com/merchants/answer/6324492" newTab>
|
||||
Size
|
||||
</TextLink>
|
||||
</li>
|
||||
</ul>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue