Product Feed: Add attribute mapping (#838)

* Add attribute mapping

* Improve release note

* Log the error

* Add pattern attribute
This commit is contained in:
Krzysztof Wolski 2023-08-03 10:27:14 +02:00 committed by GitHub
parent 8e33f59e0c
commit aece07338e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 804 additions and 1 deletions

View 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.

View file

@ -0,0 +1,5 @@
fragment AttributeWithMappingFragment on Attribute {
id
name
slug
}

View file

@ -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
}

View file

@ -0,0 +1,13 @@
query FetchAttributesWithMapping($cursor: String){
attributes(first: 100, after: $cursor){
pageInfo{
hasNextPage
endCursor
}
edges{
node{
...AttributeWithMappingFragment
}
}
}
}

View file

@ -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", () => {

View file

@ -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;
}
}

View file

@ -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;
}),
});

View file

@ -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);
}
}

View file

@ -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>
)}
</>
);
};

View file

@ -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",
});
});
});
});

View file

@ -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,
};
};

View file

@ -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,
});
});

View file

@ -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,
});
});
});

View file

@ -84,6 +84,7 @@ export class GoogleFeedSettingsFetcher {
storefrontUrl,
productStorefrontUrl,
s3BucketConfiguration: appConfig.getS3Config(),
attributeMapping: appConfig.getAttributeMapping(),
};
}
}

View file

@ -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,
};

View file

@ -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 = {

View file

@ -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");

View file

@ -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 &quot;Primary color&quot; and &quot;Secondary
color&quot;, 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>
);
};