From 6a84b4b7e0b9df9f2b58c01bd4661e042c92afa7 Mon Sep 17 00:00:00 2001 From: Krzysztof Wolski Date: Mon, 7 Aug 2023 16:33:01 +0200 Subject: [PATCH] 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 --- .changeset/blue-beans-battle.md | 8 ++ .changeset/brave-wasps-destroy.md | 5 + apps/products-feed/package.json | 1 + .../app-configuration/app-config.test.ts | 31 +++-- .../modules/app-configuration/app-config.ts | 34 ++++- .../app-configuration.router.ts | 86 +++++++++--- .../app-configuration/example-variant-data.ts | 58 ++++++++ .../prepare-example-variant-data.ts | 19 +++ .../title-formatting-form.tsx | 127 ++++++++++++++++++ .../feed-url/fill-url-template.test.ts | 37 ----- .../src/modules/feed-url/fill-url-template.ts | 17 --- .../generate-google-xml-feed.test.ts | 68 ++++++++++ .../google-feed/generate-google-xml-feed.ts | 32 ++++- .../get-google-feed-settings.test.ts | 10 +- .../google-feed/get-google-feed-settings.ts | 41 +----- .../google-feed/product-to-proxy.test.ts | 20 +-- .../modules/google-feed/product-to-proxy.ts | 12 +- .../src/modules/google-feed/types.ts | 4 +- .../render-handlebars-template.test.ts | 21 +++ .../render-handlebars-template.ts | 24 ++++ .../transform-template-format.test.ts | 17 +++ .../transform-template-format.ts | 15 +++ .../api/feed/[url]/[channel]/google.xml.ts | 3 + .../products-feed/src/pages/configuration.tsx | 43 ++++-- pnpm-lock.yaml | 7 + 25 files changed, 579 insertions(+), 161 deletions(-) create mode 100644 .changeset/blue-beans-battle.md create mode 100644 .changeset/brave-wasps-destroy.md create mode 100644 apps/products-feed/src/modules/app-configuration/example-variant-data.ts create mode 100644 apps/products-feed/src/modules/app-configuration/prepare-example-variant-data.ts create mode 100644 apps/products-feed/src/modules/app-configuration/title-formatting-form.tsx delete mode 100644 apps/products-feed/src/modules/feed-url/fill-url-template.test.ts delete mode 100644 apps/products-feed/src/modules/feed-url/fill-url-template.ts create mode 100644 apps/products-feed/src/modules/handlebarsTemplates/render-handlebars-template.test.ts create mode 100644 apps/products-feed/src/modules/handlebarsTemplates/render-handlebars-template.ts create mode 100644 apps/products-feed/src/modules/handlebarsTemplates/transform-template-format.test.ts create mode 100644 apps/products-feed/src/modules/handlebarsTemplates/transform-template-format.ts diff --git a/.changeset/blue-beans-battle.md b/.changeset/blue-beans-battle.md new file mode 100644 index 0000000..889f545 --- /dev/null +++ b/.changeset/blue-beans-battle.md @@ -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}}` diff --git a/.changeset/brave-wasps-destroy.md b/.changeset/brave-wasps-destroy.md new file mode 100644 index 0000000..db90bcf --- /dev/null +++ b/.changeset/brave-wasps-destroy.md @@ -0,0 +1,5 @@ +--- +"saleor-app-products-feed": minor +--- + +Added item title customization using Handlebars. diff --git a/apps/products-feed/package.json b/apps/products-feed/package.json index 5dd1cec..d0e7c56 100644 --- a/apps/products-feed/package.json +++ b/apps/products-feed/package.json @@ -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", diff --git a/apps/products-feed/src/modules/app-configuration/app-config.test.ts b/apps/products-feed/src/modules/app-configuration/app-config.test.ts index 55a1994..5e2f33b 100644 --- a/apps/products-feed/src/modules/app-configuration/app-config.test.ts +++ b/apps/products-feed/src/modules/app-configuration/app-config.test.ts @@ -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 diff --git a/apps/products-feed/src/modules/app-configuration/app-config.ts b/apps/products-feed/src/modules/app-configuration/app-config.ts index edc9504..91c8625 100644 --- a/apps/products-feed/src/modules/app-configuration/app-config.ts +++ b/apps/products-feed/src/modules/app-configuration/app-config.ts @@ -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; + 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) { + this.rootData.titleTemplate = titleTemplate; + + return this; + } + + getTitleTemplate() { + return this.rootData.titleTemplate; + } } diff --git a/apps/products-feed/src/modules/app-configuration/app-configuration.router.ts b/apps/products-feed/src/modules/app-configuration/app-configuration.router.ts index 0969baa..7d1f30e 100644 --- a/apps/products-feed/src/modules/app-configuration/app-configuration.router.ts +++ b/apps/products-feed/src/modules/app-configuration/app-configuration.router.ts @@ -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, - }) => { - const config = await getConfig(); + .mutation(async ({ ctx: { getConfig, appConfigMetadataManager, logger }, input }) => { + logger.debug("Setting attribute mapping"); + const config = await getConfig(); - config.setAttributeMapping(input); - - await appConfigMetadataManager.set(config.serialize()); - - return null; - } - ), + 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", + }); + } + }), }); diff --git a/apps/products-feed/src/modules/app-configuration/example-variant-data.ts b/apps/products-feed/src/modules/app-configuration/example-variant-data.ts new file mode 100644 index 0000000..379b8c8 --- /dev/null +++ b/apps/products-feed/src/modules/app-configuration/example-variant-data.ts @@ -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": "Ever have those days where you feel a bit geometric? 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", + }, + }, +}; diff --git a/apps/products-feed/src/modules/app-configuration/prepare-example-variant-data.ts b/apps/products-feed/src/modules/app-configuration/prepare-example-variant-data.ts new file mode 100644 index 0000000..8c8d79f --- /dev/null +++ b/apps/products-feed/src/modules/app-configuration/prepare-example-variant-data.ts @@ -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, + }; +}; diff --git a/apps/products-feed/src/modules/app-configuration/title-formatting-form.tsx b/apps/products-feed/src/modules/app-configuration/title-formatting-form.tsx new file mode 100644 index 0000000..c625ddd --- /dev/null +++ b/apps/products-feed/src/modules/app-configuration/title-formatting-form.tsx @@ -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; + onPreview(data: TitleTemplateInput): Promise; +}; + +export const TitleFormattingConfigurationForm = (props: Props) => { + const { handleSubmit, control, getValues } = useForm({ + defaultValues: props.initialData, + resolver: zodResolver(AppConfigSchema.attributeMapping), + }); + + return ( + { + props.onSubmit(data); + })} + > + + {props.preview?.length && {props.preview}} + + + + + + ); +}; + +export const ConnectedTitleFormattingForm = () => { + const { notifyError, notifySuccess } = useDashboardNotification(); + const [preview, setPreview] = useState(); + + 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 Loading...; + } + + const showForm = !isLoading && attributes?.length; + + return ( + <> + {showForm ? ( + + ) : ( + Loading + )} + + ); +}; diff --git a/apps/products-feed/src/modules/feed-url/fill-url-template.test.ts b/apps/products-feed/src/modules/feed-url/fill-url-template.test.ts deleted file mode 100644 index befb30f..0000000 --- a/apps/products-feed/src/modules/feed-url/fill-url-template.test.ts +++ /dev/null @@ -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!" - ); - }); -}); diff --git a/apps/products-feed/src/modules/feed-url/fill-url-template.ts b/apps/products-feed/src/modules/feed-url/fill-url-template.ts deleted file mode 100644 index 438846c..0000000 --- a/apps/products-feed/src/modules/feed-url/fill-url-template.ts +++ /dev/null @@ -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)); diff --git a/apps/products-feed/src/modules/google-feed/generate-google-xml-feed.test.ts b/apps/products-feed/src/modules/google-feed/generate-google-xml-feed.test.ts index d768a7b..c10ab42 100644 --- a/apps/products-feed/src/modules/google-feed/generate-google-xml-feed.test.ts +++ b/apps/products-feed/src/modules/google-feed/generate-google-xml-feed.test.ts @@ -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(` + " + + + Example + https://example.com + Description + + sku1 + product-id + Product - Product variant + new + in_stock + Category Name + 1 + https://example.com/p/product-slug + 2.00 USD + 1.00 USD + + + sku2 + product-id + Product - Product variant 2 + new + out_of_stock + Category Name + 1 + https://example.com/p/product-slug + 2.00 USD + 1.00 USD + + + " + `); + }); + + 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", diff --git a/apps/products-feed/src/modules/google-feed/generate-google-xml-feed.ts b/apps/products-feed/src/modules/google-feed/generate-google-xml-feed.ts index 3c95781..7c10563 100644 --- a/apps/products-feed/src/modules/google-feed/generate-google-xml-feed.ts +++ b/apps/products-feed/src/modules/google-feed/generate-google-xml-feed.ts @@ -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, diff --git a/apps/products-feed/src/modules/google-feed/get-google-feed-settings.test.ts b/apps/products-feed/src/modules/google-feed/get-google-feed-settings.test.ts index a83318a..eb8e1fb 100644 --- a/apps/products-feed/src/modules/google-feed/get-google-feed-settings.test.ts +++ b/apps/products-feed/src/modules/google-feed/get-google-feed-settings.test.ts @@ -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 }}", }); }); }); diff --git a/apps/products-feed/src/modules/google-feed/get-google-feed-settings.ts b/apps/products-feed/src/modules/google-feed/get-google-feed-settings.ts index dfd99c3..4a3ed38 100644 --- a/apps/products-feed/src/modules/google-feed/get-google-feed-settings.ts +++ b/apps/products-feed/src/modules/google-feed/get-google-feed-settings.ts @@ -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(), }; } } diff --git a/apps/products-feed/src/modules/google-feed/product-to-proxy.test.ts b/apps/products-feed/src/modules/google-feed/product-to-proxy.test.ts index edeab11..ac6ad8e 100644 --- a/apps/products-feed/src/modules/google-feed/product-to-proxy.test.ts +++ b/apps/products-feed/src/modules/google-feed/product-to-proxy.test.ts @@ -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", diff --git a/apps/products-feed/src/modules/google-feed/product-to-proxy.ts b/apps/products-feed/src/modules/google-feed/product-to-proxy.ts index 77f8e16..e8adf33 100644 --- a/apps/products-feed/src/modules/google-feed/product-to-proxy.ts +++ b/apps/products-feed/src/modules/google-feed/product-to-proxy.ts @@ -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, }, ], }); diff --git a/apps/products-feed/src/modules/google-feed/types.ts b/apps/products-feed/src/modules/google-feed/types.ts index 9ae378a..65bd87e 100644 --- a/apps/products-feed/src/modules/google-feed/types.ts +++ b/apps/products-feed/src/modules/google-feed/types.ts @@ -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; diff --git a/apps/products-feed/src/modules/handlebarsTemplates/render-handlebars-template.test.ts b/apps/products-feed/src/modules/handlebarsTemplates/render-handlebars-template.test.ts new file mode 100644 index 0000000..56e292e --- /dev/null +++ b/apps/products-feed/src/modules/handlebarsTemplates/render-handlebars-template.test.ts @@ -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"); + }); +}); diff --git a/apps/products-feed/src/modules/handlebarsTemplates/render-handlebars-template.ts b/apps/products-feed/src/modules/handlebarsTemplates/render-handlebars-template.ts new file mode 100644 index 0000000..dbe2d6d --- /dev/null +++ b/apps/products-feed/src/modules/handlebarsTemplates/render-handlebars-template.ts @@ -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; +} + +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"); + } +}; diff --git a/apps/products-feed/src/modules/handlebarsTemplates/transform-template-format.test.ts b/apps/products-feed/src/modules/handlebarsTemplates/transform-template-format.test.ts new file mode 100644 index 0000000..41e1ebd --- /dev/null +++ b/apps/products-feed/src/modules/handlebarsTemplates/transform-template-format.test.ts @@ -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); + }); +}); diff --git a/apps/products-feed/src/modules/handlebarsTemplates/transform-template-format.ts b/apps/products-feed/src/modules/handlebarsTemplates/transform-template-format.ts new file mode 100644 index 0000000..596445a --- /dev/null +++ b/apps/products-feed/src/modules/handlebarsTemplates/transform-template-format.ts @@ -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 }}"); diff --git a/apps/products-feed/src/pages/api/feed/[url]/[channel]/google.xml.ts b/apps/products-feed/src/pages/api/feed/[url]/[channel]/google.xml.ts index cb9970e..05b6eb6 100644 --- a/apps/products-feed/src/pages/api/feed/[url]/[channel]/google.xml.ts +++ b/apps/products-feed/src/pages/api/feed/[url]/[channel]/google.xml.ts @@ -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"); diff --git a/apps/products-feed/src/pages/configuration.tsx b/apps/products-feed/src/pages/configuration.tsx index e0d9e30..5bb9069 100644 --- a/apps/products-feed/src/pages/configuration.tsx +++ b/apps/products-feed/src/pages/configuration.tsx @@ -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 = () => { App will generate separate feed for each channel - Provide your storefront homepage URL and product template. Use following placeholders: + Provide your storefront homepage URL and product template. Use{" "} + + Handlebars + {" "} + format. Example of the variables you can use:
  • - {"{productSlug}"} - to inject slug of the product + {"{{ variant.product.slug }}"} - product `slug`
  • - {"{variantId}"} - to inject id of the variant - -
  • -
  • - - {"{productId}"} - to inject id of the product + {"{{ variant.id }}"} - product variant id
For example following pattern: - {"https://my-shop.com/p/{productSlug}/{variantId}"} + {"https://my-shop.com/p/{{ variant.product.slug}/{{ variant.id }}"} Will produce: @@ -120,6 +120,31 @@ const ConfigurationPage: NextPage = () => {
} /> + + + + } + sideContent={ + + + Customize title of the products. Use{" "} + + Handlebars + {" "} + format. + + + Item title specification. + + + } + />