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:
parent
0b0297eeb8
commit
6a84b4b7e0
25 changed files with 579 additions and 161 deletions
8
.changeset/blue-beans-battle.md
Normal file
8
.changeset/blue-beans-battle.md
Normal 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}}`
|
5
.changeset/brave-wasps-destroy.md
Normal file
5
.changeset/brave-wasps-destroy.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
"saleor-app-products-feed": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Added item title customization using Handlebars.
|
|
@ -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",
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 },
|
const config = await getConfig();
|
||||||
input,
|
|
||||||
}) => {
|
|
||||||
const config = await getConfig();
|
|
||||||
|
|
||||||
config.setAttributeMapping(input);
|
config.setAttributeMapping(input);
|
||||||
|
|
||||||
await appConfigMetadataManager.set(config.serialize());
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
),
|
|
||||||
|
|
||||||
|
await appConfigMetadataManager.set(config.serialize());
|
||||||
|
logger.debug("Attribute map set");
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
|
@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
|
@ -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,
|
||||||
|
};
|
||||||
|
};
|
|
@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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!"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -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));
|
|
|
@ -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",
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 }}",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
|
@ -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");
|
||||||
|
}
|
||||||
|
};
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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 }}");
|
|
@ -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");
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue