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
|
quantityAvailable
|
||||||
|
attributes {
|
||||||
|
attribute {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
values {
|
||||||
|
value
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
product {
|
product {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
slug
|
slug
|
||||||
description
|
description
|
||||||
seoDescription
|
seoDescription
|
||||||
|
attributes{
|
||||||
|
attribute{
|
||||||
|
id
|
||||||
|
}
|
||||||
|
values{
|
||||||
|
value
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
thumbnail {
|
thumbnail {
|
||||||
url
|
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", () => {
|
it("Constructs empty state", () => {
|
||||||
const instance = new AppConfig();
|
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", () => {
|
it("Constructs from initial state", () => {
|
||||||
|
@ -25,6 +29,13 @@ describe("AppConfig", function () {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
attributeMapping: {
|
||||||
|
brandAttributeIds: [],
|
||||||
|
colorAttributeIds: [],
|
||||||
|
patternAttributeIds: [],
|
||||||
|
materialAttributeIds: [],
|
||||||
|
sizeAttributeIds: [],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(instance.getRootConfig()).toEqual({
|
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",
|
secretAccessKey: "secret",
|
||||||
},
|
},
|
||||||
channelConfig: {},
|
channelConfig: {},
|
||||||
|
attributeMapping: {
|
||||||
|
brandAttributeIds: [],
|
||||||
|
colorAttributeIds: [],
|
||||||
|
patternAttributeIds: [],
|
||||||
|
materialAttributeIds: [],
|
||||||
|
sizeAttributeIds: [],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const serialized = instance1.serialize();
|
const serialized = instance1.serialize();
|
||||||
|
@ -78,6 +103,13 @@ describe("AppConfig", function () {
|
||||||
secretAccessKey: "secret",
|
secretAccessKey: "secret",
|
||||||
},
|
},
|
||||||
channelConfig: {},
|
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", () => {
|
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",
|
secretAccessKey: "secret",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("getAttributeMapping gets attribute data", () => {
|
||||||
|
expect(instance.getAttributeMapping()).toEqual({
|
||||||
|
brandAttributeIds: [],
|
||||||
|
colorAttributeIds: [],
|
||||||
|
patternAttributeIds: [],
|
||||||
|
materialAttributeIds: [],
|
||||||
|
sizeAttributeIds: ["size-id"],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("setters", () => {
|
describe("setters", () => {
|
||||||
|
|
|
@ -1,5 +1,13 @@
|
||||||
import { z } from "zod";
|
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({
|
const s3ConfigSchema = z.object({
|
||||||
bucketName: z.string().min(1),
|
bucketName: z.string().min(1),
|
||||||
secretAccessKey: z.string().min(1),
|
secretAccessKey: z.string().min(1),
|
||||||
|
@ -14,6 +22,7 @@ const urlConfigurationSchema = z.object({
|
||||||
|
|
||||||
const rootAppConfigSchema = z.object({
|
const rootAppConfigSchema = z.object({
|
||||||
s3: s3ConfigSchema.nullable(),
|
s3: s3ConfigSchema.nullable(),
|
||||||
|
attributeMapping: attributeMappingSchema.nullable(),
|
||||||
channelConfig: z.record(z.object({ storefrontUrls: urlConfigurationSchema })),
|
channelConfig: z.record(z.object({ storefrontUrls: urlConfigurationSchema })),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -21,6 +30,7 @@ export const AppConfigSchema = {
|
||||||
root: rootAppConfigSchema,
|
root: rootAppConfigSchema,
|
||||||
s3Bucket: s3ConfigSchema,
|
s3Bucket: s3ConfigSchema,
|
||||||
channelUrls: urlConfigurationSchema,
|
channelUrls: urlConfigurationSchema,
|
||||||
|
attributeMapping: attributeMappingSchema,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RootConfig = z.infer<typeof rootAppConfigSchema>;
|
export type RootConfig = z.infer<typeof rootAppConfigSchema>;
|
||||||
|
@ -31,6 +41,7 @@ export class AppConfig {
|
||||||
private rootData: RootConfig = {
|
private rootData: RootConfig = {
|
||||||
channelConfig: {},
|
channelConfig: {},
|
||||||
s3: null,
|
s3: null,
|
||||||
|
attributeMapping: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(initialData?: RootConfig) {
|
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>) {
|
setChannelUrls(channelSlug: string, urlsConfig: z.infer<typeof urlConfigurationSchema>) {
|
||||||
try {
|
try {
|
||||||
const parsedConfig = urlConfigurationSchema.parse(urlsConfig);
|
const parsedConfig = urlConfigurationSchema.parse(urlsConfig);
|
||||||
|
@ -88,4 +111,8 @@ export class AppConfig {
|
||||||
getS3Config() {
|
getS3Config() {
|
||||||
return this.rootData.s3;
|
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 { createS3ClientFromConfiguration } from "../file-storage/s3/create-s3-client-from-configuration";
|
||||||
import { checkBucketAccess } from "../file-storage/s3/check-bucket-access";
|
import { checkBucketAccess } from "../file-storage/s3/check-bucket-access";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { AttributeFetcher } from "./attribute-fetcher";
|
||||||
|
|
||||||
export const appConfigurationRouter = router({
|
export const appConfigurationRouter = router({
|
||||||
/**
|
/**
|
||||||
|
@ -116,4 +117,39 @@ export const appConfigurationRouter = router({
|
||||||
return null;
|
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 { productToProxy } from "./product-to-proxy";
|
||||||
import { shopDetailsToProxy } from "./shop-details-to-proxy";
|
import { shopDetailsToProxy } from "./shop-details-to-proxy";
|
||||||
import { EditorJsPlaintextRenderer } from "../editor-js/editor-js-plaintext-renderer";
|
import { EditorJsPlaintextRenderer } from "../editor-js/editor-js-plaintext-renderer";
|
||||||
|
import { RootConfig } from "../app-configuration/app-config";
|
||||||
|
import { getMappedAttributes } from "./attribute-mapping";
|
||||||
|
|
||||||
interface GenerateGoogleXmlFeedArgs {
|
interface GenerateGoogleXmlFeedArgs {
|
||||||
productVariants: GoogleFeedProductVariantFragment[];
|
productVariants: GoogleFeedProductVariantFragment[];
|
||||||
storefrontUrl: string;
|
storefrontUrl: string;
|
||||||
productStorefrontUrl: string;
|
productStorefrontUrl: string;
|
||||||
|
attributeMapping?: RootConfig["attributeMapping"];
|
||||||
shopName: string;
|
shopName: string;
|
||||||
shopDescription?: string;
|
shopDescription?: string;
|
||||||
}
|
}
|
||||||
|
@ -29,6 +32,7 @@ const formatCurrency = (currency: string, amount: number) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const generateGoogleXmlFeed = ({
|
export const generateGoogleXmlFeed = ({
|
||||||
|
attributeMapping,
|
||||||
productVariants,
|
productVariants,
|
||||||
storefrontUrl,
|
storefrontUrl,
|
||||||
productStorefrontUrl,
|
productStorefrontUrl,
|
||||||
|
@ -36,6 +40,11 @@ export const generateGoogleXmlFeed = ({
|
||||||
shopDescription,
|
shopDescription,
|
||||||
}: GenerateGoogleXmlFeedArgs) => {
|
}: GenerateGoogleXmlFeedArgs) => {
|
||||||
const items = productVariants.map((variant) => {
|
const items = productVariants.map((variant) => {
|
||||||
|
const attributes = getMappedAttributes({
|
||||||
|
attributeMapping: attributeMapping,
|
||||||
|
variant,
|
||||||
|
});
|
||||||
|
|
||||||
const currency = variant.pricing?.price?.gross.currency;
|
const currency = variant.pricing?.price?.gross.currency;
|
||||||
const amount = variant.pricing?.price?.gross.amount;
|
const amount = variant.pricing?.price?.gross.amount;
|
||||||
|
|
||||||
|
@ -55,6 +64,11 @@ export const generateGoogleXmlFeed = ({
|
||||||
googleProductCategory: variant.product.category?.googleCategoryId || "",
|
googleProductCategory: variant.product.category?.googleCategoryId || "",
|
||||||
price: price,
|
price: price,
|
||||||
imageUrl: variant.product.thumbnail?.url || "",
|
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",
|
region: "region",
|
||||||
secretAccessKey: "secretAccessKey",
|
secretAccessKey: "secretAccessKey",
|
||||||
},
|
},
|
||||||
|
attributeMapping: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
return appConfig.serialize();
|
return appConfig.serialize();
|
||||||
|
@ -48,6 +49,7 @@ describe("GoogleFeedSettingsFetcher", () => {
|
||||||
accessKeyId: "accessKeyId",
|
accessKeyId: "accessKeyId",
|
||||||
region: "region",
|
region: "region",
|
||||||
},
|
},
|
||||||
|
attributeMapping: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -84,6 +84,7 @@ export class GoogleFeedSettingsFetcher {
|
||||||
storefrontUrl,
|
storefrontUrl,
|
||||||
productStorefrontUrl,
|
productStorefrontUrl,
|
||||||
s3BucketConfiguration: appConfig.getS3Config(),
|
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 {
|
return {
|
||||||
item,
|
item,
|
||||||
};
|
};
|
||||||
|
|
|
@ -12,6 +12,11 @@ export type ProductEntry = {
|
||||||
googleProductCategory?: string;
|
googleProductCategory?: string;
|
||||||
availability: "in_stock" | "out_of_stock" | "preorder" | "backorder";
|
availability: "in_stock" | "out_of_stock" | "preorder" | "backorder";
|
||||||
category: string;
|
category: string;
|
||||||
|
material?: string;
|
||||||
|
color?: string;
|
||||||
|
size?: string;
|
||||||
|
brand?: string;
|
||||||
|
pattern?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ShopDetailsEntry = {
|
export type ShopDetailsEntry = {
|
||||||
|
|
|
@ -80,6 +80,7 @@ export const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
let storefrontUrl: string;
|
let storefrontUrl: string;
|
||||||
let productStorefrontUrl: string;
|
let productStorefrontUrl: string;
|
||||||
let bucketConfiguration: RootConfig["s3"] | undefined;
|
let bucketConfiguration: RootConfig["s3"] | undefined;
|
||||||
|
let attributeMapping: RootConfig["attributeMapping"] | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const settingsFetcher = GoogleFeedSettingsFetcher.createFromAuthData(authData);
|
const settingsFetcher = GoogleFeedSettingsFetcher.createFromAuthData(authData);
|
||||||
|
@ -88,6 +89,7 @@ export const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
storefrontUrl = settings.storefrontUrl;
|
storefrontUrl = settings.storefrontUrl;
|
||||||
productStorefrontUrl = settings.productStorefrontUrl;
|
productStorefrontUrl = settings.productStorefrontUrl;
|
||||||
bucketConfiguration = settings.s3BucketConfiguration;
|
bucketConfiguration = settings.s3BucketConfiguration;
|
||||||
|
attributeMapping = settings.attributeMapping;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn("The application has not been configured");
|
logger.warn("The application has not been configured");
|
||||||
|
|
||||||
|
@ -181,6 +183,7 @@ export const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
storefrontUrl,
|
storefrontUrl,
|
||||||
productStorefrontUrl,
|
productStorefrontUrl,
|
||||||
productVariants,
|
productVariants,
|
||||||
|
attributeMapping,
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.debug("Feed generated. Returning formatted XML");
|
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 { ChannelsConfigAccordion } from "../modules/app-configuration/channels-config-accordion";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { CategoryMappingPreview } from "../modules/category-mapping/ui/category-mapping-preview";
|
import { CategoryMappingPreview } from "../modules/category-mapping/ui/category-mapping-preview";
|
||||||
|
import { ConnectedAttributeMappingForm } from "../modules/app-configuration/attribute-mapping-form";
|
||||||
|
|
||||||
const ConfigurationPage: NextPage = () => {
|
const ConfigurationPage: NextPage = () => {
|
||||||
useChannelsExistenceChecking();
|
useChannelsExistenceChecking();
|
||||||
|
@ -145,6 +146,49 @@ const ConfigurationPage: NextPage = () => {
|
||||||
</Box>
|
</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>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in a new issue