Skip to content

Commit

Permalink
Product Feed: Add attribute mapping (saleor#838)
Browse files Browse the repository at this point in the history
* Add attribute mapping

* Improve release note

* Log the error

* Add pattern attribute
  • Loading branch information
krzysztofwolski authored and Tha-Toe committed Aug 8, 2023
1 parent e7dd8e3 commit c6a76c7
Show file tree
Hide file tree
Showing 18 changed files with 804 additions and 1 deletion.
5 changes: 5 additions & 0 deletions .changeset/nine-dryers-wink.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
fragment AttributeWithMappingFragment on Attribute {
id
name
slug
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
query FetchAttributesWithMapping($cursor: String){
attributes(first: 100, after: $cursor){
pageInfo{
hasNextPage
endCursor
}
edges{
node{
...AttributeWithMappingFragment
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -25,6 +29,13 @@ describe("AppConfig", function () {
},
},
},
attributeMapping: {
brandAttributeIds: [],
colorAttributeIds: [],
patternAttributeIds: [],
materialAttributeIds: [],
sizeAttributeIds: [],
},
});

expect(instance.getRootConfig()).toEqual({
Expand All @@ -42,6 +53,13 @@ describe("AppConfig", function () {
},
},
},
attributeMapping: {
brandAttributeIds: [],
colorAttributeIds: [],
patternAttributeIds: [],
materialAttributeIds: [],
sizeAttributeIds: [],
},
});
});

Expand All @@ -64,6 +82,13 @@ describe("AppConfig", function () {
secretAccessKey: "secret",
},
channelConfig: {},
attributeMapping: {
brandAttributeIds: [],
colorAttributeIds: [],
patternAttributeIds: [],
materialAttributeIds: [],
sizeAttributeIds: [],
},
});

const serialized = instance1.serialize();
Expand All @@ -78,6 +103,13 @@ describe("AppConfig", function () {
secretAccessKey: "secret",
},
channelConfig: {},
attributeMapping: {
brandAttributeIds: [],
colorAttributeIds: [],
patternAttributeIds: [],
materialAttributeIds: [],
sizeAttributeIds: [],
},
});
});
});
Expand All @@ -98,6 +130,13 @@ describe("AppConfig", function () {
},
},
},
attributeMapping: {
brandAttributeIds: [],
colorAttributeIds: [],
patternAttributeIds: [],
materialAttributeIds: [],
sizeAttributeIds: ["size-id"],
},
});

it("getRootConfig returns root config data", () => {
Expand All @@ -116,6 +155,13 @@ describe("AppConfig", function () {
},
},
},
attributeMapping: {
brandAttributeIds: [],
colorAttributeIds: [],
patternAttributeIds: [],
materialAttributeIds: [],
sizeAttributeIds: ["size-id"],
},
});
});

Expand All @@ -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", () => {
Expand Down
27 changes: 27 additions & 0 deletions apps/products-feed/src/modules/app-configuration/app-config.ts
Original file line number Diff line number Diff line change
@@ -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),
Expand All @@ -14,13 +22,15 @@ const urlConfigurationSchema = z.object({

const rootAppConfigSchema = z.object({
s3: s3ConfigSchema.nullable(),
attributeMapping: attributeMappingSchema.nullable(),
channelConfig: z.record(z.object({ storefrontUrls: urlConfigurationSchema })),
});

export const AppConfigSchema = {
root: rootAppConfigSchema,
s3Bucket: s3ConfigSchema,
channelUrls: urlConfigurationSchema,
attributeMapping: attributeMappingSchema,
};

export type RootConfig = z.infer<typeof rootAppConfigSchema>;
Expand All @@ -31,6 +41,7 @@ export class AppConfig {
private rootData: RootConfig = {
channelConfig: {},
s3: null,
attributeMapping: null,
};

constructor(initialData?: RootConfig) {
Expand Down Expand Up @@ -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);
Expand All @@ -88,4 +111,8 @@ export class AppConfig {
getS3Config() {
return this.rootData.s3;
}

getAttributeMapping() {
return this.rootData.attributeMapping;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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({
/**
Expand Down Expand Up @@ -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;
}),
});
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading

0 comments on commit c6a76c7

Please sign in to comment.