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",
|
||||
"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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,11 +1,19 @@
|
|||
import { z } from "zod";
|
||||
|
||||
const titleTemplateFieldSchema = z.string().default("{{variant.product.name}} - {{variant.name}}");
|
||||
|
||||
export const titleTemplateInputSchema = z.object({
|
||||
titleTemplate: titleTemplateFieldSchema,
|
||||
});
|
||||
|
||||
export type TitleTemplateInput = z.infer<typeof titleTemplateInputSchema>;
|
||||
|
||||
const attributeMappingSchema = z.object({
|
||||
brandAttributeIds: z.array(z.string()),
|
||||
colorAttributeIds: z.array(z.string()),
|
||||
sizeAttributeIds: z.array(z.string()),
|
||||
materialAttributeIds: z.array(z.string()),
|
||||
patternAttributeIds: z.array(z.string()),
|
||||
brandAttributeIds: z.array(z.string()).default([]),
|
||||
colorAttributeIds: z.array(z.string()).default([]),
|
||||
sizeAttributeIds: z.array(z.string()).default([]),
|
||||
materialAttributeIds: z.array(z.string()).default([]),
|
||||
patternAttributeIds: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
const s3ConfigSchema = z.object({
|
||||
|
@ -22,6 +30,7 @@ const urlConfigurationSchema = z.object({
|
|||
|
||||
const rootAppConfigSchema = z.object({
|
||||
s3: s3ConfigSchema.nullable(),
|
||||
titleTemplate: titleTemplateFieldSchema,
|
||||
attributeMapping: attributeMappingSchema.nullable(),
|
||||
channelConfig: z.record(z.object({ storefrontUrls: urlConfigurationSchema })),
|
||||
});
|
||||
|
@ -41,7 +50,8 @@ export class AppConfig {
|
|||
private rootData: RootConfig = {
|
||||
channelConfig: {},
|
||||
s3: null,
|
||||
attributeMapping: null,
|
||||
attributeMapping: attributeMappingSchema.parse({}),
|
||||
titleTemplate: titleTemplateFieldSchema.parse(undefined),
|
||||
};
|
||||
|
||||
constructor(initialData?: RootConfig) {
|
||||
|
@ -93,6 +103,8 @@ export class AppConfig {
|
|||
this.rootData.channelConfig[channelSlug] = {
|
||||
storefrontUrls: parsedConfig,
|
||||
};
|
||||
|
||||
return this;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
||||
|
@ -115,4 +127,14 @@ export class AppConfig {
|
|||
getAttributeMapping() {
|
||||
return this.rootData.attributeMapping;
|
||||
}
|
||||
|
||||
setTitleTemplate(titleTemplate: z.infer<typeof titleTemplateFieldSchema>) {
|
||||
this.rootData.titleTemplate = titleTemplate;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
getTitleTemplate() {
|
||||
return this.rootData.titleTemplate;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -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", () => {
|
||||
it("Generates feed", () => {
|
||||
const result = generateGoogleXmlFeed({
|
||||
productStorefrontUrl: "https://example.com/p/{{ variant.product.slug }}",
|
||||
shopDescription: "Description",
|
||||
shopName: "Example",
|
||||
storefrontUrl: "https://example.com",
|
||||
titleTemplate: "{{ variant.product.name }} - {{ variant.name }}",
|
||||
productVariants: [
|
||||
{
|
||||
id: "id1",
|
||||
__typename: "ProductVariant",
|
||||
sku: "sku1",
|
||||
quantityAvailable: 1,
|
||||
pricing: priceBase,
|
||||
name: "Product variant",
|
||||
product: productBase,
|
||||
attributes: [],
|
||||
},
|
||||
{
|
||||
id: "id2",
|
||||
__typename: "ProductVariant",
|
||||
sku: "sku2",
|
||||
quantityAvailable: 0,
|
||||
pricing: priceBase,
|
||||
name: "Product variant 2",
|
||||
product: productBase,
|
||||
attributes: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"<?xml version=\\"1.0\\" encoding=\\"utf-8\\"?>
|
||||
<rss xmlns:g=\\"http://base.google.com/ns/1.0\\" version=\\"2.0\\">
|
||||
<channel>
|
||||
<title>Example</title>
|
||||
<link>https://example.com</link>
|
||||
<description>Description</description>
|
||||
<item>
|
||||
<g:id>sku1</g:id>
|
||||
<g:item_group_id>product-id</g:item_group_id>
|
||||
<title>Product - Product variant</title>
|
||||
<g:condition>new</g:condition>
|
||||
<g:availability>in_stock</g:availability>
|
||||
<g:product_type>Category Name</g:product_type>
|
||||
<g:google_product_category>1</g:google_product_category>
|
||||
<link>https://example.com/p/product-slug</link>
|
||||
<g:price>2.00 USD</g:price>
|
||||
<g:sale_price>1.00 USD</g:sale_price>
|
||||
</item>
|
||||
<item>
|
||||
<g:id>sku2</g:id>
|
||||
<g:item_group_id>product-id</g:item_group_id>
|
||||
<title>Product - Product variant 2</title>
|
||||
<g:condition>new</g:condition>
|
||||
<g:availability>out_of_stock</g:availability>
|
||||
<g:product_type>Category Name</g:product_type>
|
||||
<g:google_product_category>1</g:google_product_category>
|
||||
<link>https://example.com/p/product-slug</link>
|
||||
<g:price>2.00 USD</g:price>
|
||||
<g:sale_price>1.00 USD</g:sale_price>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>"
|
||||
`);
|
||||
});
|
||||
|
||||
it("Generates feed with rendered urls, when provided old style URL template", () => {
|
||||
const result = generateGoogleXmlFeed({
|
||||
productStorefrontUrl: "https://example.com/p/{productSlug}",
|
||||
shopDescription: "Description",
|
||||
shopName: "Example",
|
||||
storefrontUrl: "https://example.com",
|
||||
titleTemplate: "{{ variant.product.name }} - {{ variant.name }}",
|
||||
productVariants: [
|
||||
{
|
||||
id: "id1",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 }}",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 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");
|
||||
|
|
|
@ -11,6 +11,7 @@ import { ChannelsConfigAccordion } from "../modules/app-configuration/channels-c
|
|||
import { useRouter } from "next/router";
|
||||
import { CategoryMappingPreview } from "../modules/category-mapping/ui/category-mapping-preview";
|
||||
import { ConnectedAttributeMappingForm } from "../modules/app-configuration/attribute-mapping-form";
|
||||
import { ConnectedTitleFormattingForm } from "../modules/app-configuration/title-formatting-form";
|
||||
|
||||
const ConfigurationPage: NextPage = () => {
|
||||
useChannelsExistenceChecking();
|
||||
|
@ -90,28 +91,27 @@ const ConfigurationPage: NextPage = () => {
|
|||
<Box>
|
||||
<Paragraph size={"small"}>App will generate separate feed for each channel</Paragraph>
|
||||
<Paragraph size={"small"}>
|
||||
Provide your storefront homepage URL and product template. Use following placeholders:
|
||||
Provide your storefront homepage URL and product template. Use{" "}
|
||||
<TextLink href="https://handlebarsjs.com/" newTab>
|
||||
Handlebars
|
||||
</TextLink>{" "}
|
||||
format. Example of the variables you can use:
|
||||
</Paragraph>
|
||||
<ul>
|
||||
<li>
|
||||
<Text size={"small"}>
|
||||
<code>{"{productSlug}"}</code> - to inject slug of the product
|
||||
<code>{"{{ variant.product.slug }}"}</code> - product `slug`
|
||||
</Text>
|
||||
</li>
|
||||
<li>
|
||||
<Text size={"small"}>
|
||||
<code>{"{variantId}"}</code> - to inject id of the variant
|
||||
</Text>
|
||||
</li>
|
||||
<li>
|
||||
<Text size={"small"}>
|
||||
<code>{"{productId}"}</code> - to inject id of the product
|
||||
<code>{"{{ variant.id }}"}</code> - product variant id
|
||||
</Text>
|
||||
</li>
|
||||
</ul>
|
||||
<Paragraph size={"small"}>For example following pattern:</Paragraph>
|
||||
<Paragraph size={"small"}>
|
||||
<code>{"https://my-shop.com/p/{productSlug}/{variantId}"}</code>
|
||||
<code>{"https://my-shop.com/p/{{ variant.product.slug}/{{ variant.id }}"}</code>
|
||||
</Paragraph>
|
||||
<Paragraph size={"small"}>Will produce:</Paragraph>
|
||||
<Paragraph size={"small"}>
|
||||
|
@ -120,6 +120,31 @@ const ConfigurationPage: NextPage = () => {
|
|||
</Box>
|
||||
}
|
||||
/>
|
||||
<AppSection
|
||||
data-testid={"title-configuration-section"}
|
||||
__marginBottom="100px"
|
||||
includePadding
|
||||
heading={"Item title"}
|
||||
mainContent={
|
||||
<Box>
|
||||
<ConnectedTitleFormattingForm />
|
||||
</Box>
|
||||
}
|
||||
sideContent={
|
||||
<Box>
|
||||
<Paragraph size={"small"}>
|
||||
Customize title of the products. Use{" "}
|
||||
<TextLink href="https://handlebarsjs.com/" newTab>
|
||||
Handlebars
|
||||
</TextLink>{" "}
|
||||
format.
|
||||
</Paragraph>
|
||||
<TextLink href="https://support.google.com/merchants/answer/6324415" newTab>
|
||||
Item title specification.
|
||||
</TextLink>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<AppSection
|
||||
data-testid={"categories-mapping-section"}
|
||||
__marginBottom="100px"
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
lockfileVersion: '6.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
|
@ -943,6 +947,9 @@ importers:
|
|||
graphql-tag:
|
||||
specifier: ^2.12.6
|
||||
version: 2.12.6(graphql@16.7.1)
|
||||
handlebars:
|
||||
specifier: ^4.7.7
|
||||
version: 4.7.7
|
||||
jsdom:
|
||||
specifier: ^20.0.3
|
||||
version: 20.0.3
|
||||
|
|
Loading…
Reference in a new issue