Skip to content

Commit

Permalink
Product Feed: Add configurable image size and additional photos (#915)
Browse files Browse the repository at this point in the history
* Add configurable image size and additional photos

* CR fixes

* CSpell fixes
  • Loading branch information
krzysztofwolski committed Sep 1, 2023
1 parent 0aa1d12 commit 261e9d1
Show file tree
Hide file tree
Showing 23 changed files with 483 additions and 43 deletions.
5 changes: 5 additions & 0 deletions .changeset/quiet-cups-kneel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"saleor-app-products-feed": minor
---

Added additional images attribute to the feed for media uploaded to the product.
6 changes: 6 additions & 0 deletions .changeset/sharp-buses-reflect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"saleor-app-products-feed": patch
---

Improved default resolution of the submitted images. Was: 500px, now it's 1024px.
Users can now configure the size in the app configuration.
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,21 @@ fragment GoogleFeedProductVariant on ProductVariant {
slug
description
seoDescription
media{
id
alt
url(size: $imageSize)
type
}
variants{
id
media{
id
alt
url(size: $imageSize)
type
}
}
attributes{
attribute{
id
Expand All @@ -41,7 +56,7 @@ fragment GoogleFeedProductVariant on ProductVariant {
name
}
}
thumbnail {
thumbnail(size: $imageSize) {
url
}
category {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
query FetchProductDataForFeed($first:Int!, $after: String, $channel: String!){
query FetchProductDataForFeed($first:Int!, $after: String, $channel: String!, $imageSize: Int = 1024){
productVariants(first:$first, after: $after, channel: $channel){
pageInfo{
hasNextPage
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,14 @@ const exampleAttributeMappingConfig: RootConfig["attributeMapping"] = {
const exampleTitleTemplate: RootConfig["titleTemplate"] =
"Example {{ variant.product.name }} - {{ variant.name }}";

const exampleImageSize: RootConfig["imageSize"] = 1024;

const exampleConfiguration: RootConfig = {
channelConfig: exampleChannelConfig,
s3: exampleS3Config,
attributeMapping: exampleAttributeMappingConfig,
titleTemplate: exampleTitleTemplate,
imageSize: exampleImageSize,
};

describe("AppConfig", function () {
Expand All @@ -51,6 +54,7 @@ describe("AppConfig", function () {
sizeAttributeIds: [],
},
titleTemplate: "{{variant.product.name}} - {{variant.name}}",
imageSize: 1024,
});
});

Expand All @@ -60,13 +64,15 @@ describe("AppConfig", function () {
expect(instance.getRootConfig()).toEqual(exampleConfiguration);
});

it("Fill attribute mapping and title template with default values, when initial data are lacking those fields", () => {
it("Fill attribute mapping, image size and title template with default values, when initial data are lacking those fields", () => {
const configurationWithoutMapping = structuredClone(exampleConfiguration);

// @ts-expect-error: Simulating data before the migration
delete configurationWithoutMapping.attributeMapping;
// @ts-expect-error
delete configurationWithoutMapping.titleTemplate;
// @ts-expect-error
delete configurationWithoutMapping.imageSize;

const instance = new AppConfig(configurationWithoutMapping as any); // Casting used to prevent TS from reporting an error

Expand All @@ -80,6 +86,7 @@ describe("AppConfig", function () {
sizeAttributeIds: [],
},
titleTemplate: "{{variant.product.name}} - {{variant.name}}",
imageSize: 1024,
});
});

Expand Down Expand Up @@ -110,6 +117,7 @@ describe("AppConfig", function () {
sizeAttributeIds: [],
},
titleTemplate: "{{ variant.name }}",
imageSize: 1024,
});

const serialized = instance1.serialize();
Expand All @@ -132,6 +140,7 @@ describe("AppConfig", function () {
sizeAttributeIds: [],
},
titleTemplate: "{{ variant.name }}",
imageSize: 1024,
});
});
});
Expand Down Expand Up @@ -160,6 +169,7 @@ describe("AppConfig", function () {
sizeAttributeIds: ["size-id"],
},
titleTemplate: "{{ variant.product.name }} - {{ variant.name }}",
imageSize: 1024,
});

it("getRootConfig returns root config data", () => {
Expand All @@ -186,6 +196,7 @@ describe("AppConfig", function () {
sizeAttributeIds: ["size-id"],
},
titleTemplate: "{{ variant.product.name }} - {{ variant.name }}",
imageSize: 1024,
});
});

Expand Down
20 changes: 20 additions & 0 deletions apps/products-feed/src/modules/app-configuration/app-config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { createLogger } from "@saleor/apps-shared";
import { z } from "zod";

const imageSizeFieldSchema = z.coerce.number().gte(256).default(1024);

export const imageSizeInputSchema = z.object({
imageSize: imageSizeFieldSchema,
});

export type ImageSizeInput = z.infer<typeof imageSizeInputSchema>;

const titleTemplateFieldSchema = z.string().default("{{variant.product.name}} - {{variant.name}}");

export const titleTemplateInputSchema = z.object({
Expand Down Expand Up @@ -34,6 +42,7 @@ const rootAppConfigSchema = z.object({
titleTemplate: titleTemplateFieldSchema
.optional()
.default(titleTemplateFieldSchema.parse(undefined)),
imageSize: imageSizeFieldSchema.optional().default(imageSizeFieldSchema.parse(undefined)),
attributeMapping: attributeMappingSchema
.nullable()
.optional()
Expand All @@ -60,6 +69,7 @@ export class AppConfig {
s3: null,
attributeMapping: attributeMappingSchema.parse({}),
titleTemplate: titleTemplateFieldSchema.parse(undefined),
imageSize: imageSizeFieldSchema.parse(undefined),
};

constructor(initialData?: RootConfig) {
Expand Down Expand Up @@ -147,4 +157,14 @@ export class AppConfig {
getTitleTemplate() {
return this.rootData.titleTemplate;
}

setImageSize(imageSize: z.infer<typeof imageSizeFieldSchema>) {
this.rootData.imageSize = imageSize;

return this;
}

getImageSize() {
return this.rootData.imageSize;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { protectedClientProcedure } from "../trpc/protected-client-procedure";
import { createLogger } from "@saleor/apps-shared";

import { updateCacheForConfigurations } from "../metadata-cache/update-cache-for-configurations";
import { AppConfigSchema, titleTemplateInputSchema } from "./app-config";
import { AppConfigSchema, imageSizeInputSchema, 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";
Expand Down Expand Up @@ -158,6 +158,21 @@ export const appConfigurationRouter = router({

return result;
}),
setImageSize: protectedClientProcedure
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(imageSizeInputSchema)
.mutation(async ({ ctx: { getConfig, appConfigMetadataManager, logger }, input }) => {
logger.debug("Setting image size");
const config = await getConfig();

config.setImageSize(input.imageSize);

await appConfigMetadataManager.set(config.serialize());

logger.debug("image size set");
return null;
}),

setTitleTemplate: protectedClientProcedure
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(titleTemplateInputSchema)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { ImageSizeInput, imageSizeInputSchema } from "./app-config";
import { useForm } from "react-hook-form";

import { Box, Button, Text } from "@saleor/macaw-ui/next";

import React, { useCallback, useMemo } from "react";
import { Select } 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: ImageSizeInput;
onSubmit(data: ImageSizeInput): Promise<void>;
};

const imageSizeOptions = [
{ value: "256", label: "256px" },
{ value: "512", label: "512px" },
{ value: "1024", label: "1024px" },
{ value: "2048", label: "2048px" },
{ value: "4096", label: "4096px" },
];

export const ImageConfigurationForm = (props: Props) => {
const { handleSubmit, control, formState } = useForm<ImageSizeInput>({
defaultValues: props.initialData,
resolver: zodResolver(imageSizeInputSchema),
});

return (
<Box
as={"form"}
display={"flex"}
gap={5}
flexDirection={"column"}
onSubmit={handleSubmit(props.onSubmit)}
>
<Select control={control} name="imageSize" label="Image size" options={imageSizeOptions} />
{!!formState.errors.imageSize?.message && (
<Text variant="caption" color={"textCriticalSubdued"}>
{formState.errors.imageSize?.message}
</Text>
)}
<Box display={"flex"} flexDirection={"row"} gap={4} justifyContent={"flex-end"}>
<Button type="submit" variant="primary">
Save {props.initialData.imageSize}
</Button>
</Box>
</Box>
);
};

export const ConnectedImageConfigurationForm = () => {
const { notifyError, notifySuccess } = useDashboardNotification();

const { data, isLoading } = trpcClient.appConfiguration.fetch.useQuery();

const { mutate } = trpcClient.appConfiguration.setImageSize.useMutation({
onSuccess() {
notifySuccess("Success", "Updated image size");
},
onError() {
notifyError("Error", "Failed to update, please refresh and try again");
},
});

const handleSubmit = useCallback(
async (data: ImageSizeInput) => {
mutate(data);
},
[mutate],
);

const formData: ImageSizeInput = useMemo(() => {
if (data?.imageSize) {
return {
imageSize: data.imageSize,
};
}

return imageSizeInputSchema.parse({});
}, [data]);

if (isLoading) {
return <Text>Loading...</Text>;
}

return (
<>
{!isLoading ? (
<ImageConfigurationForm onSubmit={handleSubmit} initialData={formData} />
) : (
<Box>Loading</Box>
)}
</>
);
};
15 changes: 13 additions & 2 deletions apps/products-feed/src/modules/google-feed/fetch-product-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,12 @@ const fetchVariants = async ({
client,
after,
channel,
imageSize,
}: {
client: Client;
after?: string;
channel: string;
imageSize?: number;
}): Promise<GoogleFeedProductVariantFragment[]> => {
const logger = createLogger({ saleorApiUrl: url, channel, fn: "fetchVariants" });

Expand All @@ -54,6 +56,7 @@ const fetchVariants = async ({
channel: channel,
first: 100,
after,
imageSize,
})
.toPromise();

Expand All @@ -69,9 +72,15 @@ interface FetchProductDataArgs {
client: Client;
channel: string;
cursors?: Array<string>;
imageSize?: number;
}

export const fetchProductData = async ({ client, channel, cursors }: FetchProductDataArgs) => {
export const fetchProductData = async ({
client,
channel,
cursors,
imageSize,
}: FetchProductDataArgs) => {
const logger = createLogger({ saleorApiUrl: url, channel, route: "Google Product Feed" });

const cachedCursors = cursors || (await getCursors({ client, channel }));
Expand All @@ -80,7 +89,9 @@ export const fetchProductData = async ({ client, channel, cursors }: FetchProduc

logger.debug(`Query generated ${pageCursors.length} cursors`);

const promises = pageCursors.map((cursor) => fetchVariants({ client, after: cursor, channel }));
const promises = pageCursors.map((cursor) =>
fetchVariants({ client, after: cursor, channel, imageSize }),
);

const results = await Promise.all(promises);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { priceMapping } from "./price-mapping";
import { renderHandlebarsTemplate } from "../handlebarsTemplates/render-handlebars-template";
import { transformTemplateFormat } from "../handlebarsTemplates/transform-template-format";
import { EditorJsPlaintextRenderer } from "@saleor/apps-shared";
import { getRelatedMedia, getVariantMediaMap } from "./get-related-media";

interface GenerateGoogleXmlFeedArgs {
productVariants: GoogleFeedProductVariantFragment[];
Expand Down Expand Up @@ -50,6 +51,12 @@ export const generateGoogleXmlFeed = ({

let link = undefined;

const { additionalImages, thumbnailUrl } = getRelatedMedia({
productMedia: variant.product.media || [],
productVariantId: variant.id,
variantMediaMap: getVariantMediaMap({ variant }) || [],
});

try {
link = renderHandlebarsTemplate({
data: {
Expand All @@ -72,7 +79,8 @@ export const generateGoogleXmlFeed = ({
variant.quantityAvailable && variant.quantityAvailable > 0 ? "in_stock" : "out_of_stock",
category: variant.product.category?.name || "unknown",
googleProductCategory: variant.product.category?.googleCategoryId || "",
imageUrl: variant.product.thumbnail?.url || "",
imageUrl: thumbnailUrl,
additionalImageLinks: additionalImages,
material: attributes?.material,
color: attributes?.color,
brand: attributes?.brand,
Expand Down Expand Up @@ -115,7 +123,7 @@ export const generateGoogleXmlFeed = ({
{
rss: [
{
// @ts-ignore - This is "just an object" that is transformed to XML. I dont see good way to type it, other than "any"
// @ts-ignore - This is "just an object" that is transformed to XML. I don't see good way to type it, other than "any"
channel: channelData.concat(items),
},
],
Expand Down

0 comments on commit 261e9d1

Please sign in to comment.