Product feed: Use Handlebars for title and URL formatting (#859)

* Use handlebars templates for title and URLs

* Add changesets

* Allow chaining on set methods

* Fix failing test

* Use getValues instead of handle submit

* Narrow down type for data input
This commit is contained in:
Krzysztof Wolski 2023-08-07 16:33:01 +02:00 committed by GitHub
parent 0b0297eeb8
commit 6a84b4b7e0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 579 additions and 161 deletions

View file

@ -0,0 +1,8 @@
---
"saleor-app-products-feed": minor
---
Changed format of product URLs to Handlebars. Previous format can still be parsed, so this change does not affect existing configurations.
Old format: `http://example.com/{variantId}`
New format: `http://example.com/{{variant.id}}`

View file

@ -0,0 +1,5 @@
---
"saleor-app-products-feed": minor
---
Added item title customization using Handlebars.

View file

@ -31,6 +31,7 @@
"fast-xml-parser": "^4.0.15", "fast-xml-parser": "^4.0.15",
"graphql": "16.7.1", "graphql": "16.7.1",
"graphql-tag": "^2.12.6", "graphql-tag": "^2.12.6",
"handlebars": "^4.7.7",
"jsdom": "^20.0.3", "jsdom": "^20.0.3",
"next": "13.4.8", "next": "13.4.8",
"pino": "^8.14.1", "pino": "^8.14.1",

View file

@ -9,7 +9,14 @@ describe("AppConfig", function () {
expect(instance.getRootConfig()).toEqual({ expect(instance.getRootConfig()).toEqual({
channelConfig: {}, channelConfig: {},
s3: null, s3: null,
attributeMapping: null, attributeMapping: {
brandAttributeIds: [],
colorAttributeIds: [],
patternAttributeIds: [],
materialAttributeIds: [],
sizeAttributeIds: [],
},
titleTemplate: "{{variant.product.name}} - {{variant.name}}",
}); });
}); });
@ -25,7 +32,7 @@ describe("AppConfig", function () {
test: { test: {
storefrontUrls: { storefrontUrls: {
productStorefrontUrl: "https://example.com", productStorefrontUrl: "https://example.com",
storefrontUrl: "https://example.com/p/{productFeed}", storefrontUrl: "https://example.com/p/{{ variant.product.slug }}",
}, },
}, },
}, },
@ -36,6 +43,7 @@ describe("AppConfig", function () {
materialAttributeIds: [], materialAttributeIds: [],
sizeAttributeIds: [], sizeAttributeIds: [],
}, },
titleTemplate: "{{ variant.name }}",
}); });
expect(instance.getRootConfig()).toEqual({ expect(instance.getRootConfig()).toEqual({
@ -49,7 +57,7 @@ describe("AppConfig", function () {
test: { test: {
storefrontUrls: { storefrontUrls: {
productStorefrontUrl: "https://example.com", productStorefrontUrl: "https://example.com",
storefrontUrl: "https://example.com/p/{productFeed}", storefrontUrl: "https://example.com/p/{{ variant.product.slug }}",
}, },
}, },
}, },
@ -60,6 +68,7 @@ describe("AppConfig", function () {
materialAttributeIds: [], materialAttributeIds: [],
sizeAttributeIds: [], sizeAttributeIds: [],
}, },
titleTemplate: "{{ variant.name }}",
}); });
}); });
@ -89,6 +98,7 @@ describe("AppConfig", function () {
materialAttributeIds: [], materialAttributeIds: [],
sizeAttributeIds: [], sizeAttributeIds: [],
}, },
titleTemplate: "{{ variant.name }}",
}); });
const serialized = instance1.serialize(); const serialized = instance1.serialize();
@ -110,6 +120,7 @@ describe("AppConfig", function () {
materialAttributeIds: [], materialAttributeIds: [],
sizeAttributeIds: [], sizeAttributeIds: [],
}, },
titleTemplate: "{{ variant.name }}",
}); });
}); });
}); });
@ -126,7 +137,7 @@ describe("AppConfig", function () {
test: { test: {
storefrontUrls: { storefrontUrls: {
productStorefrontUrl: "https://example.com", productStorefrontUrl: "https://example.com",
storefrontUrl: "https://example.com/p/{productFeed}", storefrontUrl: "https://example.com/p/{{ variant.product.slug }}",
}, },
}, },
}, },
@ -137,6 +148,7 @@ describe("AppConfig", function () {
materialAttributeIds: [], materialAttributeIds: [],
sizeAttributeIds: ["size-id"], sizeAttributeIds: ["size-id"],
}, },
titleTemplate: "{{ variant.product.name }} - {{ variant.name }}",
}); });
it("getRootConfig returns root config data", () => { it("getRootConfig returns root config data", () => {
@ -151,7 +163,7 @@ describe("AppConfig", function () {
test: { test: {
storefrontUrls: { storefrontUrls: {
productStorefrontUrl: "https://example.com", productStorefrontUrl: "https://example.com",
storefrontUrl: "https://example.com/p/{productFeed}", storefrontUrl: "https://example.com/p/{{ variant.product.slug }}",
}, },
}, },
}, },
@ -162,13 +174,14 @@ describe("AppConfig", function () {
materialAttributeIds: [], materialAttributeIds: [],
sizeAttributeIds: ["size-id"], sizeAttributeIds: ["size-id"],
}, },
titleTemplate: "{{ variant.product.name }} - {{ variant.name }}",
}); });
}); });
it("getUrlsForChannel gets data for given channel or undefined if doesnt exist", () => { it("getUrlsForChannel gets data for given channel or undefined if doesn't exist", () => {
expect(instance.getUrlsForChannel("test")).toEqual({ expect(instance.getUrlsForChannel("test")).toEqual({
productStorefrontUrl: "https://example.com", productStorefrontUrl: "https://example.com",
storefrontUrl: "https://example.com/p/{productFeed}", storefrontUrl: "https://example.com/p/{{ variant.product.slug }}",
}); });
expect(instance.getUrlsForChannel("not-existing")).toBeUndefined(); expect(instance.getUrlsForChannel("not-existing")).toBeUndefined();
@ -221,12 +234,12 @@ describe("AppConfig", function () {
instance.setChannelUrls("test", { instance.setChannelUrls("test", {
productStorefrontUrl: "https://example.com", productStorefrontUrl: "https://example.com",
storefrontUrl: "https://example.com/p/{productFeed}", storefrontUrl: "https://example.com/p/{{ variant.product.slug }}",
}); });
expect(instance.getUrlsForChannel("test")).toEqual({ expect(instance.getUrlsForChannel("test")).toEqual({
productStorefrontUrl: "https://example.com", productStorefrontUrl: "https://example.com",
storefrontUrl: "https://example.com/p/{productFeed}", storefrontUrl: "https://example.com/p/{{ variant.product.slug }}",
}); });
// @ts-expect-error // @ts-expect-error

View file

@ -1,11 +1,19 @@
import { z } from "zod"; import { z } from "zod";
const titleTemplateFieldSchema = z.string().default("{{variant.product.name}} - {{variant.name}}");
export const titleTemplateInputSchema = z.object({
titleTemplate: titleTemplateFieldSchema,
});
export type TitleTemplateInput = z.infer<typeof titleTemplateInputSchema>;
const attributeMappingSchema = z.object({ const attributeMappingSchema = z.object({
brandAttributeIds: z.array(z.string()), brandAttributeIds: z.array(z.string()).default([]),
colorAttributeIds: z.array(z.string()), colorAttributeIds: z.array(z.string()).default([]),
sizeAttributeIds: z.array(z.string()), sizeAttributeIds: z.array(z.string()).default([]),
materialAttributeIds: z.array(z.string()), materialAttributeIds: z.array(z.string()).default([]),
patternAttributeIds: z.array(z.string()), patternAttributeIds: z.array(z.string()).default([]),
}); });
const s3ConfigSchema = z.object({ const s3ConfigSchema = z.object({
@ -22,6 +30,7 @@ const urlConfigurationSchema = z.object({
const rootAppConfigSchema = z.object({ const rootAppConfigSchema = z.object({
s3: s3ConfigSchema.nullable(), s3: s3ConfigSchema.nullable(),
titleTemplate: titleTemplateFieldSchema,
attributeMapping: attributeMappingSchema.nullable(), attributeMapping: attributeMappingSchema.nullable(),
channelConfig: z.record(z.object({ storefrontUrls: urlConfigurationSchema })), channelConfig: z.record(z.object({ storefrontUrls: urlConfigurationSchema })),
}); });
@ -41,7 +50,8 @@ export class AppConfig {
private rootData: RootConfig = { private rootData: RootConfig = {
channelConfig: {}, channelConfig: {},
s3: null, s3: null,
attributeMapping: null, attributeMapping: attributeMappingSchema.parse({}),
titleTemplate: titleTemplateFieldSchema.parse(undefined),
}; };
constructor(initialData?: RootConfig) { constructor(initialData?: RootConfig) {
@ -93,6 +103,8 @@ export class AppConfig {
this.rootData.channelConfig[channelSlug] = { this.rootData.channelConfig[channelSlug] = {
storefrontUrls: parsedConfig, storefrontUrls: parsedConfig,
}; };
return this;
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@ -115,4 +127,14 @@ export class AppConfig {
getAttributeMapping() { getAttributeMapping() {
return this.rootData.attributeMapping; return this.rootData.attributeMapping;
} }
setTitleTemplate(titleTemplate: z.infer<typeof titleTemplateFieldSchema>) {
this.rootData.titleTemplate = titleTemplate;
return this;
}
getTitleTemplate() {
return this.rootData.titleTemplate;
}
} }

View file

@ -3,18 +3,20 @@ import { protectedClientProcedure } from "../trpc/protected-client-procedure";
import { createLogger } from "@saleor/apps-shared"; import { createLogger } from "@saleor/apps-shared";
import { updateCacheForConfigurations } from "../metadata-cache/update-cache-for-configurations"; import { updateCacheForConfigurations } from "../metadata-cache/update-cache-for-configurations";
import { AppConfigSchema } from "./app-config"; import { AppConfigSchema, titleTemplateInputSchema } from "./app-config";
import { z } from "zod"; 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"; import { AttributeFetcher } from "./attribute-fetcher";
import { renderHandlebarsTemplate } from "../handlebarsTemplates/render-handlebars-template";
import { prepareExampleVariantData } from "./prepare-example-variant-data";
export const appConfigurationRouter = router({ export const appConfigurationRouter = router({
/** /**
* Prefer fetching all to avoid unnecessary calls. Routes are cached by react-query * Prefer fetching all to avoid unnecessary calls. Routes are cached by react-query
*/ */
fetch: protectedClientProcedure.query(async ({ ctx: { logger, getConfig }, input }) => { fetch: protectedClientProcedure.query(async ({ ctx: { logger, getConfig } }) => {
return getConfig().then((c) => { return getConfig().then((c) => {
logger.debug("Fetched config"); logger.debug("Fetched config");
@ -24,7 +26,7 @@ export const appConfigurationRouter = router({
testS3BucketConfiguration: protectedClientProcedure testS3BucketConfiguration: protectedClientProcedure
.meta({ requiredClientPermissions: ["MANAGE_APPS"] }) .meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(AppConfigSchema.s3Bucket) .input(AppConfigSchema.s3Bucket)
.mutation(async ({ ctx: { saleorApiUrl, getConfig, appConfigMetadataManager }, input }) => { .mutation(async ({ ctx: { saleorApiUrl }, input }) => {
const logger = createLogger({ saleorApiUrl: saleorApiUrl }); const logger = createLogger({ saleorApiUrl: saleorApiUrl });
logger.debug("Validate the credentials"); logger.debug("Validate the credentials");
@ -98,7 +100,7 @@ export const appConfigurationRouter = router({
/** /**
* TODO Check if this has to run, once its cached, it should be invalidated by webhooks only. * TODO Check if this has to run, once its cached, it should be invalidated by webhooks only.
* *
* But this operation isnt expensive and users will not continously save this form * But this operation isn't expensive and users will not continuously save this form
*/ */
await updateCacheForConfigurations({ await updateCacheForConfigurations({
client: apiClient, client: apiClient,
@ -120,21 +122,16 @@ export const appConfigurationRouter = router({
setAttributeMapping: protectedClientProcedure setAttributeMapping: protectedClientProcedure
.meta({ requiredClientPermissions: ["MANAGE_APPS"] }) .meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(AppConfigSchema.attributeMapping) .input(AppConfigSchema.attributeMapping)
.mutation( .mutation(async ({ ctx: { getConfig, appConfigMetadataManager, logger }, input }) => {
async ({ logger.debug("Setting attribute mapping");
ctx: { getConfig, apiClient, saleorApiUrl, appConfigMetadataManager, logger },
input,
}) => {
const config = await getConfig(); const config = await getConfig();
config.setAttributeMapping(input); config.setAttributeMapping(input);
await appConfigMetadataManager.set(config.serialize()); await appConfigMetadataManager.set(config.serialize());
logger.debug("Attribute map set");
return null; return null;
} }),
),
getAttributes: protectedClientProcedure getAttributes: protectedClientProcedure
.meta({ requiredClientPermissions: ["MANAGE_APPS"] }) .meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.query(async ({ ctx: { logger, apiClient } }) => { .query(async ({ ctx: { logger, apiClient } }) => {
@ -152,4 +149,59 @@ export const appConfigurationRouter = router({
return result; return result;
}), }),
setTitleTemplate: protectedClientProcedure
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(titleTemplateInputSchema)
.mutation(async ({ ctx: { getConfig, appConfigMetadataManager, logger }, input }) => {
logger.debug("Setting title template");
const config = await getConfig();
// Test render to prevent saving invalid template
try {
renderHandlebarsTemplate({
data: {},
template: input.titleTemplate,
});
} catch (err) {
logger.debug({ error: err }, "Template render failed");
throw new TRPCError({
code: "BAD_REQUEST",
message: "Submitted template is invalid",
});
}
config.setTitleTemplate(input.titleTemplate);
await appConfigMetadataManager.set(config.serialize());
logger.debug("Template title set");
return null;
}),
renderTemplate: protectedClientProcedure
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(titleTemplateInputSchema)
.mutation(async ({ ctx: { getConfig, logger }, input }) => {
logger.debug(input, "renderTemplate called");
const config = await getConfig();
try {
const title = renderHandlebarsTemplate({
data: prepareExampleVariantData({
attributeMapping: config.getAttributeMapping(),
}),
template: input.titleTemplate,
});
logger.debug("Title rendered succeeded");
return { title };
} catch (err) {
logger.debug({ error: err }, "Template render failed");
throw new TRPCError({
code: "BAD_REQUEST",
message: "Submitted template is invalid",
});
}
}),
}); });

View file

@ -0,0 +1,58 @@
import { GoogleFeedProductVariantFragment } from "../../../generated/graphql";
export const exampleVariantData: GoogleFeedProductVariantFragment = {
id: "UHJvZHVjdFZhcmlhbnQ6MzYx",
name: "M",
sku: "218223580",
pricing: {
price: {
gross: {
currency: "USD",
amount: 45,
},
},
},
quantityAvailable: 50,
attributes: [
{
attribute: {
id: "QXR0cmlidXRlOjM4",
},
values: [
{
value: "",
name: "M",
},
],
},
],
product: {
id: "UHJvZHVjdDoxMzc=",
name: "Blue Polygon Shirt",
slug: "blue-polygon-shirt",
description:
'{"time": 1653425319677, "blocks": [{"id": "sMEIn2NR8s", "data": {"text": "<b>Ever have those days where you feel a bit geometric?</b> Can\'t quite shape yourself up right? Show your different sides with a Saleor styles."}, "type": "paragraph"}], "version": "2.22.2"}',
seoDescription: "",
attributes: [
{
attribute: {
id: "QXR0cmlidXRlOjM2",
},
values: [
{
value: "",
name: "Cotton",
},
],
},
],
thumbnail: {
url: "https://example.eu.saleor.cloud/media/thumbnails/products/saleor-blue-polygon-tee-front_thumbnail_256.png",
},
category: {
id: "Q2F0ZWdvcnk6Mzk=",
name: "T-shirts",
googleCategoryId: "42",
},
},
};

View file

@ -0,0 +1,19 @@
import { getMappedAttributes } from "../google-feed/attribute-mapping";
import { RootConfig } from "./app-config";
import { exampleVariantData } from "./example-variant-data";
interface PrepareExampleVariantData {
attributeMapping?: RootConfig["attributeMapping"];
}
export const prepareExampleVariantData = ({ attributeMapping }: PrepareExampleVariantData) => {
const attributes = getMappedAttributes({
attributeMapping: attributeMapping,
variant: exampleVariantData,
});
return {
variant: exampleVariantData,
googleAttributes: attributes,
};
};

View file

@ -0,0 +1,127 @@
import { AppConfigSchema, TitleTemplateInput, titleTemplateInputSchema } from "./app-config";
import { useForm } from "react-hook-form";
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import React, { useCallback, useMemo, useState } from "react";
import { Input } from "@saleor/react-hook-form-macaw";
import { zodResolver } from "@hookform/resolvers/zod";
import { trpcClient } from "../trpc/trpc-client";
import { useDashboardNotification } from "@saleor/apps-shared";
type Props = {
initialData: TitleTemplateInput;
preview: string | undefined;
onSubmit(data: TitleTemplateInput): Promise<void>;
onPreview(data: TitleTemplateInput): Promise<void>;
};
export const TitleFormattingConfigurationForm = (props: Props) => {
const { handleSubmit, control, getValues } = useForm<TitleTemplateInput>({
defaultValues: props.initialData,
resolver: zodResolver(AppConfigSchema.attributeMapping),
});
return (
<Box
as={"form"}
display={"flex"}
gap={5}
flexDirection={"column"}
onSubmit={handleSubmit((data) => {
props.onSubmit(data);
})}
>
<Input control={control} name="titleTemplate" label="Title template" />
{props.preview?.length && <Text variant="caption">{props.preview}</Text>}
<Box display={"flex"} flexDirection={"row"} gap={4} justifyContent={"flex-end"}>
<Button
variant="secondary"
onClick={() => {
props.onPreview(getValues());
}}
>
Preview
</Button>
<Button type="submit" variant="primary">
Save
</Button>
</Box>
</Box>
);
};
export const ConnectedTitleFormattingForm = () => {
const { notifyError, notifySuccess } = useDashboardNotification();
const [preview, setPreview] = useState<string | undefined>();
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.setTitleTemplate.useMutation({
onSuccess() {
notifySuccess("Success", "Updated title template");
},
onError() {
notifyError("Error", "Failed to update, please refresh and try again");
},
});
const { mutate: previewMutate } = trpcClient.appConfiguration.renderTemplate.useMutation({
onSuccess(data) {
setPreview(data.title);
},
onError() {
notifyError("Error", "Template invalid");
},
});
const handleSubmit = useCallback(
async (data: TitleTemplateInput) => {
mutate(data);
},
[mutate]
);
const handlePreview = useCallback(
async (data: TitleTemplateInput) => {
previewMutate(data);
},
[previewMutate]
);
const formData: TitleTemplateInput = useMemo(() => {
if (data?.titleTemplate) {
return {
titleTemplate: data.titleTemplate,
};
}
return titleTemplateInputSchema.parse({});
}, [data]);
if (isLoading) {
return <Text>Loading...</Text>;
}
const showForm = !isLoading && attributes?.length;
return (
<>
{showForm ? (
<TitleFormattingConfigurationForm
onSubmit={handleSubmit}
initialData={formData}
onPreview={handlePreview}
preview={preview}
/>
) : (
<Box>Loading</Box>
)}
</>
);
};

View file

@ -1,37 +0,0 @@
import { describe, expect, it } from "vitest";
import { fillUrlTemplate } from "./fill-url-template";
import { z } from "zod";
describe("fillUrlTemplate", () => {
it("Replaces template strings in url", () => {
expect(
fillUrlTemplate({
urlTemplate: `https://example.com/p/{productSlug}/{productId}/{variantId}`,
productId: "PRODUCT_ID",
productSlug: "PRODUCT_SLUG",
variantId: "VARIANT_ID",
})
).toEqual("https://example.com/p/PRODUCT_SLUG/PRODUCT_ID/VARIANT_ID");
});
/**
* Not likely to happen, but better safe than sorry
*/
it("Encodes components so special characters are not passed to URL", () => {
const resultUrl = fillUrlTemplate({
urlTemplate: `https://example.com/p/{productSlug}/{productId}/{variantId}`,
productId: "productId < ",
productSlug: "product/slug",
variantId: "variantId%12!",
});
/**
* Validate URL with URL api
*/
expect(() => new URL(resultUrl)).not.toThrow();
expect(resultUrl).toEqual(
"https://example.com/p/product%2Fslug/productId%20%3C%20%20%20/variantId%2512!"
);
});
});

View file

@ -1,17 +0,0 @@
interface FillUrlTemplateArgs {
urlTemplate: string;
productId: string;
productSlug: string;
variantId: string;
}
export const fillUrlTemplate = ({
urlTemplate,
productId,
productSlug,
variantId,
}: FillUrlTemplateArgs) =>
urlTemplate
.replace("{productId}", encodeURIComponent(productId))
.replace("{productSlug}", encodeURIComponent(productSlug))
.replace("{variantId}", encodeURIComponent(variantId));

View file

@ -41,11 +41,79 @@ const priceBase: GoogleFeedProductVariantFragment["pricing"] = {
describe("generateGoogleXmlFeed", () => { describe("generateGoogleXmlFeed", () => {
it("Generates feed", () => { it("Generates feed", () => {
const result = generateGoogleXmlFeed({
productStorefrontUrl: "https://example.com/p/{{ variant.product.slug }}",
shopDescription: "Description",
shopName: "Example",
storefrontUrl: "https://example.com",
titleTemplate: "{{ variant.product.name }} - {{ variant.name }}",
productVariants: [
{
id: "id1",
__typename: "ProductVariant",
sku: "sku1",
quantityAvailable: 1,
pricing: priceBase,
name: "Product variant",
product: productBase,
attributes: [],
},
{
id: "id2",
__typename: "ProductVariant",
sku: "sku2",
quantityAvailable: 0,
pricing: priceBase,
name: "Product variant 2",
product: productBase,
attributes: [],
},
],
});
expect(result).toMatchInlineSnapshot(`
"<?xml version=\\"1.0\\" encoding=\\"utf-8\\"?>
<rss xmlns:g=\\"http://base.google.com/ns/1.0\\" version=\\"2.0\\">
<channel>
<title>Example</title>
<link>https://example.com</link>
<description>Description</description>
<item>
<g:id>sku1</g:id>
<g:item_group_id>product-id</g:item_group_id>
<title>Product - Product variant</title>
<g:condition>new</g:condition>
<g:availability>in_stock</g:availability>
<g:product_type>Category Name</g:product_type>
<g:google_product_category>1</g:google_product_category>
<link>https://example.com/p/product-slug</link>
<g:price>2.00 USD</g:price>
<g:sale_price>1.00 USD</g:sale_price>
</item>
<item>
<g:id>sku2</g:id>
<g:item_group_id>product-id</g:item_group_id>
<title>Product - Product variant 2</title>
<g:condition>new</g:condition>
<g:availability>out_of_stock</g:availability>
<g:product_type>Category Name</g:product_type>
<g:google_product_category>1</g:google_product_category>
<link>https://example.com/p/product-slug</link>
<g:price>2.00 USD</g:price>
<g:sale_price>1.00 USD</g:sale_price>
</item>
</channel>
</rss>"
`);
});
it("Generates feed with rendered urls, when provided old style URL template", () => {
const result = generateGoogleXmlFeed({ const result = generateGoogleXmlFeed({
productStorefrontUrl: "https://example.com/p/{productSlug}", productStorefrontUrl: "https://example.com/p/{productSlug}",
shopDescription: "Description", shopDescription: "Description",
shopName: "Example", shopName: "Example",
storefrontUrl: "https://example.com", storefrontUrl: "https://example.com",
titleTemplate: "{{ variant.product.name }} - {{ variant.name }}",
productVariants: [ productVariants: [
{ {
id: "id1", id: "id1",

View file

@ -6,11 +6,14 @@ import { EditorJsPlaintextRenderer } from "../editor-js/editor-js-plaintext-rend
import { RootConfig } from "../app-configuration/app-config"; import { RootConfig } from "../app-configuration/app-config";
import { getMappedAttributes } from "./attribute-mapping"; import { getMappedAttributes } from "./attribute-mapping";
import { priceMapping } from "./price-mapping"; import { priceMapping } from "./price-mapping";
import { renderHandlebarsTemplate } from "../handlebarsTemplates/render-handlebars-template";
import { transformTemplateFormat } from "../handlebarsTemplates/transform-template-format";
interface GenerateGoogleXmlFeedArgs { interface GenerateGoogleXmlFeedArgs {
productVariants: GoogleFeedProductVariantFragment[]; productVariants: GoogleFeedProductVariantFragment[];
storefrontUrl: string; storefrontUrl: string;
productStorefrontUrl: string; productStorefrontUrl: string;
titleTemplate: string;
attributeMapping?: RootConfig["attributeMapping"]; attributeMapping?: RootConfig["attributeMapping"];
shopName: string; shopName: string;
shopDescription?: string; shopDescription?: string;
@ -20,6 +23,7 @@ export const generateGoogleXmlFeed = ({
attributeMapping, attributeMapping,
productVariants, productVariants,
storefrontUrl, storefrontUrl,
titleTemplate,
productStorefrontUrl, productStorefrontUrl,
shopName, shopName,
shopDescription, shopDescription,
@ -32,10 +36,34 @@ export const generateGoogleXmlFeed = ({
const pricing = priceMapping({ pricing: variant.pricing }); const pricing = priceMapping({ pricing: variant.pricing });
let title = "";
try {
title = renderHandlebarsTemplate({
data: {
variant,
googleAttributes: attributes,
},
template: titleTemplate,
});
} catch {}
let link = undefined;
try {
link = renderHandlebarsTemplate({
data: {
variant,
googleAttributes: attributes,
},
template: transformTemplateFormat({ template: productStorefrontUrl }),
});
} catch {}
return productToProxy({ return productToProxy({
storefrontUrlTemplate: productStorefrontUrl, link,
title: title || "",
id: variant.product.id, id: variant.product.id,
name: `${variant.product.name} - ${variant.name}`,
slug: variant.product.slug, slug: variant.product.slug,
variantId: variant.id, variantId: variant.id,
sku: variant.sku || undefined, sku: variant.sku || undefined,

View file

@ -10,13 +10,15 @@ describe("GoogleFeedSettingsFetcher", () => {
testChannel: { testChannel: {
storefrontUrls: { storefrontUrls: {
storefrontUrl: "https://example.com", storefrontUrl: "https://example.com",
productStorefrontUrl: "https://example.com/p/{productSlug}/v/{variantId}", productStorefrontUrl:
"https://example.com/p/{{ variant.product.slug }}/v/{{ variant.slug }}",
}, },
}, },
anotherChannel: { anotherChannel: {
storefrontUrls: { storefrontUrls: {
storefrontUrl: "https://another.example.com", storefrontUrl: "https://another.example.com",
productStorefrontUrl: "https://another.example.com/p/{productSlug}/v/{variantId}", productStorefrontUrl:
"https://another.example.com/p/{{ variant.product.slug }}/v/{{ variant.slug }}",
}, },
}, },
}, },
@ -27,6 +29,7 @@ describe("GoogleFeedSettingsFetcher", () => {
secretAccessKey: "secretAccessKey", secretAccessKey: "secretAccessKey",
}, },
attributeMapping: null, attributeMapping: null,
titleTemplate: "{{ variant.name }}",
}); });
return appConfig.serialize(); return appConfig.serialize();
@ -42,7 +45,7 @@ describe("GoogleFeedSettingsFetcher", () => {
expect(result).toEqual({ expect(result).toEqual({
storefrontUrl: "https://example.com", storefrontUrl: "https://example.com",
productStorefrontUrl: "https://example.com/p/{productSlug}/v/{variantId}", productStorefrontUrl: "https://example.com/p/{{ variant.product.slug }}/v/{{ variant.slug }}",
s3BucketConfiguration: { s3BucketConfiguration: {
bucketName: "bucketName", bucketName: "bucketName",
secretAccessKey: "secretAccessKey", secretAccessKey: "secretAccessKey",
@ -50,6 +53,7 @@ describe("GoogleFeedSettingsFetcher", () => {
region: "region", region: "region",
}, },
attributeMapping: null, attributeMapping: null,
titleTemplate: "{{ variant.name }}",
}); });
}); });
}); });

View file

@ -4,46 +4,6 @@ import { GraphqlClientFactory } from "../../lib/create-graphq-client";
import { createSettingsManager } from "../../lib/metadata-manager"; import { createSettingsManager } from "../../lib/metadata-manager";
import { AppConfig } from "../app-configuration/app-config"; import { AppConfig } from "../app-configuration/app-config";
interface GetGoogleFeedSettingsArgs {
authData: AuthData;
channel: string;
}
/**
* @deprecated replace with class
*/
export const getGoogleFeedSettings = async ({ authData, channel }: GetGoogleFeedSettingsArgs) => {
const client = GraphqlClientFactory.fromAuthData(authData);
const metadataManager = new AppConfigMetadataManager(createSettingsManager(client));
const configString = await metadataManager.get();
if (!configString) {
throw new Error("App is not configured");
}
const appConfig = AppConfig.parse(configString);
const channelConfig = appConfig.getUrlsForChannel(channel);
if (!channelConfig) {
throw new Error("App is not configured");
}
const storefrontUrl = channelConfig.storefrontUrl;
const productStorefrontUrl = channelConfig.productStorefrontUrl;
if (!storefrontUrl.length || !productStorefrontUrl.length) {
throw new Error("The application has not been configured");
}
return {
storefrontUrl,
productStorefrontUrl,
s3BucketConfiguration: appConfig.getS3Config(),
};
};
export class GoogleFeedSettingsFetcher { export class GoogleFeedSettingsFetcher {
static createFromAuthData(authData: AuthData) { static createFromAuthData(authData: AuthData) {
return new GoogleFeedSettingsFetcher({ return new GoogleFeedSettingsFetcher({
@ -85,6 +45,7 @@ export class GoogleFeedSettingsFetcher {
productStorefrontUrl, productStorefrontUrl,
s3BucketConfiguration: appConfig.getS3Config(), s3BucketConfiguration: appConfig.getS3Config(),
attributeMapping: appConfig.getAttributeMapping(), attributeMapping: appConfig.getAttributeMapping(),
titleTemplate: appConfig.getTitleTemplate(),
}; };
} }
} }

View file

@ -9,7 +9,7 @@ describe("productToProxy", () => {
category: "1", category: "1",
condition: "new", condition: "new",
id: "product-id", id: "product-id",
name: "Name", title: "title",
variantId: "variant-id", variantId: "variant-id",
}); });
@ -32,7 +32,7 @@ describe("productToProxy", () => {
* condition: "new", * condition: "new",
*/ */
id: "product-id", id: "product-id",
name: "Name", title: "title",
variantId: "variant-id", variantId: "variant-id",
}); });
@ -52,7 +52,7 @@ describe("productToProxy", () => {
category: "1", category: "1",
condition: "new", condition: "new",
id: "product-id", id: "product-id",
name: "Name", title: "title",
variantId: "variant-id", variantId: "variant-id",
description: "Product description", description: "Product description",
}); });
@ -74,7 +74,7 @@ describe("productToProxy", () => {
condition: "new", condition: "new",
googleProductCategory: "1", googleProductCategory: "1",
id: "product-id", id: "product-id",
name: "Name", title: "title",
variantId: "variant-id", variantId: "variant-id",
}); });
@ -87,7 +87,7 @@ describe("productToProxy", () => {
); );
}); });
it("Adds link section with filled product url template", () => { it("Adds link section, when url is provided", () => {
const result = productToProxy({ const result = productToProxy({
slug: "slug", slug: "slug",
availability: "in_stock", availability: "in_stock",
@ -95,9 +95,9 @@ describe("productToProxy", () => {
condition: "new", condition: "new",
googleProductCategory: "1", googleProductCategory: "1",
id: "product-id", id: "product-id",
name: "Name", title: "title",
variantId: "variant-id", variantId: "variant-id",
storefrontUrlTemplate: "https://example.com/p/{productSlug}/{productId}/{variantId}", link: "https://example.com/p/product-id",
}); });
expect(result.item).toEqual( expect(result.item).toEqual(
@ -105,7 +105,7 @@ describe("productToProxy", () => {
{ {
link: expect.arrayContaining([ link: expect.arrayContaining([
{ {
"#text": "https://example.com/p/slug/product-id/variant-id", "#text": "https://example.com/p/product-id",
}, },
]), ]),
}, },
@ -121,7 +121,7 @@ describe("productToProxy", () => {
condition: "new", condition: "new",
googleProductCategory: "1", googleProductCategory: "1",
id: "product-id", id: "product-id",
name: "Name", title: "title",
variantId: "variant-id", variantId: "variant-id",
imageUrl: "https://image.example.com", imageUrl: "https://image.example.com",
}); });
@ -143,7 +143,7 @@ describe("productToProxy", () => {
condition: "new", condition: "new",
googleProductCategory: "1", googleProductCategory: "1",
id: "product-id", id: "product-id",
name: "Name", title: "title",
variantId: "variant-id", variantId: "variant-id",
imageUrl: "https://image.example.com", imageUrl: "https://image.example.com",
price: "50.00 USD", price: "50.00 USD",

View file

@ -1,4 +1,3 @@
import { fillUrlTemplate } from "../feed-url/fill-url-template";
import { GoogleProxyItem, ProductEntry } from "./types"; import { GoogleProxyItem, ProductEntry } from "./types";
export const productToProxy = (p: ProductEntry) => { export const productToProxy = (p: ProductEntry) => {
@ -20,7 +19,7 @@ export const productToProxy = (p: ProductEntry) => {
{ {
title: [ title: [
{ {
"#text": p.name, "#text": p.title,
}, },
], ],
}, },
@ -74,16 +73,11 @@ export const productToProxy = (p: ProductEntry) => {
}); });
} }
if (p.storefrontUrlTemplate?.length) { if (p.link?.length) {
item.push({ item.push({
link: [ link: [
{ {
"#text": fillUrlTemplate({ "#text": p.link,
urlTemplate: p.storefrontUrlTemplate,
productId: p.id,
productSlug: p.slug,
variantId: p.variantId,
}),
}, },
], ],
}); });

View file

@ -1,11 +1,11 @@
export type ProductEntry = { export type ProductEntry = {
id: string; id: string;
title: string;
sku?: string; sku?: string;
name: string;
slug: string; slug: string;
variantId: string; variantId: string;
description?: string; description?: string;
storefrontUrlTemplate?: string; link?: string;
imageUrl?: string; imageUrl?: string;
condition?: "new" | "refurbished" | "used"; condition?: "new" | "refurbished" | "used";
price?: string; price?: string;

View file

@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { renderHandlebarsTemplate } from "./render-handlebars-template";
describe("renderHandlebarsTemplate", () => {
it("Returns formatted string, when valid template and data are provided", () => {
expect(
renderHandlebarsTemplate({
data: { name: "John", hobby: "fishing" },
template: "Hello, my name is {{ name }}. My hobby is {{ hobby }}.",
})
).toStrictEqual("Hello, my name is John. My hobby is fishing.");
});
it("Throws an error, when provided template is not valid", () => {
expect(() =>
renderHandlebarsTemplate({
data: { name: "John", hobby: "fishing" },
template: "Hello, my name is {{ name }}. My hobby is {{ hobby", // no closing brackets to trigger an error
})
).toThrowError("Could not render the template");
});
});

View file

@ -0,0 +1,24 @@
// TODO: PF and EAM apps use handlebars. Extract this module to shared package.
import Handlebars from "handlebars";
import { createLogger } from "@saleor/apps-shared";
const logger = createLogger({
name: "renderHandlebarsTemplate",
});
interface RenderHandlebarsTemplateArgs {
template: string;
data: Record<string, unknown>;
}
export const renderHandlebarsTemplate = ({ template, data }: RenderHandlebarsTemplateArgs) => {
try {
const compiledTemplate = Handlebars.compile(template);
return compiledTemplate(data);
} catch (error) {
logger.error(error, "Template compilation failed");
throw new Error("Could not render the template");
}
};

View file

@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { transformTemplateFormat } from "./transform-template-format";
describe("templateTransformer", () => {
it("Returns unchanged string, when no v1 tags are found", () => {
const template = "No changes, {unknownTag}";
expect(transformTemplateFormat({ template })).toBe(template);
});
it("Transforms tags to handlebars format, when template contain any", () => {
const oldTemplate = "Test: {productId} {productName} {productSlug} {variantId} {variantName}";
const handlebarsTemplate =
"Test: {{ variant.product.id }} {{ variant.product.name }} {{ variant.product.slug }} {{ variant.id }} {{ variant.name }}";
expect(transformTemplateFormat({ template: oldTemplate })).toBe(handlebarsTemplate);
});
});

View file

@ -0,0 +1,15 @@
interface transformTemplateFormatArgs {
template: string;
}
/*
* Transform simple templates to handlebars format.
* Example: `{productID}` will be transformed to `{{ variant. product.id}}`.
*/
export const transformTemplateFormat = ({ template }: transformTemplateFormatArgs) =>
template
.replace("{productId}", "{{ variant.product.id }}")
.replace("{productSlug}", "{{ variant.product.slug }}")
.replace("{productName}", "{{ variant.product.name }}")
.replace("{variantId}", "{{ variant.id }}")
.replace("{variantName}", "{{ variant.name }}");

View file

@ -81,6 +81,7 @@ export const handler = async (req: NextApiRequest, res: NextApiResponse) => {
let productStorefrontUrl: string; let productStorefrontUrl: string;
let bucketConfiguration: RootConfig["s3"] | undefined; let bucketConfiguration: RootConfig["s3"] | undefined;
let attributeMapping: RootConfig["attributeMapping"] | undefined; let attributeMapping: RootConfig["attributeMapping"] | undefined;
let titleTemplate: RootConfig["titleTemplate"] | undefined;
try { try {
const settingsFetcher = GoogleFeedSettingsFetcher.createFromAuthData(authData); const settingsFetcher = GoogleFeedSettingsFetcher.createFromAuthData(authData);
@ -90,6 +91,7 @@ export const handler = async (req: NextApiRequest, res: NextApiResponse) => {
productStorefrontUrl = settings.productStorefrontUrl; productStorefrontUrl = settings.productStorefrontUrl;
bucketConfiguration = settings.s3BucketConfiguration; bucketConfiguration = settings.s3BucketConfiguration;
attributeMapping = settings.attributeMapping; attributeMapping = settings.attributeMapping;
titleTemplate = settings.titleTemplate;
} catch (error) { } catch (error) {
logger.warn("The application has not been configured"); logger.warn("The application has not been configured");
@ -184,6 +186,7 @@ export const handler = async (req: NextApiRequest, res: NextApiResponse) => {
productStorefrontUrl, productStorefrontUrl,
productVariants, productVariants,
attributeMapping, attributeMapping,
titleTemplate,
}); });
logger.debug("Feed generated. Returning formatted XML"); logger.debug("Feed generated. Returning formatted XML");

View file

@ -11,6 +11,7 @@ import { ChannelsConfigAccordion } from "../modules/app-configuration/channels-c
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"; import { ConnectedAttributeMappingForm } from "../modules/app-configuration/attribute-mapping-form";
import { ConnectedTitleFormattingForm } from "../modules/app-configuration/title-formatting-form";
const ConfigurationPage: NextPage = () => { const ConfigurationPage: NextPage = () => {
useChannelsExistenceChecking(); useChannelsExistenceChecking();
@ -90,28 +91,27 @@ const ConfigurationPage: NextPage = () => {
<Box> <Box>
<Paragraph size={"small"}>App will generate separate feed for each channel</Paragraph> <Paragraph size={"small"}>App will generate separate feed for each channel</Paragraph>
<Paragraph size={"small"}> <Paragraph size={"small"}>
Provide your storefront homepage URL and product template. Use following placeholders: Provide your storefront homepage URL and product template. Use{" "}
<TextLink href="https://handlebarsjs.com/" newTab>
Handlebars
</TextLink>{" "}
format. Example of the variables you can use:
</Paragraph> </Paragraph>
<ul> <ul>
<li> <li>
<Text size={"small"}> <Text size={"small"}>
<code>{"{productSlug}"}</code> - to inject slug of the product <code>{"{{ variant.product.slug }}"}</code> - product `slug`
</Text> </Text>
</li> </li>
<li> <li>
<Text size={"small"}> <Text size={"small"}>
<code>{"{variantId}"}</code> - to inject id of the variant <code>{"{{ variant.id }}"}</code> - product variant id
</Text>
</li>
<li>
<Text size={"small"}>
<code>{"{productId}"}</code> - to inject id of the product
</Text> </Text>
</li> </li>
</ul> </ul>
<Paragraph size={"small"}>For example following pattern:</Paragraph> <Paragraph size={"small"}>For example following pattern:</Paragraph>
<Paragraph size={"small"}> <Paragraph size={"small"}>
<code>{"https://my-shop.com/p/{productSlug}/{variantId}"}</code> <code>{"https://my-shop.com/p/{{ variant.product.slug}/{{ variant.id }}"}</code>
</Paragraph> </Paragraph>
<Paragraph size={"small"}>Will produce:</Paragraph> <Paragraph size={"small"}>Will produce:</Paragraph>
<Paragraph size={"small"}> <Paragraph size={"small"}>
@ -120,6 +120,31 @@ const ConfigurationPage: NextPage = () => {
</Box> </Box>
} }
/> />
<AppSection
data-testid={"title-configuration-section"}
__marginBottom="100px"
includePadding
heading={"Item title"}
mainContent={
<Box>
<ConnectedTitleFormattingForm />
</Box>
}
sideContent={
<Box>
<Paragraph size={"small"}>
Customize title of the products. Use{" "}
<TextLink href="https://handlebarsjs.com/" newTab>
Handlebars
</TextLink>{" "}
format.
</Paragraph>
<TextLink href="https://support.google.com/merchants/answer/6324415" newTab>
Item title specification.
</TextLink>
</Box>
}
/>
<AppSection <AppSection
data-testid={"categories-mapping-section"} data-testid={"categories-mapping-section"}
__marginBottom="100px" __marginBottom="100px"

View file

@ -1,5 +1,9 @@
lockfileVersion: '6.0' lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers: importers:
.: .:
@ -943,6 +947,9 @@ importers:
graphql-tag: graphql-tag:
specifier: ^2.12.6 specifier: ^2.12.6
version: 2.12.6(graphql@16.7.1) version: 2.12.6(graphql@16.7.1)
handlebars:
specifier: ^4.7.7
version: 4.7.7
jsdom: jsdom:
specifier: ^20.0.3 specifier: ^20.0.3
version: 20.0.3 version: 20.0.3