diff --git a/.changeset/quiet-cups-kneel.md b/.changeset/quiet-cups-kneel.md new file mode 100644 index 0000000..e7371ea --- /dev/null +++ b/.changeset/quiet-cups-kneel.md @@ -0,0 +1,5 @@ +--- +"saleor-app-products-feed": minor +--- + +Added additional images attribute to the feed for media uploaded to the product. diff --git a/.changeset/sharp-buses-reflect.md b/.changeset/sharp-buses-reflect.md new file mode 100644 index 0000000..4fc2600 --- /dev/null +++ b/.changeset/sharp-buses-reflect.md @@ -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. diff --git a/apps/products-feed/graphql/fragments/GoogleFeedProductVariantFragment.graphql b/apps/products-feed/graphql/fragments/GoogleFeedProductVariantFragment.graphql index 2ec4d2f..8427568 100644 --- a/apps/products-feed/graphql/fragments/GoogleFeedProductVariantFragment.graphql +++ b/apps/products-feed/graphql/fragments/GoogleFeedProductVariantFragment.graphql @@ -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 @@ -41,7 +56,7 @@ fragment GoogleFeedProductVariant on ProductVariant { name } } - thumbnail { + thumbnail(size: $imageSize) { url } category { diff --git a/apps/products-feed/graphql/queries/FetchProductDataForFeed.graphql b/apps/products-feed/graphql/queries/FetchProductDataForFeed.graphql index a336ba0..b438a6f 100644 --- a/apps/products-feed/graphql/queries/FetchProductDataForFeed.graphql +++ b/apps/products-feed/graphql/queries/FetchProductDataForFeed.graphql @@ -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 diff --git a/apps/products-feed/src/lib/create-graphq-client.ts b/apps/products-feed/src/lib/create-graphql-client.ts similarity index 100% rename from apps/products-feed/src/lib/create-graphq-client.ts rename to apps/products-feed/src/lib/create-graphql-client.ts diff --git a/apps/products-feed/src/modules/app-configuration/app-config.test.ts b/apps/products-feed/src/modules/app-configuration/app-config.test.ts index 37f5af9..2506583 100644 --- a/apps/products-feed/src/modules/app-configuration/app-config.test.ts +++ b/apps/products-feed/src/modules/app-configuration/app-config.test.ts @@ -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 () { @@ -51,6 +54,7 @@ describe("AppConfig", function () { sizeAttributeIds: [], }, titleTemplate: "{{variant.product.name}} - {{variant.name}}", + imageSize: 1024, }); }); @@ -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 @@ -80,6 +86,7 @@ describe("AppConfig", function () { sizeAttributeIds: [], }, titleTemplate: "{{variant.product.name}} - {{variant.name}}", + imageSize: 1024, }); }); @@ -110,6 +117,7 @@ describe("AppConfig", function () { sizeAttributeIds: [], }, titleTemplate: "{{ variant.name }}", + imageSize: 1024, }); const serialized = instance1.serialize(); @@ -132,6 +140,7 @@ describe("AppConfig", function () { sizeAttributeIds: [], }, titleTemplate: "{{ variant.name }}", + imageSize: 1024, }); }); }); @@ -160,6 +169,7 @@ describe("AppConfig", function () { sizeAttributeIds: ["size-id"], }, titleTemplate: "{{ variant.product.name }} - {{ variant.name }}", + imageSize: 1024, }); it("getRootConfig returns root config data", () => { @@ -186,6 +196,7 @@ describe("AppConfig", function () { sizeAttributeIds: ["size-id"], }, titleTemplate: "{{ variant.product.name }} - {{ variant.name }}", + imageSize: 1024, }); }); diff --git a/apps/products-feed/src/modules/app-configuration/app-config.ts b/apps/products-feed/src/modules/app-configuration/app-config.ts index 956fe25..712ad96 100644 --- a/apps/products-feed/src/modules/app-configuration/app-config.ts +++ b/apps/products-feed/src/modules/app-configuration/app-config.ts @@ -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; + const titleTemplateFieldSchema = z.string().default("{{variant.product.name}} - {{variant.name}}"); export const titleTemplateInputSchema = z.object({ @@ -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() @@ -60,6 +69,7 @@ export class AppConfig { s3: null, attributeMapping: attributeMappingSchema.parse({}), titleTemplate: titleTemplateFieldSchema.parse(undefined), + imageSize: imageSizeFieldSchema.parse(undefined), }; constructor(initialData?: RootConfig) { @@ -147,4 +157,14 @@ export class AppConfig { getTitleTemplate() { return this.rootData.titleTemplate; } + + setImageSize(imageSize: z.infer) { + this.rootData.imageSize = imageSize; + + return this; + } + + getImageSize() { + return this.rootData.imageSize; + } } diff --git a/apps/products-feed/src/modules/app-configuration/app-configuration.router.ts b/apps/products-feed/src/modules/app-configuration/app-configuration.router.ts index 7c0bb4f..4b2fd13 100644 --- a/apps/products-feed/src/modules/app-configuration/app-configuration.router.ts +++ b/apps/products-feed/src/modules/app-configuration/app-configuration.router.ts @@ -3,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"; @@ -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) diff --git a/apps/products-feed/src/modules/app-configuration/image-configuration-form.tsx b/apps/products-feed/src/modules/app-configuration/image-configuration-form.tsx new file mode 100644 index 0000000..9cc1bd2 --- /dev/null +++ b/apps/products-feed/src/modules/app-configuration/image-configuration-form.tsx @@ -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; +}; + +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({ + defaultValues: props.initialData, + resolver: zodResolver(imageSizeInputSchema), + }); + + return ( + +