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",
"graphql": "16.7.1",
"graphql-tag": "^2.12.6",
"handlebars": "^4.7.7",
"jsdom": "^20.0.3",
"next": "13.4.8",
"pino": "^8.14.1",

View file

@ -9,7 +9,14 @@ describe("AppConfig", function () {
expect(instance.getRootConfig()).toEqual({
channelConfig: {},
s3: null,
attributeMapping: null,
attributeMapping: {
brandAttributeIds: [],
colorAttributeIds: [],
patternAttributeIds: [],
materialAttributeIds: [],
sizeAttributeIds: [],
},
titleTemplate: "{{variant.product.name}} - {{variant.name}}",
});
});
@ -25,7 +32,7 @@ describe("AppConfig", function () {
test: {
storefrontUrls: {
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: [],
sizeAttributeIds: [],
},
titleTemplate: "{{ variant.name }}",
});
expect(instance.getRootConfig()).toEqual({
@ -49,7 +57,7 @@ describe("AppConfig", function () {
test: {
storefrontUrls: {
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: [],
sizeAttributeIds: [],
},
titleTemplate: "{{ variant.name }}",
});
});
@ -89,6 +98,7 @@ describe("AppConfig", function () {
materialAttributeIds: [],
sizeAttributeIds: [],
},
titleTemplate: "{{ variant.name }}",
});
const serialized = instance1.serialize();
@ -110,6 +120,7 @@ describe("AppConfig", function () {
materialAttributeIds: [],
sizeAttributeIds: [],
},
titleTemplate: "{{ variant.name }}",
});
});
});
@ -126,7 +137,7 @@ describe("AppConfig", function () {
test: {
storefrontUrls: {
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: [],
sizeAttributeIds: ["size-id"],
},
titleTemplate: "{{ variant.product.name }} - {{ variant.name }}",
});
it("getRootConfig returns root config data", () => {
@ -151,7 +163,7 @@ describe("AppConfig", function () {
test: {
storefrontUrls: {
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: [],
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({
productStorefrontUrl: "https://example.com",
storefrontUrl: "https://example.com/p/{productFeed}",
storefrontUrl: "https://example.com/p/{{ variant.product.slug }}",
});
expect(instance.getUrlsForChannel("not-existing")).toBeUndefined();
@ -221,12 +234,12 @@ describe("AppConfig", function () {
instance.setChannelUrls("test", {
productStorefrontUrl: "https://example.com",
storefrontUrl: "https://example.com/p/{productFeed}",
storefrontUrl: "https://example.com/p/{{ variant.product.slug }}",
});
expect(instance.getUrlsForChannel("test")).toEqual({
productStorefrontUrl: "https://example.com",
storefrontUrl: "https://example.com/p/{productFeed}",
storefrontUrl: "https://example.com/p/{{ variant.product.slug }}",
});
// @ts-expect-error

View file

@ -1,11 +1,19 @@
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({
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()),
brandAttributeIds: z.array(z.string()).default([]),
colorAttributeIds: z.array(z.string()).default([]),
sizeAttributeIds: z.array(z.string()).default([]),
materialAttributeIds: z.array(z.string()).default([]),
patternAttributeIds: z.array(z.string()).default([]),
});
const s3ConfigSchema = z.object({
@ -22,6 +30,7 @@ const urlConfigurationSchema = z.object({
const rootAppConfigSchema = z.object({
s3: s3ConfigSchema.nullable(),
titleTemplate: titleTemplateFieldSchema,
attributeMapping: attributeMappingSchema.nullable(),
channelConfig: z.record(z.object({ storefrontUrls: urlConfigurationSchema })),
});
@ -41,7 +50,8 @@ export class AppConfig {
private rootData: RootConfig = {
channelConfig: {},
s3: null,
attributeMapping: null,
attributeMapping: attributeMappingSchema.parse({}),
titleTemplate: titleTemplateFieldSchema.parse(undefined),
};
constructor(initialData?: RootConfig) {
@ -93,6 +103,8 @@ export class AppConfig {
this.rootData.channelConfig[channelSlug] = {
storefrontUrls: parsedConfig,
};
return this;
} catch (e) {
console.error(e);
@ -115,4 +127,14 @@ export class AppConfig {
getAttributeMapping() {
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 { updateCacheForConfigurations } from "../metadata-cache/update-cache-for-configurations";
import { AppConfigSchema } from "./app-config";
import { AppConfigSchema, titleTemplateInputSchema } from "./app-config";
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";
import { renderHandlebarsTemplate } from "../handlebarsTemplates/render-handlebars-template";
import { prepareExampleVariantData } from "./prepare-example-variant-data";
export const appConfigurationRouter = router({
/**
* 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) => {
logger.debug("Fetched config");
@ -24,7 +26,7 @@ export const appConfigurationRouter = router({
testS3BucketConfiguration: protectedClientProcedure
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(AppConfigSchema.s3Bucket)
.mutation(async ({ ctx: { saleorApiUrl, getConfig, appConfigMetadataManager }, input }) => {
.mutation(async ({ ctx: { saleorApiUrl }, input }) => {
const logger = createLogger({ saleorApiUrl: saleorApiUrl });
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.
*
* 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({
client: apiClient,
@ -120,21 +122,16 @@ export const appConfigurationRouter = router({
setAttributeMapping: protectedClientProcedure
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(AppConfigSchema.attributeMapping)
.mutation(
async ({
ctx: { getConfig, apiClient, saleorApiUrl, appConfigMetadataManager, logger },
input,
}) => {
.mutation(async ({ ctx: { getConfig, appConfigMetadataManager, logger }, input }) => {
logger.debug("Setting attribute mapping");
const config = await getConfig();
config.setAttributeMapping(input);
await appConfigMetadataManager.set(config.serialize());
logger.debug("Attribute map set");
return null;
}
),
}),
getAttributes: protectedClientProcedure
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.query(async ({ ctx: { logger, apiClient } }) => {
@ -152,4 +149,59 @@ export const appConfigurationRouter = router({
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", () => {
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({
productStorefrontUrl: "https://example.com/p/{productSlug}",
shopDescription: "Description",
shopName: "Example",
storefrontUrl: "https://example.com",
titleTemplate: "{{ variant.product.name }} - {{ variant.name }}",
productVariants: [
{
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 { getMappedAttributes } from "./attribute-mapping";
import { priceMapping } from "./price-mapping";
import { renderHandlebarsTemplate } from "../handlebarsTemplates/render-handlebars-template";
import { transformTemplateFormat } from "../handlebarsTemplates/transform-template-format";
interface GenerateGoogleXmlFeedArgs {
productVariants: GoogleFeedProductVariantFragment[];
storefrontUrl: string;
productStorefrontUrl: string;
titleTemplate: string;
attributeMapping?: RootConfig["attributeMapping"];
shopName: string;
shopDescription?: string;
@ -20,6 +23,7 @@ export const generateGoogleXmlFeed = ({
attributeMapping,
productVariants,
storefrontUrl,
titleTemplate,
productStorefrontUrl,
shopName,
shopDescription,
@ -32,10 +36,34 @@ export const generateGoogleXmlFeed = ({
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({
storefrontUrlTemplate: productStorefrontUrl,
link,
title: title || "",
id: variant.product.id,
name: `${variant.product.name} - ${variant.name}`,
slug: variant.product.slug,
variantId: variant.id,
sku: variant.sku || undefined,

View file

@ -10,13 +10,15 @@ describe("GoogleFeedSettingsFetcher", () => {
testChannel: {
storefrontUrls: {
storefrontUrl: "https://example.com",
productStorefrontUrl: "https://example.com/p/{productSlug}/v/{variantId}",
productStorefrontUrl:
"https://example.com/p/{{ variant.product.slug }}/v/{{ variant.slug }}",
},
},
anotherChannel: {
storefrontUrls: {
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",
},
attributeMapping: null,
titleTemplate: "{{ variant.name }}",
});
return appConfig.serialize();
@ -42,7 +45,7 @@ describe("GoogleFeedSettingsFetcher", () => {
expect(result).toEqual({
storefrontUrl: "https://example.com",
productStorefrontUrl: "https://example.com/p/{productSlug}/v/{variantId}",
productStorefrontUrl: "https://example.com/p/{{ variant.product.slug }}/v/{{ variant.slug }}",
s3BucketConfiguration: {
bucketName: "bucketName",
secretAccessKey: "secretAccessKey",
@ -50,6 +53,7 @@ describe("GoogleFeedSettingsFetcher", () => {
region: "region",
},
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 { 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 {
static createFromAuthData(authData: AuthData) {
return new GoogleFeedSettingsFetcher({
@ -85,6 +45,7 @@ export class GoogleFeedSettingsFetcher {
productStorefrontUrl,
s3BucketConfiguration: appConfig.getS3Config(),
attributeMapping: appConfig.getAttributeMapping(),
titleTemplate: appConfig.getTitleTemplate(),
};
}
}

View file

@ -9,7 +9,7 @@ describe("productToProxy", () => {
category: "1",
condition: "new",
id: "product-id",
name: "Name",
title: "title",
variantId: "variant-id",
});
@ -32,7 +32,7 @@ describe("productToProxy", () => {
* condition: "new",
*/
id: "product-id",
name: "Name",
title: "title",
variantId: "variant-id",
});
@ -52,7 +52,7 @@ describe("productToProxy", () => {
category: "1",
condition: "new",
id: "product-id",
name: "Name",
title: "title",
variantId: "variant-id",
description: "Product description",
});
@ -74,7 +74,7 @@ describe("productToProxy", () => {
condition: "new",
googleProductCategory: "1",
id: "product-id",
name: "Name",
title: "title",
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({
slug: "slug",
availability: "in_stock",
@ -95,9 +95,9 @@ describe("productToProxy", () => {
condition: "new",
googleProductCategory: "1",
id: "product-id",
name: "Name",
title: "title",
variantId: "variant-id",
storefrontUrlTemplate: "https://example.com/p/{productSlug}/{productId}/{variantId}",
link: "https://example.com/p/product-id",
});
expect(result.item).toEqual(
@ -105,7 +105,7 @@ describe("productToProxy", () => {
{
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",
googleProductCategory: "1",
id: "product-id",
name: "Name",
title: "title",
variantId: "variant-id",
imageUrl: "https://image.example.com",
});
@ -143,7 +143,7 @@ describe("productToProxy", () => {
condition: "new",
googleProductCategory: "1",
id: "product-id",
name: "Name",
title: "title",
variantId: "variant-id",
imageUrl: "https://image.example.com",
price: "50.00 USD",

View file

@ -1,4 +1,3 @@
import { fillUrlTemplate } from "../feed-url/fill-url-template";
import { GoogleProxyItem, ProductEntry } from "./types";
export const productToProxy = (p: ProductEntry) => {
@ -20,7 +19,7 @@ export const productToProxy = (p: ProductEntry) => {
{
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({
link: [
{
"#text": fillUrlTemplate({
urlTemplate: p.storefrontUrlTemplate,
productId: p.id,
productSlug: p.slug,
variantId: p.variantId,
}),
"#text": p.link,
},
],
});

View file

@ -1,11 +1,11 @@
export type ProductEntry = {
id: string;
title: string;
sku?: string;
name: string;
slug: string;
variantId: string;
description?: string;
storefrontUrlTemplate?: string;
link?: string;
imageUrl?: string;
condition?: "new" | "refurbished" | "used";
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 bucketConfiguration: RootConfig["s3"] | undefined;
let attributeMapping: RootConfig["attributeMapping"] | undefined;
let titleTemplate: RootConfig["titleTemplate"] | undefined;
try {
const settingsFetcher = GoogleFeedSettingsFetcher.createFromAuthData(authData);
@ -90,6 +91,7 @@ export const handler = async (req: NextApiRequest, res: NextApiResponse) => {
productStorefrontUrl = settings.productStorefrontUrl;
bucketConfiguration = settings.s3BucketConfiguration;
attributeMapping = settings.attributeMapping;
titleTemplate = settings.titleTemplate;
} catch (error) {
logger.warn("The application has not been configured");
@ -184,6 +186,7 @@ export const handler = async (req: NextApiRequest, res: NextApiResponse) => {
productStorefrontUrl,
productVariants,
attributeMapping,
titleTemplate,
});
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 { CategoryMappingPreview } from "../modules/category-mapping/ui/category-mapping-preview";
import { ConnectedAttributeMappingForm } from "../modules/app-configuration/attribute-mapping-form";
import { ConnectedTitleFormattingForm } from "../modules/app-configuration/title-formatting-form";
const ConfigurationPage: NextPage = () => {
useChannelsExistenceChecking();
@ -90,28 +91,27 @@ const ConfigurationPage: NextPage = () => {
<Box>
<Paragraph size={"small"}>App will generate separate feed for each channel</Paragraph>
<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>
<ul>
<li>
<Text size={"small"}>
<code>{"{productSlug}"}</code> - to inject slug of the product
<code>{"{{ variant.product.slug }}"}</code> - product `slug`
</Text>
</li>
<li>
<Text size={"small"}>
<code>{"{variantId}"}</code> - to inject id of the variant
</Text>
</li>
<li>
<Text size={"small"}>
<code>{"{productId}"}</code> - to inject id of the product
<code>{"{{ variant.id }}"}</code> - product variant id
</Text>
</li>
</ul>
<Paragraph size={"small"}>For example following pattern:</Paragraph>
<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 size={"small"}>Will produce:</Paragraph>
<Paragraph size={"small"}>
@ -120,6 +120,31 @@ const ConfigurationPage: NextPage = () => {
</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
data-testid={"categories-mapping-section"}
__marginBottom="100px"

View file

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