diff --git a/.changeset/plenty-dolls-arrive.md b/.changeset/plenty-dolls-arrive.md new file mode 100644 index 0000000..6e5dbb8 --- /dev/null +++ b/.changeset/plenty-dolls-arrive.md @@ -0,0 +1,12 @@ +--- +"saleor-app-products-feed": minor +--- + +This version introduces major update of the App UI: +- Added Macaw UI / next +- Rebuilt app's UI to match modern Saleor guidelines +- Moved S3 form to be configured once, globally +- Added tests and refactored App configuration/metadata (incompatible with previous version) +- Product-related webhooks are now refreshing cache responsible on indexing large databases + +App was not officially released yet, hence only minor update is introduced diff --git a/apps/products-feed/.env.example b/apps/products-feed/.env.example new file mode 100644 index 0000000..bcc5a09 --- /dev/null +++ b/apps/products-feed/.env.example @@ -0,0 +1 @@ +APP_DEBUG=info \ No newline at end of file diff --git a/apps/products-feed/graphql/fragments/ProductVariantWebhookPayload.graphql b/apps/products-feed/graphql/fragments/ProductVariantWebhookPayload.graphql new file mode 100644 index 0000000..7f32150 --- /dev/null +++ b/apps/products-feed/graphql/fragments/ProductVariantWebhookPayload.graphql @@ -0,0 +1,8 @@ +fragment ProductVariantWebhookPayload on ProductVariant { + channel + channelListings { + channel { + slug + } + } +} diff --git a/apps/products-feed/graphql/fragments/ProductWebhookPayload.graphql b/apps/products-feed/graphql/fragments/ProductWebhookPayload.graphql new file mode 100644 index 0000000..7a49bac --- /dev/null +++ b/apps/products-feed/graphql/fragments/ProductWebhookPayload.graphql @@ -0,0 +1,8 @@ +fragment ProductWebhookPayload on Product { + channel + channelListings { + channel { + slug + } + } +} diff --git a/apps/products-feed/graphql/queries/FetchCategoriesWithMapping.graphql b/apps/products-feed/graphql/queries/FetchCategoriesWithMapping.graphql index e6b4eeb..0a36657 100644 --- a/apps/products-feed/graphql/queries/FetchCategoriesWithMapping.graphql +++ b/apps/products-feed/graphql/queries/FetchCategoriesWithMapping.graphql @@ -1,5 +1,5 @@ -query FetchCategoriesWithMapping{ - categories(first: 10){ +query FetchCategoriesWithMapping($cursor: String){ + categories(first: 100, after: $cursor){ pageInfo{ hasNextPage endCursor diff --git a/apps/products-feed/graphql/subscriptions/productCreated.graphql b/apps/products-feed/graphql/subscriptions/productCreated.graphql new file mode 100644 index 0000000..f55430a --- /dev/null +++ b/apps/products-feed/graphql/subscriptions/productCreated.graphql @@ -0,0 +1,9 @@ +subscription ProductCreated { + event { + ... on ProductCreated { + product { + ...ProductWebhookPayload + } + } + } +} diff --git a/apps/products-feed/graphql/subscriptions/productDeleted.graphql b/apps/products-feed/graphql/subscriptions/productDeleted.graphql new file mode 100644 index 0000000..dda49e0 --- /dev/null +++ b/apps/products-feed/graphql/subscriptions/productDeleted.graphql @@ -0,0 +1,9 @@ +subscription ProductDeleted { + event { + ... on ProductDeleted { + product { + ...ProductWebhookPayload + } + } + } +} diff --git a/apps/products-feed/graphql/subscriptions/productUpdated.graphql b/apps/products-feed/graphql/subscriptions/productUpdated.graphql new file mode 100644 index 0000000..9d76752 --- /dev/null +++ b/apps/products-feed/graphql/subscriptions/productUpdated.graphql @@ -0,0 +1,9 @@ +subscription ProductUpdated { + event { + ... on ProductUpdated { + product { + ...ProductWebhookPayload + } + } + } +} diff --git a/apps/products-feed/graphql/subscriptions/productVariantCreated.graphql b/apps/products-feed/graphql/subscriptions/productVariantCreated.graphql new file mode 100644 index 0000000..81fbf7d --- /dev/null +++ b/apps/products-feed/graphql/subscriptions/productVariantCreated.graphql @@ -0,0 +1,9 @@ +subscription ProductVariantCreated { + event { + ... on ProductVariantCreated { + productVariant { + ...ProductVariantWebhookPayload + } + } + } +} diff --git a/apps/products-feed/graphql/subscriptions/productVariantDeleted.graphql b/apps/products-feed/graphql/subscriptions/productVariantDeleted.graphql new file mode 100644 index 0000000..a313a68 --- /dev/null +++ b/apps/products-feed/graphql/subscriptions/productVariantDeleted.graphql @@ -0,0 +1,10 @@ +subscription ProductVariantDeleted { + event { + ... on ProductVariantDeleted { + productVariant { + ...ProductVariantWebhookPayload + } + } + + } +} diff --git a/apps/products-feed/graphql/subscriptions/productVariantUpdated.graphql b/apps/products-feed/graphql/subscriptions/productVariantUpdated.graphql new file mode 100644 index 0000000..301ac99 --- /dev/null +++ b/apps/products-feed/graphql/subscriptions/productVariantUpdated.graphql @@ -0,0 +1,9 @@ +subscription ProductVariantUpdated { + event { + ... on ProductVariantUpdated { + productVariant { + ...ProductVariantWebhookPayload + } + } + } +} diff --git a/apps/products-feed/next.config.js b/apps/products-feed/next.config.js index 841ec92..e1866a9 100644 --- a/apps/products-feed/next.config.js +++ b/apps/products-feed/next.config.js @@ -1,7 +1,7 @@ /** @type {import('next').NextConfig} */ module.exports = { reactStrictMode: true, - transpilePackages: ["@saleor/apps-shared"], + transpilePackages: ["@saleor/apps-shared", "@saleor/apps-ui", "@saleor/react-hook-form-macaw"], }; const isSentryEnvAvailable = diff --git a/apps/products-feed/package.json b/apps/products-feed/package.json index 3940b18..4445724 100644 --- a/apps/products-feed/package.json +++ b/apps/products-feed/package.json @@ -17,13 +17,11 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.332.0", - "@hookform/resolvers": "^2.9.10", - "@material-ui/core": "^4.12.4", - "@material-ui/icons": "^4.11.3", - "@material-ui/lab": "4.0.0-alpha.61", "@saleor/app-sdk": "0.39.1", "@saleor/apps-shared": "workspace:*", - "@saleor/macaw-ui": "^0.7.2", + "@saleor/react-hook-form-macaw": "workspace:*", + "@saleor/apps-ui": "workspace:*", + "@saleor/macaw-ui": "0.8.0-pre.84", "@sentry/nextjs": "^7.52.1", "@tanstack/react-query": "^4.24.2", "@trpc/client": "^10.9.0", @@ -43,13 +41,15 @@ "pino-pretty": "^10.0.0", "react": "18.2.0", "react-dom": "18.2.0", - "react-hook-form": "^7.43.0", + "react-hook-form": "^7.43.9", "react-is": "^18.2.0", + "react-query": "^3.39.3", "urql": "^3.0.3", "usehooks-ts": "^2.9.1", "vite": "4.3.9", "vitest": "0.31.3", - "zod": "^3.20.2" + "zod": "^3.20.2", + "@hookform/resolvers": "^3.1.0" }, "devDependencies": { "@graphql-codegen/cli": "3.2.2", diff --git a/apps/products-feed/src/lib/create-graphq-client.ts b/apps/products-feed/src/lib/create-graphq-client.ts index 1448c8a..0d55adf 100644 --- a/apps/products-feed/src/lib/create-graphq-client.ts +++ b/apps/products-feed/src/lib/create-graphq-client.ts @@ -5,6 +5,7 @@ import { dedupExchange, fetchExchange, } from "urql"; +import { AuthData } from "@saleor/app-sdk/APL"; interface IAuthState { token: string; @@ -46,3 +47,11 @@ export const createClient = (url: string, getAuth: AuthConfig["getAu fetchExchange, ], }); + +export const GraphqlClientFactory = { + fromAuthData(authData: Pick) { + return createClient(authData.saleorApiUrl, async () => + Promise.resolve({ token: authData.token }) + ); + }, +}; diff --git a/apps/products-feed/src/lib/google-feed/get-google-feed-settings.ts b/apps/products-feed/src/lib/google-feed/get-google-feed-settings.ts deleted file mode 100644 index a3a1cbf..0000000 --- a/apps/products-feed/src/lib/google-feed/get-google-feed-settings.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { AuthData } from "@saleor/app-sdk/APL"; -import { appRouter } from "../../modules/trpc/trpc-app-router"; - -interface GetGoogleFeedSettingsArgs { - authData: AuthData; - channel: string; -} - -export const getGoogleFeedSettings = async ({ authData, channel }: GetGoogleFeedSettingsArgs) => { - const caller = appRouter.createCaller({ - appId: authData.appId, - saleorApiUrl: authData.saleorApiUrl, - token: authData.token, - ssr: true, - }); - - const configurations = await caller.appConfiguration.fetch(); - - const configuration = configurations.shopConfigPerChannel[channel]; - - const storefrontUrl = configuration.urlConfiguration.storefrontUrl; - - const productStorefrontUrl = configuration.urlConfiguration.productStorefrontUrl; - - if (!storefrontUrl.length || !productStorefrontUrl.length) { - throw new Error("The application has not been configured"); - } - - return { - storefrontUrl, - productStorefrontUrl, - s3BucketConfiguration: configuration.s3BucketConfiguration, - }; -}; diff --git a/apps/products-feed/src/lib/is-in-iframe.ts b/apps/products-feed/src/lib/is-in-iframe.ts deleted file mode 100644 index e1f481c..0000000 --- a/apps/products-feed/src/lib/is-in-iframe.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function isInIframe() { - try { - return window.self !== window.top; - } catch (e) { - return true; - } -} diff --git a/apps/products-feed/src/lib/metadata-manager.ts b/apps/products-feed/src/lib/metadata-manager.ts index a852374..63c8ccc 100644 --- a/apps/products-feed/src/lib/metadata-manager.ts +++ b/apps/products-feed/src/lib/metadata-manager.ts @@ -1,4 +1,4 @@ -import { MetadataEntry, EncryptedMetadataManager } from "@saleor/app-sdk/settings-manager"; +import { EncryptedMetadataManager, MetadataEntry } from "@saleor/app-sdk/settings-manager"; import { Client, gql } from "urql"; import { FetchAppDetailsDocument, diff --git a/apps/products-feed/src/lib/no-ssr-wrapper.tsx b/apps/products-feed/src/lib/no-ssr-wrapper.tsx deleted file mode 100644 index f195439..0000000 --- a/apps/products-feed/src/lib/no-ssr-wrapper.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React, { PropsWithChildren } from "react"; -import dynamic from "next/dynamic"; - -const Wrapper = (props: PropsWithChildren<{}>) => {props.children}; - -/** - * Saleor App can be rendered only as a Saleor Dashboard iframe. - * All content is rendered after Dashboard exchanges auth with the app. - * Hence, there is no reason to render app server side. - * - * This component forces app to work in SPA-mode. It simplifies browser-only code and reduces need - * of using dynamic() calls - * - * You can use this wrapper selectively for some pages or remove it completely. - * It doesn't affect Saleor communication, but may cause problems with some client-only code. - */ -export const NoSSRWrapper = dynamic(() => Promise.resolve(Wrapper), { - ssr: false, -}); diff --git a/apps/products-feed/src/lib/theme-synchronizer.test.tsx b/apps/products-feed/src/lib/theme-synchronizer.test.tsx deleted file mode 100644 index c2d8b70..0000000 --- a/apps/products-feed/src/lib/theme-synchronizer.test.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { AppBridgeState } from "@saleor/app-sdk/app-bridge"; -import { render, waitFor } from "@testing-library/react"; -import { ThemeSynchronizer } from "./theme-synchronizer"; - -const appBridgeState: AppBridgeState = { - ready: true, - token: "token", - domain: "some-domain.saleor.cloud", - theme: "dark", - path: "/", - locale: "en", - id: "app-id", - saleorApiUrl: "https://some-domain.saleor.cloud/graphql/", -}; - -const mockThemeChange = vi.fn(); - -vi.mock("@saleor/app-sdk/app-bridge", () => { - return { - useAppBridge() { - return { - appBridgeState: appBridgeState, - }; - }, - }; -}); - -vi.mock("@saleor/macaw-ui", () => { - return { - useTheme() { - return { - setTheme: mockThemeChange, - themeType: "light", - }; - }, - }; -}); - -describe("ThemeSynchronizer", () => { - it("Updates MacawUI theme when AppBridgeState theme changes", () => { - render(); - - return waitFor(() => { - expect(mockThemeChange).toHaveBeenCalledWith("dark"); - }); - }); -}); diff --git a/apps/products-feed/src/lib/theme-synchronizer.tsx b/apps/products-feed/src/lib/theme-synchronizer.tsx index 7e5ce17..3386f49 100644 --- a/apps/products-feed/src/lib/theme-synchronizer.tsx +++ b/apps/products-feed/src/lib/theme-synchronizer.tsx @@ -1,33 +1,25 @@ import { useAppBridge } from "@saleor/app-sdk/app-bridge"; -import { useTheme } from "@saleor/macaw-ui"; -import { memo, useEffect } from "react"; +import { useTheme } from "@saleor/macaw-ui/next"; +import { useEffect } from "react"; -/** - * Macaw-ui stores its theme mode in memory and local storage. To synchronize App with Dashboard, - * Macaw must be informed about this change from AppBridge. - * - * If you are not using Macaw, you can remove this. - */ -function _ThemeSynchronizer() { +// todo move to shared +export function ThemeSynchronizer() { const { appBridgeState } = useAppBridge(); - const { setTheme, themeType } = useTheme(); + const { setTheme } = useTheme(); useEffect(() => { if (!setTheme || !appBridgeState?.theme) { return; } - if (themeType !== appBridgeState?.theme) { - setTheme(appBridgeState.theme); - /** - * Hack to fix macaw, which is going into infinite loop on light mode (probably de-sync local storage with react state) - * TODO Fix me when Macaw 2.0 is shipped - */ - window.localStorage.setItem("macaw-ui-theme", appBridgeState.theme); + if (appBridgeState.theme === "light") { + setTheme("defaultLight"); } - }, [appBridgeState?.theme, setTheme, themeType]); + + if (appBridgeState.theme === "dark") { + setTheme("defaultDark"); + } + }, [appBridgeState?.theme, setTheme]); return null; } - -export const ThemeSynchronizer = memo(_ThemeSynchronizer); diff --git a/apps/products-feed/src/modules/app-configuration/app-config-container.ts b/apps/products-feed/src/modules/app-configuration/app-config-container.ts deleted file mode 100644 index a474fdc..0000000 --- a/apps/products-feed/src/modules/app-configuration/app-config-container.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { AppConfig, SellerShopConfig } from "./app-config"; - -const getDefaultEmptyUrlConfiguration = (): SellerShopConfig["urlConfiguration"] => ({ - storefrontUrl: "", - productStorefrontUrl: "", -}); - -const getDefaultEmptyPerChannelConfiguration = (): SellerShopConfig => ({ - urlConfiguration: getDefaultEmptyUrlConfiguration(), - s3BucketConfiguration: undefined, //getDefaultEmptyS3BucketConfiguration(), -}); - -const getChannelUrlConfiguration = - (appConfig: AppConfig | null | undefined) => (channelSlug: string) => { - try { - return appConfig?.shopConfigPerChannel[channelSlug].urlConfiguration ?? null; - } catch (e) { - return null; - } - }; - -const setChannelUrlConfiguration = - (appConfig: AppConfig | null | undefined) => - (channelSlug: string) => - (urlConfiguration: SellerShopConfig["urlConfiguration"]) => { - const appConfigNormalized = structuredClone(appConfig) ?? { shopConfigPerChannel: {} }; - - appConfigNormalized.shopConfigPerChannel[channelSlug] ??= - getDefaultEmptyPerChannelConfiguration(); - - appConfigNormalized.shopConfigPerChannel[channelSlug].urlConfiguration = urlConfiguration; - - return appConfigNormalized; - }; - -const getChannelS3BucketConfiguration = - (appConfig: AppConfig | null | undefined) => (channelSlug: string) => { - try { - return appConfig?.shopConfigPerChannel[channelSlug].s3BucketConfiguration ?? null; - } catch (e) { - return null; - } - }; - -const setChannelS3BucketConfiguration = - (appConfig: AppConfig | null | undefined) => - (channelSlug: string) => - (s3BucketConfiguration: SellerShopConfig["s3BucketConfiguration"]) => { - const appConfigNormalized = structuredClone(appConfig) ?? { shopConfigPerChannel: {} }; - - appConfigNormalized.shopConfigPerChannel[channelSlug].s3BucketConfiguration = - s3BucketConfiguration; - - return appConfigNormalized; - }; - -export const AppConfigContainer = { - getChannelUrlConfiguration, - setChannelUrlConfiguration, - getChannelS3BucketConfiguration, - setChannelS3BucketConfiguration, -}; diff --git a/apps/products-feed/src/modules/app-configuration/app-config-input-schema.ts b/apps/products-feed/src/modules/app-configuration/app-config-input-schema.ts deleted file mode 100644 index a2c3eeb..0000000 --- a/apps/products-feed/src/modules/app-configuration/app-config-input-schema.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { z } from "zod"; -import { sellerShopConfigSchema } from "./app-config"; - -export const appConfigInputSchema = z.object({ - shopConfigPerChannel: z.record(sellerShopConfigSchema), -}); diff --git a/apps/products-feed/src/modules/app-configuration/app-config-metadata-manager.ts b/apps/products-feed/src/modules/app-configuration/app-config-metadata-manager.ts new file mode 100644 index 0000000..19562cb --- /dev/null +++ b/apps/products-feed/src/modules/app-configuration/app-config-metadata-manager.ts @@ -0,0 +1,18 @@ +import { SettingsManager } from "@saleor/app-sdk/settings-manager"; + +export class AppConfigMetadataManager { + private readonly metadataKey = "app-config-v1"; + + constructor(private settingsManager: SettingsManager) {} + + get() { + return this.settingsManager.get(this.metadataKey); + } + + set(stringMetadata: string) { + return this.settingsManager.set({ + key: this.metadataKey, + value: stringMetadata, + }); + } +} 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 new file mode 100644 index 0000000..df06334 --- /dev/null +++ b/apps/products-feed/src/modules/app-configuration/app-config.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from "vitest"; +import { AppConfig } from "./app-config"; + +describe("AppConfig", function () { + describe("Construction", () => { + it("Constructs empty state", () => { + const instance = new AppConfig(); + + expect(instance.getRootConfig()).toEqual({ channelConfig: {}, s3: null }); + }); + + it("Constructs from initial state", () => { + const instance = new AppConfig({ + s3: { + region: "region", + bucketName: "bucket", + accessKeyId: "access", + secretAccessKey: "secret", + }, + channelConfig: { + test: { + storefrontUrls: { + productStorefrontUrl: "https://example.com", + storefrontUrl: "https://example.com/p/{productFeed}", + }, + }, + }, + }); + + expect(instance.getRootConfig()).toEqual({ + s3: { + bucketName: "bucket", + secretAccessKey: "secret", + accessKeyId: "access", + region: "region", + }, + channelConfig: { + test: { + storefrontUrls: { + productStorefrontUrl: "https://example.com", + storefrontUrl: "https://example.com/p/{productFeed}", + }, + }, + }, + }); + }); + + it("Fails construction if invalid state provided", () => { + expect( + () => + new AppConfig({ + // @ts-expect-error + foo: "bar", + }) + ).toThrow(); + }); + + it("Parses from string", () => { + const instance1 = new AppConfig({ + s3: { + region: "region", + bucketName: "bucket", + accessKeyId: "access", + secretAccessKey: "secret", + }, + channelConfig: {}, + }); + + const serialized = instance1.serialize(); + + const instance2 = AppConfig.parse(serialized); + + expect(instance2.getRootConfig()).toEqual({ + s3: { + region: "region", + bucketName: "bucket", + accessKeyId: "access", + secretAccessKey: "secret", + }, + channelConfig: {}, + }); + }); + }); + + describe("getters", () => { + const instance = new AppConfig({ + s3: { + region: "region", + bucketName: "bucket", + accessKeyId: "access", + secretAccessKey: "secret", + }, + channelConfig: { + test: { + storefrontUrls: { + productStorefrontUrl: "https://example.com", + storefrontUrl: "https://example.com/p/{productFeed}", + }, + }, + }, + }); + + it("getRootConfig returns root config data", () => { + expect(instance.getRootConfig()).toEqual({ + s3: { + region: "region", + bucketName: "bucket", + accessKeyId: "access", + secretAccessKey: "secret", + }, + channelConfig: { + test: { + storefrontUrls: { + productStorefrontUrl: "https://example.com", + storefrontUrl: "https://example.com/p/{productFeed}", + }, + }, + }, + }); + }); + + it("getUrlsForChannel gets data for given channel or undefined if doesnt exist", () => { + expect(instance.getUrlsForChannel("test")).toEqual({ + productStorefrontUrl: "https://example.com", + storefrontUrl: "https://example.com/p/{productFeed}", + }); + + expect(instance.getUrlsForChannel("not-existing")).toBeUndefined(); + }); + + it("getS3Config gets s3 data", () => { + expect(instance.getS3Config()).toEqual({ + region: "region", + bucketName: "bucket", + accessKeyId: "access", + secretAccessKey: "secret", + }); + }); + }); + + describe("setters", () => { + it("setS3 sets valid config to s3 key and rejects invalid config", () => { + const instance = new AppConfig(); + + instance.setS3({ + region: "region", + bucketName: "bucket", + accessKeyId: "access", + secretAccessKey: "secret", + }); + + expect(instance.getS3Config()).toEqual({ + region: "region", + bucketName: "bucket", + accessKeyId: "access", + secretAccessKey: "secret", + }); + + // @ts-expect-error + expect(() => instance.setS3({ foo: "bar" })).toThrow(); + }); + + it("setChannelUrls sets valid config to channelConfig[channelSlug] and rejects invalid config", () => { + const instance = new AppConfig(); + + instance.setChannelUrls("test", { + productStorefrontUrl: "https://example.com", + storefrontUrl: "https://example.com/p/{productFeed}", + }); + + expect(instance.getUrlsForChannel("test")).toEqual({ + productStorefrontUrl: "https://example.com", + storefrontUrl: "https://example.com/p/{productFeed}", + }); + + // @ts-expect-error + expect(() => instance.setChannelUrls("channel", "foo")).toThrow(); + }); + }); + + it("Serializes to string", () => { + const instance = new AppConfig(); + + instance.setS3({ + region: "region", + bucketName: "bucket", + accessKeyId: "access", + secretAccessKey: "secret", + }); + + const serialized = instance.serialize(); + + /** + * Only way to check if serialization works is to deserialize. Order of serialized fields is not given so string cant be asserted. + * JSON.parse can be used but its testing implementation details + */ + expect(AppConfig.parse(serialized).getS3Config()).toEqual({ + region: "region", + bucketName: "bucket", + accessKeyId: "access", + secretAccessKey: "secret", + }); + }); +}); 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 0585402..9dc1929 100644 --- a/apps/products-feed/src/modules/app-configuration/app-config.ts +++ b/apps/products-feed/src/modules/app-configuration/app-config.ts @@ -1,34 +1,91 @@ import { z } from "zod"; -import { UrlConfiguration } from "./url-configuration"; -export const s3BucketConfigurationSchema = z.object({ - bucketName: z.string(), - secretAccessKey: z.string(), - accessKeyId: z.string(), - region: z.string(), +const s3ConfigSchema = z.object({ + bucketName: z.string().min(1), + secretAccessKey: z.string().min(1), + accessKeyId: z.string().min(1), + region: z.string().min(1), }); -export type S3BucketConfiguration = z.infer; - -export const urlConfigurationSchema = z.object({ - /** - * min() to allow empty strings - */ - storefrontUrl: z.string().min(0), - productStorefrontUrl: z.string().min(0), +const urlConfigurationSchema = z.object({ + storefrontUrl: z.string().min(1).url(), + productStorefrontUrl: z.string().min(1).url(), }); -export type UrlConfiguration = z.infer; - -export const sellerShopConfigSchema = z.object({ - urlConfiguration: urlConfigurationSchema, - s3BucketConfiguration: s3BucketConfigurationSchema.optional(), +const rootAppConfigSchema = z.object({ + s3: s3ConfigSchema.nullable(), + channelConfig: z.record(z.object({ storefrontUrls: urlConfigurationSchema })), }); -export type SellerShopConfig = z.infer; - -export type ShopConfigPerChannelSlug = Record; - -export type AppConfig = { - shopConfigPerChannel: ShopConfigPerChannelSlug; +export const AppConfigSchema = { + root: rootAppConfigSchema, + s3Bucket: s3ConfigSchema, + channelUrls: urlConfigurationSchema, }; + +export type RootConfig = z.infer; + +export type ChannelUrlsConfig = z.infer; + +export class AppConfig { + private rootData: RootConfig = { + channelConfig: {}, + s3: null, + }; + + constructor(initialData?: RootConfig) { + if (initialData) { + this.rootData = rootAppConfigSchema.parse(initialData); + } + } + + static parse(serializedSchema: string) { + return new AppConfig(JSON.parse(serializedSchema)); + } + + getRootConfig() { + return this.rootData; + } + + serialize() { + return JSON.stringify(this.rootData); + } + + setS3(s3Config: z.infer) { + try { + this.rootData.s3 = s3ConfigSchema.parse(s3Config); + + return this; + } catch (e) { + console.error(e); + + throw new Error("Invalid S3 config provided"); + } + } + + setChannelUrls(channelSlug: string, urlsConfig: z.infer) { + try { + const parsedConfig = urlConfigurationSchema.parse(urlsConfig); + + this.rootData.channelConfig[channelSlug] = { + storefrontUrls: parsedConfig, + }; + } catch (e) { + console.error(e); + + throw new Error("Invalid payload"); + } + } + + getUrlsForChannel(channelSlug: string) { + try { + return this.rootData.channelConfig[channelSlug].storefrontUrls; + } catch (e) { + return undefined; + } + } + + getS3Config() { + return this.rootData.s3; + } +} 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 4ff7cfb..f34d441 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 @@ -1,44 +1,75 @@ import { router } from "../trpc/trpc-server"; import { protectedClientProcedure } from "../trpc/protected-client-procedure"; -import { PrivateMetadataAppConfigurator } from "./app-configurator"; -import { createSettingsManager } from "../../lib/metadata-manager"; import { createLogger } from "@saleor/apps-shared"; -import { appConfigInputSchema } from "./app-config-input-schema"; -import { GetAppConfigurationService } from "./get-app-configuration.service"; + import { updateCacheForConfigurations } from "../metadata-cache/update-cache-for-configurations"; +import { AppConfigSchema } from "./app-config"; +import { z } from "zod"; export const appConfigurationRouter = router({ - fetch: protectedClientProcedure.query(async ({ ctx, input }) => { - const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl }); + /** + * Prefer fetching all to avoid unnecessary calls. Routes are cached by react-query + */ + fetch: protectedClientProcedure.query(async ({ ctx: { logger, getConfig }, input }) => { + return getConfig().then((c) => { + logger.debug("Fetched config"); - logger.debug("appConfigurationRouter.fetch called"); - - return new GetAppConfigurationService({ - apiClient: ctx.apiClient, - saleorApiUrl: ctx.saleorApiUrl, - }).getConfiguration(); + return c.getRootConfig(); + }); }), - setAndReplace: protectedClientProcedure + setS3BucketConfiguration: protectedClientProcedure .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) - .input(appConfigInputSchema) - .mutation(async ({ ctx, input }) => { - const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl }); + .input(AppConfigSchema.s3Bucket) + .mutation(async ({ ctx: { saleorApiUrl, getConfig, appConfigMetadataManager }, input }) => { + const logger = createLogger({ saleorApiUrl: saleorApiUrl }); - logger.debug(input, "appConfigurationRouter.setAndReplace called with input"); + logger.debug(input, "Input"); - const appConfigurator = new PrivateMetadataAppConfigurator( - createSettingsManager(ctx.apiClient), - ctx.saleorApiUrl - ); + const config = await getConfig(); - await updateCacheForConfigurations({ - client: ctx.apiClient, - configurations: input, - saleorApiUrl: ctx.saleorApiUrl, - }); + config.setS3(input); - await appConfigurator.setConfig(input); + await appConfigMetadataManager.set(config.serialize()); + + logger.debug("Config saved"); return null; }), + setChannelsUrls: protectedClientProcedure + .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) + .input( + z.object({ + channelSlug: z.string(), + urls: AppConfigSchema.channelUrls, + }) + ) + .mutation( + async ({ + ctx: { getConfig, apiClient, saleorApiUrl, appConfigMetadataManager, logger }, + input, + }) => { + const config = await getConfig(); + + /** + * 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 + */ + await updateCacheForConfigurations({ + client: apiClient, + channelsSlugs: [input.channelSlug], + saleorApiUrl: saleorApiUrl, + }); + + logger.debug({ channel: input.channelSlug }, "Updated cache for channel"); + + config.setChannelUrls(input.channelSlug, input.urls); + + await appConfigMetadataManager.set(config.serialize()); + + logger.debug("Saved config"); + + return null; + } + ), }); diff --git a/apps/products-feed/src/modules/app-configuration/app-configurator.ts b/apps/products-feed/src/modules/app-configuration/app-configurator.ts deleted file mode 100644 index 41f3c21..0000000 --- a/apps/products-feed/src/modules/app-configuration/app-configurator.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { AppConfig } from "./app-config"; -import { SettingsManager } from "@saleor/app-sdk/settings-manager"; - -export interface AppConfigurator { - setConfig(config: AppConfig): Promise; - getConfig(): Promise; -} - -export class PrivateMetadataAppConfigurator implements AppConfigurator { - private metadataKey = "app-config"; - - constructor(private metadataManager: SettingsManager, private saleorApiUrl: string) {} - - getConfig(): Promise { - return this.metadataManager.get(this.metadataKey, this.saleorApiUrl).then((data) => { - if (!data) { - return undefined; - } - - try { - return JSON.parse(data); - } catch (e) { - throw new Error("Invalid metadata value, can't be parsed"); - } - }); - } - - setConfig(config: AppConfig): Promise { - return this.metadataManager.set({ - key: this.metadataKey, - value: JSON.stringify(config), - domain: this.saleorApiUrl, - }); - } -} diff --git a/apps/products-feed/src/modules/app-configuration/channels-config-accordion.tsx b/apps/products-feed/src/modules/app-configuration/channels-config-accordion.tsx new file mode 100644 index 0000000..781a7a8 --- /dev/null +++ b/apps/products-feed/src/modules/app-configuration/channels-config-accordion.tsx @@ -0,0 +1,105 @@ +import { Accordion, Box, Button, Divider, PropsWithBox, Text } from "@saleor/macaw-ui/next"; +import { Input } from "@saleor/react-hook-form-macaw"; +import { trpcClient } from "../trpc/trpc-client"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { AppConfigSchema, ChannelUrlsConfig } from "./app-config"; +import { FeedPreviewCard } from "./feed-preview-card"; +import { useDashboardNotification } from "@saleor/apps-shared"; + +const ChannelConfigForm = ({ channelSlug, ...props }: PropsWithBox<{ channelSlug: string }>) => { + const { notifySuccess, notifyError } = useDashboardNotification(); + + const { data: appConfig } = trpcClient.appConfiguration.fetch.useQuery(); + + const channelConfig = appConfig?.channelConfig[channelSlug]; + + const { mutate } = trpcClient.appConfiguration.setChannelsUrls.useMutation({ + onSuccess() { + notifySuccess("Success"); + }, + onError() { + notifyError("Failed saving configuration.", "Refresh the page and try again"); + }, + }); + + const { control, handleSubmit } = useForm({ + resolver: zodResolver(AppConfigSchema.channelUrls), + defaultValues: { + productStorefrontUrl: channelConfig?.storefrontUrls.productStorefrontUrl ?? "", + storefrontUrl: channelConfig?.storefrontUrls.storefrontUrl ?? "", + }, + }); + + return ( + { + mutate({ + urls: data, + channelSlug, + }); + })} + as={"form"} + display={"grid"} + gap={6} + {...props} + > + + Configure channel URLs + + + + + + + + ); +}; + +export const ChannelsConfigAccordion = () => { + const { data, isLoading } = trpcClient.channels.fetch.useQuery(); + + if (isLoading) { + return Loading...; + } + + return ( + + {data?.map((channel) => ( + + + {channel.name} + + + + + + + + ))} + + ); +}; diff --git a/apps/products-feed/src/modules/channels/channels-fetcher.ts b/apps/products-feed/src/modules/app-configuration/channels/channels-fetcher.ts similarity index 84% rename from apps/products-feed/src/modules/channels/channels-fetcher.ts rename to apps/products-feed/src/modules/app-configuration/channels/channels-fetcher.ts index 49eeb93..42317f6 100644 --- a/apps/products-feed/src/modules/channels/channels-fetcher.ts +++ b/apps/products-feed/src/modules/app-configuration/channels/channels-fetcher.ts @@ -1,5 +1,5 @@ import { Client, gql } from "urql"; -import { FetchChannelsDocument } from "../../../generated/graphql"; +import { FetchChannelsDocument } from "../../../../generated/graphql"; gql` fragment Channel on Channel { diff --git a/apps/products-feed/src/modules/app-configuration/channels/channels.router.ts b/apps/products-feed/src/modules/app-configuration/channels/channels.router.ts new file mode 100644 index 0000000..7c65ddb --- /dev/null +++ b/apps/products-feed/src/modules/app-configuration/channels/channels.router.ts @@ -0,0 +1,16 @@ +import { router } from "../../trpc/trpc-server"; +import { protectedClientProcedure } from "../../trpc/protected-client-procedure"; +import { ChannelsFetcher } from "./channels-fetcher"; +import { ChannelFragment } from "../../../../generated/graphql"; + +export const channelsRouter = router({ + fetch: protectedClientProcedure.query( + async ({ ctx: { logger, apiClient }, input }): Promise => { + const fetcher = new ChannelsFetcher(apiClient); + + logger.debug("Will fetch channels"); + + return fetcher.fetchChannels().then((channels) => channels ?? []); + } + ), +}); diff --git a/apps/products-feed/src/modules/app-configuration/channels/use-channels-existence-checking.ts b/apps/products-feed/src/modules/app-configuration/channels/use-channels-existence-checking.ts new file mode 100644 index 0000000..96a4cc3 --- /dev/null +++ b/apps/products-feed/src/modules/app-configuration/channels/use-channels-existence-checking.ts @@ -0,0 +1,17 @@ +import { trpcClient } from "../../trpc/trpc-client"; +import { useRouter } from "next/router"; +import { useEffect } from "react"; + +/** + * This app requires channels to exist, so redirect to error page if channels don't exist + */ +export const useChannelsExistenceChecking = () => { + const channels = trpcClient.channels.fetch.useQuery(); + const router = useRouter(); + + useEffect(() => { + if (channels.isSuccess && channels.data.length === 0) { + router.push("/not-ready"); + } + }, [channels.data, channels.isSuccess, router]); +}; diff --git a/apps/products-feed/src/modules/app-configuration/fallback-app-config.ts b/apps/products-feed/src/modules/app-configuration/fallback-app-config.ts deleted file mode 100644 index 15b2e94..0000000 --- a/apps/products-feed/src/modules/app-configuration/fallback-app-config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { AppConfig } from "./app-config"; -import { AppConfigContainer } from "./app-config-container"; -import { ChannelFragment, ShopInfoFragment } from "../../../generated/graphql"; - -/** - * TODO Test - */ -export const FallbackAppConfig = { - createFallbackConfigFromExistingShopAndChannels( - channels: ChannelFragment[], - shopUrlConfiguration: ShopInfoFragment | null - ) { - return (channels ?? []).reduce( - (state, channel) => { - return AppConfigContainer.setChannelUrlConfiguration(state)(channel.slug)({ - storefrontUrl: "", - productStorefrontUrl: "", - }); - }, - { shopConfigPerChannel: {} } - ); - }, -}; diff --git a/apps/products-feed/src/modules/app-configuration/feed-preview-card.tsx b/apps/products-feed/src/modules/app-configuration/feed-preview-card.tsx new file mode 100644 index 0000000..099e239 --- /dev/null +++ b/apps/products-feed/src/modules/app-configuration/feed-preview-card.tsx @@ -0,0 +1,44 @@ +import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge"; +import { Box, Button, Input, PropsWithBox, Text } from "@saleor/macaw-ui/next"; +import { useGetFeedApiUrl } from "../feed-url/use-get-feed-api-url"; + +interface FeedPreviewCardProps { + channelSlug: string; +} + +export const FeedPreviewCard = ({ channelSlug, ...props }: PropsWithBox) => { + const { appBridge } = useAppBridge(); + + const googleFeedUrl = useGetFeedApiUrl(channelSlug); + + if (!googleFeedUrl) { + // Should never happen + return null; + } + + const openUrlInNewTab = async (url: string) => { + await appBridge?.dispatch(actions.Redirect({ to: url, newContext: true })); + }; + + return ( + + + Test your feed + + { + e.target.select(); + }} + helperText="Dedicated URL for your Google Merchant Feed. Click to select and copy." + /> + + + + + + ); +}; diff --git a/apps/products-feed/src/modules/app-configuration/get-app-configuration.service.ts b/apps/products-feed/src/modules/app-configuration/get-app-configuration.service.ts deleted file mode 100644 index 3e5e0f2..0000000 --- a/apps/products-feed/src/modules/app-configuration/get-app-configuration.service.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { PrivateMetadataAppConfigurator } from "./app-configurator"; -import { createSettingsManager } from "../../lib/metadata-manager"; -import { ChannelsFetcher } from "../channels/channels-fetcher"; -import { ShopInfoFetcher } from "../shop-info/shop-info-fetcher"; -import { FallbackAppConfig } from "./fallback-app-config"; -import { Client } from "urql"; -import { createLogger } from "@saleor/apps-shared"; - -export class GetAppConfigurationService { - constructor( - private settings: { - apiClient: Client; - saleorApiUrl: string; - } - ) {} - - async getConfiguration() { - const logger = createLogger({ - service: "GetAppConfigurationService", - saleorApiUrl: this.settings.saleorApiUrl, - }); - - const { saleorApiUrl, apiClient } = this.settings; - - const appConfigurator = new PrivateMetadataAppConfigurator( - createSettingsManager(apiClient), - saleorApiUrl - ); - - const savedAppConfig = (await appConfigurator.getConfig()) ?? null; - - logger.debug(savedAppConfig, "Retrieved app config from Metadata. Will return it"); - - if (savedAppConfig) { - return savedAppConfig; - } - - logger.info("App config not found in metadata. Will create default config now."); - - const channelsFetcher = new ChannelsFetcher(apiClient); - const shopInfoFetcher = new ShopInfoFetcher(apiClient); - - const [channels, shopUrlConfiguration] = await Promise.all([ - channelsFetcher.fetchChannels(), - shopInfoFetcher.fetchShopInfo(), - ]); - - logger.debug(channels, "Fetched channels"); - logger.debug(shopUrlConfiguration, "Fetched shop url configuration"); - - const appConfig = FallbackAppConfig.createFallbackConfigFromExistingShopAndChannels( - channels ?? [], - shopUrlConfiguration - ); - - logger.debug(appConfig, "Created a fallback AppConfig. Will save it."); - - await appConfigurator.setConfig(appConfig); - - logger.info("Saved initial AppConfig"); - - return appConfig; - } -} diff --git a/apps/products-feed/src/modules/app-configuration/s3-configuration-form.tsx b/apps/products-feed/src/modules/app-configuration/s3-configuration-form.tsx new file mode 100644 index 0000000..d215584 --- /dev/null +++ b/apps/products-feed/src/modules/app-configuration/s3-configuration-form.tsx @@ -0,0 +1,94 @@ +import { AppConfigSchema, RootConfig } 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 { 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 S3BucketConfiguration = Exclude; + +type Props = { + initialData: S3BucketConfiguration; + onSubmit(data: S3BucketConfiguration): Promise; +}; + +export const S3ConfigurationForm = (props: Props) => { + const { handleSubmit, control } = useForm({ + defaultValues: props.initialData, + resolver: zodResolver(AppConfigSchema.s3Bucket), + }); + + return ( + { + props.onSubmit(data); + })} + > + + + + + + + + + + + ); +}; + +export const ConnectedS3ConfigurationForm = () => { + const { notifyError, notifySuccess } = useDashboardNotification(); + + const { mutate } = trpcClient.appConfiguration.setS3BucketConfiguration.useMutation({ + onSuccess() { + notifySuccess("Success", "Updated S3 configration"); + }, + onError() { + notifyError("Error", "Failed to update, please refresh and try again"); + }, + }); + const { data, isLoading } = trpcClient.appConfiguration.fetch.useQuery(); + + const handleSubmit = useCallback( + async (data: S3BucketConfiguration) => { + mutate(data); + }, + [mutate] + ); + + const formData: S3BucketConfiguration = useMemo(() => { + if (data?.s3) { + return data.s3; + } + + return { + accessKeyId: "", + bucketName: "", + region: "", + secretAccessKey: "", + }; + }, [data]); + + if (isLoading) { + return Loading...; + } + + return ; +}; diff --git a/apps/products-feed/src/modules/app-configuration/ui/channels-configuration.tsx b/apps/products-feed/src/modules/app-configuration/ui/channels-configuration.tsx deleted file mode 100644 index 858c3d5..0000000 --- a/apps/products-feed/src/modules/app-configuration/ui/channels-configuration.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { trpcClient } from "../../trpc/trpc-client"; -import { LinearProgress, Paper } from "@material-ui/core"; -import React, { useEffect, useMemo, useState } from "react"; -import { EditIcon, IconButton, makeStyles } from "@saleor/macaw-ui"; -import { AppConfigContainer } from "../app-config-container"; -import { UrlConfigurationForm } from "./url-configuration-form"; -import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge"; -import { AppColumnsLayout } from "../../ui/app-columns-layout"; -import { FeedPreviewCard } from "./feed-preview-card"; -import { Instructions } from "./instructions"; -import { SideMenu } from "./side-menu"; -import { useDashboardNotification } from "@saleor/apps-shared"; -import { S3ConfigurationForm } from "./s3-configuration-form"; - -const useStyles = makeStyles((theme) => { - return { - grid: { - display: "grid", - gridTemplateColumns: "1fr 1fr", - alignItems: "start", - gap: 40, - }, - instructionsContainer: { - padding: 15, - }, - configurationColumn: { - display: "flex", - flexDirection: "column", - gap: 20, - }, - }; -}); - -export const ChannelsConfiguration = () => { - const styles = useStyles(); - - const { appBridge } = useAppBridge(); - const { notifySuccess } = useDashboardNotification(); - - const { data: configurationData, refetch: refetchConfig } = - trpcClient.appConfiguration.fetch.useQuery(); - - const channels = trpcClient.channels.fetch.useQuery(); - - const [activeChannelSlug, setActiveChannelSlug] = useState(null); - - const { mutate, error: saveError } = trpcClient.appConfiguration.setAndReplace.useMutation({ - onSuccess() { - refetchConfig(); - notifySuccess("Success", "Saved app configuration"); - }, - }); - - useEffect(() => { - if (channels.isSuccess) { - setActiveChannelSlug(channels.data![0]?.slug ?? null); - } - }, [channels.isSuccess, channels.data]); - - const activeChannel = useMemo(() => { - try { - return channels.data!.find((c) => c.slug === activeChannelSlug)!; - } catch (e) { - return null; - } - }, [channels.data, activeChannelSlug]); - - if (channels.isLoading || !channels.data) { - return ; - } - - if (!activeChannel) { - return
Error. No channel available
; - } - - return ( - - { - appBridge?.dispatch( - actions.Redirect({ - to: `/channels/`, - }) - ); - }} - > - - - } - onClick={(id) => setActiveChannelSlug(id)} - items={channels.data.map((c) => ({ label: c.name, id: c.slug })) || []} - /> - - {activeChannel ? ( -
- - { - const newConfig = AppConfigContainer.setChannelUrlConfiguration(configurationData)( - activeChannel.slug - )(data); - - mutate(newConfig); - }} - initialData={AppConfigContainer.getChannelUrlConfiguration(configurationData)( - activeChannel.slug - )} - channelName={activeChannel?.name ?? activeChannelSlug} - /> - { - const newConfig = AppConfigContainer.setChannelS3BucketConfiguration( - configurationData - )(activeChannel.slug)(data); - - mutate(newConfig); - }} - initialData={AppConfigContainer.getChannelS3BucketConfiguration(configurationData)( - activeChannel.slug - )} - channelName={activeChannel?.name ?? activeChannelSlug} - /> - {saveError && {saveError.message}} - - -
- ) : null} - -
- ); -}; diff --git a/apps/products-feed/src/modules/app-configuration/ui/feed-preview-card.tsx b/apps/products-feed/src/modules/app-configuration/ui/feed-preview-card.tsx deleted file mode 100644 index 44000d1..0000000 --- a/apps/products-feed/src/modules/app-configuration/ui/feed-preview-card.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { Paper, TextField, Typography } from "@material-ui/core"; -import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge"; -import { Button, makeStyles } from "@saleor/macaw-ui"; - -const useStyles = makeStyles((theme) => { - return { - header: { marginBottom: 20 }, - grid: { display: "grid", gridTemplateColumns: "1fr 1fr", alignItems: "start", gap: 40 }, - formContainer: { - top: 0, - position: "sticky", - }, - instructionsContainer: { - padding: 15, - }, - field: { - marginBottom: 20, - }, - }; -}); - -interface FeedPreviewCardProps { - channelSlug: string; -} - -export const FeedPreviewCard = ({ channelSlug }: FeedPreviewCardProps) => { - const styles = useStyles(); - - const { appBridge } = useAppBridge(); - const { saleorApiUrl } = appBridge?.getState() || {}; - - const googleFeedUrl = `${window.location.origin}/api/feed/${encodeURIComponent( - saleorApiUrl as string - )}/${channelSlug}/google.xml`; - - const openUrlInNewTab = async (url: string) => { - await appBridge?.dispatch(actions.Redirect({ to: url, newContext: true })); - }; - - return ( - - - Your Google Merchant Feed preview - - - - - - ); -}; diff --git a/apps/products-feed/src/modules/app-configuration/ui/instructions.tsx b/apps/products-feed/src/modules/app-configuration/ui/instructions.tsx deleted file mode 100644 index 56e815a..0000000 --- a/apps/products-feed/src/modules/app-configuration/ui/instructions.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Link, Paper, Typography } from "@material-ui/core"; -import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge"; -import { makeStyles } from "@saleor/macaw-ui"; - -const useStyles = makeStyles((theme) => { - return { - instructionsContainer: { - padding: 15, - }, - }; -}); - -export const Instructions = () => { - const styles = useStyles(); - - const { appBridge } = useAppBridge(); - - return ( - - - Using the feed - - - Configure your Google Merchant account to schedule fetches of the feed. Instructions can be - found{" "} - { - appBridge?.dispatch( - actions.Redirect({ - to: "https://support.google.com/merchants/answer/1219255", - newContext: true, - }) - ); - }} - > - here - - . - - - - URL templates - - - URLs to products in your storefront are generated dynamically, based on the product data. - For example, the template - - {"https://example.com/product/{productSlug}"} - Will produce - {"https://example.com/product/red-t-shirt"} - Available fields: productId, productSlug, variantId - - ); -}; diff --git a/apps/products-feed/src/modules/app-configuration/ui/s3-configuration-form.tsx b/apps/products-feed/src/modules/app-configuration/ui/s3-configuration-form.tsx deleted file mode 100644 index d0a130b..0000000 --- a/apps/products-feed/src/modules/app-configuration/ui/s3-configuration-form.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { S3BucketConfiguration, SellerShopConfig } from "../app-config"; -import { useForm } from "react-hook-form"; -import { TextField, TextFieldProps, Typography } from "@material-ui/core"; -import { Button, makeStyles } from "@saleor/macaw-ui"; -import React from "react"; -import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge"; - -const useStyles = makeStyles({ - field: { - marginBottom: 20, - }, - form: { - padding: 20, - }, - channelName: { - fontFamily: "monospace", - cursor: "pointer", - }, -}); - -type Props = { - channelSlug: string; - channelName: string; - channelID: string; - onSubmit(data: S3BucketConfiguration): Promise; - initialData?: S3BucketConfiguration | null; -}; - -export const S3ConfigurationForm = (props: Props) => { - const { register, handleSubmit } = useForm({ - defaultValues: props.initialData ?? undefined, - }); - const styles = useStyles(); - const { appBridge } = useAppBridge(); - - const CommonFieldProps: TextFieldProps = { - className: styles.field, - fullWidth: true, - }; - - return ( -
{ - props.onSubmit(data); - })} - className={styles.form} - > - - S3 storage - - - - - - - - - - - - ); -}; diff --git a/apps/products-feed/src/modules/app-configuration/ui/side-menu.tsx b/apps/products-feed/src/modules/app-configuration/ui/side-menu.tsx deleted file mode 100644 index 378d9fa..0000000 --- a/apps/products-feed/src/modules/app-configuration/ui/side-menu.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { Card, CardContent, CardHeader, Divider } from "@material-ui/core"; -("@material-ui/icons"); -import { DeleteIcon, IconButton, List, ListItem, ListItemCell } from "@saleor/macaw-ui"; -import clsx from "clsx"; -import React from "react"; - -import { makeStyles } from "@saleor/macaw-ui"; -import { Skeleton } from "@material-ui/lab"; - -export const useStyles = makeStyles((theme) => ({ - menu: { - height: "fit-content", - }, - clickable: { - cursor: "pointer", - }, - selected: { - "&&&&::before": { - position: "absolute", - left: 0, - width: "4px", - height: "100%", - backgroundColor: theme.palette.saleor.active[1], - }, - }, - spaceBetween: { - display: "flex", - justifyContent: "space-between", - alignItems: "center", - }, - tableRow: { - minHeight: "48px", - "&::after": { - display: "none", - }, - }, - greyText: { - color: theme.palette.text.hint, - }, - link: { - all: "inherit", - display: "contents", - }, -})); - -interface SideMenuProps { - title: string; - noItemsText?: string; - items: { id: string; label: string }[]; - selectedItemId?: string; - headerToolbar?: React.ReactNode; - onDelete?: (itemId: string) => void; - onClick: (itemId: string) => void; -} - -export const SideMenu: React.FC = ({ - title, - items, - headerToolbar, - selectedItemId, - noItemsText, - onDelete, - onClick, -}) => { - const classes = useStyles(); - - const isNoItems = !items || !items.length; - - return ( - - - {isNoItems ? ( - !!noItemsText && {noItemsText} - ) : ( - - {items.map((item) => ( - - - onClick(item.id)} - > - -
- {item.label} - {!!onDelete && ( - { - event.stopPropagation(); - event.preventDefault(); - onDelete(item.id); - }} - > - - - )} -
-
-
-
- )) ?? } - -
- )} -
- ); -}; diff --git a/apps/products-feed/src/modules/app-configuration/ui/url-configuration-form.tsx b/apps/products-feed/src/modules/app-configuration/ui/url-configuration-form.tsx deleted file mode 100644 index f4ba4fe..0000000 --- a/apps/products-feed/src/modules/app-configuration/ui/url-configuration-form.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { SellerShopConfig } from "../app-config"; -import { useForm } from "react-hook-form"; -import { TextField, TextFieldProps, Typography } from "@material-ui/core"; -import { Button, makeStyles } from "@saleor/macaw-ui"; -import React from "react"; -import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge"; - -const useStyles = makeStyles({ - field: { - marginBottom: 20, - }, - form: { - padding: 20, - }, - channelName: { - fontFamily: "monospace", - cursor: "pointer", - }, -}); - -type Props = { - channelSlug: string; - channelName: string; - channelID: string; - onSubmit(data: SellerShopConfig["urlConfiguration"]): Promise; - initialData?: SellerShopConfig["urlConfiguration"] | null; -}; - -export const UrlConfigurationForm = (props: Props) => { - const { register, handleSubmit } = useForm({ - defaultValues: props.initialData ?? undefined, - }); - const styles = useStyles(); - const { appBridge } = useAppBridge(); - - const CommonFieldProps: TextFieldProps = { - className: styles.field, - fullWidth: true, - }; - - const handleChannelNameClick = () => { - appBridge?.dispatch( - actions.Redirect({ - to: `/channels/${props.channelID}`, - }) - ); - }; - - return ( -
{ - props.onSubmit(data); - })} - className={styles.form} - > - - Configure - - {` ${props.channelName} `} - - channel - - - - - - ); -}; diff --git a/apps/products-feed/src/modules/app-configuration/url-configuration.ts b/apps/products-feed/src/modules/app-configuration/url-configuration.ts deleted file mode 100644 index 7a07ee0..0000000 --- a/apps/products-feed/src/modules/app-configuration/url-configuration.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { SellerShopConfig } from "./app-config"; - -export const UrlConfiguration = { - createEmpty(): SellerShopConfig["urlConfiguration"] { - return { - storefrontUrl: "", - productStorefrontUrl: "", - }; - }, -}; diff --git a/apps/products-feed/src/modules/category-mapping/categories-fetcher.test.ts b/apps/products-feed/src/modules/category-mapping/categories-fetcher.test.ts new file mode 100644 index 0000000..9bbf0ee --- /dev/null +++ b/apps/products-feed/src/modules/category-mapping/categories-fetcher.test.ts @@ -0,0 +1,168 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { CategoriesFetcher } from "./categories-fetcher"; +import { Client, OperationResult } from "urql"; +import { FetchCategoriesWithMappingQuery } from "../../../generated/graphql"; + +type FetchResult = OperationResult; + +const generateCategoryEdgeMock = (uniqueIncrement: number) => { + return { + node: { + name: `Category ${uniqueIncrement}`, + id: `cat-${uniqueIncrement}`, + googleCategoryId: `${uniqueIncrement * 2}`, + }, + }; +}; + +const generateArr = (length: number) => new Array(length).fill(null).map((_, index) => index); + +const wait = () => new Promise((res) => setTimeout(res, 100)); + +describe("CategoriesFetcher", () => { + const mockQueryPromise = vi.fn(); + + const mockClient: Pick = { + // @ts-ignore - It's hard to mock urql mocks - but it can be improved + query() { + return { + toPromise: mockQueryPromise, + }; + }, + }; + + const instance = new CategoriesFetcher(mockClient); + + beforeEach(() => { + vi.resetAllMocks(); + }); + + it("Fetches single page of categories correctly", async () => { + mockQueryPromise.mockImplementationOnce(async () => { + const data: FetchCategoriesWithMappingQuery = { + categories: { + pageInfo: { + endCursor: undefined, + hasNextPage: false, + }, + edges: [generateCategoryEdgeMock(1), generateCategoryEdgeMock(2)], + }, + }; + + return { + error: undefined, + data: data, + } as FetchResult; + }); + + const result = await instance.fetchAllCategories(); + + expect(result).toEqual([ + { + googleCategoryId: "2", + id: "cat-1", + name: "Category 1", + }, + { + googleCategoryId: "4", + id: "cat-2", + name: "Category 2", + }, + ]); + }); + + it("Fetches 3 pages correctly and merges them", async () => { + mockQueryPromise.mockImplementationOnce(async () => { + await wait(); + + return { + error: undefined, + data: { + categories: { + edges: generateArr(100).map((index) => generateCategoryEdgeMock(index)), + pageInfo: { + hasNextPage: true, + endCursor: "cat-99", + }, + }, + }, + } as FetchResult; + }); + + mockQueryPromise.mockImplementationOnce(async () => { + await wait(); + + return { + error: undefined, + data: { + categories: { + edges: generateArr(100) + .map((index) => index + 100) + .map((index) => generateCategoryEdgeMock(index)), + pageInfo: { + hasNextPage: true, + endCursor: "cat-199", + }, + }, + }, + } as FetchResult; + }); + + mockQueryPromise.mockImplementationOnce(async () => { + await wait(); + + return { + error: undefined, + data: { + categories: { + edges: generateArr(100) + .map((index) => index + 200) + .map((index) => generateCategoryEdgeMock(index)), + pageInfo: { + hasNextPage: false, + endCursor: undefined, + }, + }, + }, + } as FetchResult; + }); + + const result = await instance.fetchAllCategories(); + + expect(result).toHaveLength(300); + + /** + * Some indexes assertions + */ + [0, 5, 99, 299].forEach((index) => { + expect(result[index]).toEqual({ + googleCategoryId: `${index * 2}`, + id: `cat-${index}`, + name: `Category ${index}`, + }); + }); + }); + + it("Returns empty array if not categories returned from the API", async () => { + mockQueryPromise.mockImplementationOnce(async () => { + const data: FetchCategoriesWithMappingQuery = { + categories: { + pageInfo: { + endCursor: undefined, + hasNextPage: false, + }, + edges: [], + }, + }; + + return { + error: undefined, + data: data, + } as FetchResult; + }); + + const result = await instance.fetchAllCategories(); + + expect(result).toEqual([]); + }); +}); diff --git a/apps/products-feed/src/modules/category-mapping/categories-fetcher.ts b/apps/products-feed/src/modules/category-mapping/categories-fetcher.ts new file mode 100644 index 0000000..934c492 --- /dev/null +++ b/apps/products-feed/src/modules/category-mapping/categories-fetcher.ts @@ -0,0 +1,49 @@ +import { Client } from "urql"; +import { + CategoryWithMappingFragmentFragment, + FetchCategoriesWithMappingDocument, +} from "../../../generated/graphql"; + +export class CategoriesFetcher { + constructor(private apiClient: Pick) {} + + private async fetchRecursivePage( + accumulator: CategoryWithMappingFragmentFragment[], + cursor?: string + ): Promise { + const result = await this.apiClient + .query(FetchCategoriesWithMappingDocument, { + cursor, + }) + .toPromise(); + + if (result.error) { + throw new Error(result.error.message); + } + + if (!result.data) { + // todo sentry + throw new Error("Empty categories data"); + } + + accumulator = [...accumulator, ...(result.data.categories?.edges.map((c) => c.node) ?? [])]; + + const hasNextPage = result.data.categories?.pageInfo.hasNextPage; + const endCursor = result.data.categories?.pageInfo.endCursor; + + if (hasNextPage && endCursor) { + return this.fetchRecursivePage(accumulator, endCursor); + } else { + return accumulator; + } + } + + /** + * Fetches all categories pages - standard page is max 100 items + */ + async fetchAllCategories(): Promise { + let categories: CategoryWithMappingFragmentFragment[] = []; + + return this.fetchRecursivePage(categories, undefined); + } +} diff --git a/apps/products-feed/src/modules/category-mapping/category-mapping.router.ts b/apps/products-feed/src/modules/category-mapping/category-mapping.router.ts index d77d740..b6001f5 100644 --- a/apps/products-feed/src/modules/category-mapping/category-mapping.router.ts +++ b/apps/products-feed/src/modules/category-mapping/category-mapping.router.ts @@ -1,54 +1,54 @@ import { router } from "../trpc/trpc-server"; import { protectedClientProcedure } from "../trpc/protected-client-procedure"; -import { createLogger } from "@saleor/apps-shared"; import { SetCategoryMappingInputSchema } from "./category-mapping-input-schema"; -import { - FetchCategoriesWithMappingDocument, - UpdateCategoryMappingDocument, -} from "../../../generated/graphql"; +import { UpdateCategoryMappingDocument } from "../../../generated/graphql"; import { TRPCError } from "@trpc/server"; +import { CategoriesFetcher } from "./categories-fetcher"; export const categoryMappingRouter = router({ /** * Get all the category mappings to Google categories from its public metadata */ - getCategoryMappings: protectedClientProcedure.query(async ({ ctx, input }) => { - const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl }); + getCategoryMappings: protectedClientProcedure.query( + async ({ ctx: { logger, apiClient }, input }) => { + const categoriesFetcher = new CategoriesFetcher(apiClient); - logger.debug("categoriesRouter.getCategoryMappings called"); - - const result = await ctx.apiClient.query(FetchCategoriesWithMappingDocument, {}).toPromise(); - const categories = result.data?.categories?.edges?.map((edge) => edge.node) || []; - - if (result.error) { - logger.error(`Error during the GraphqlAPI call: ${result.error.message}`); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Can't fetch the categories", + const result = await categoriesFetcher.fetchAllCategories().catch((e) => { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Can't fetch the categories", + }); }); - } - return categories; - }), + logger.debug("Returning categories"); + + return result; + } + ), /** * Sets the Google category mapping for a given category in its public metadata */ setCategoryMapping: protectedClientProcedure .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) .input(SetCategoryMappingInputSchema) - .mutation(async ({ ctx, input }) => { - const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl }); - - logger.debug("categoriesRouter.setCategoryMapping called"); - const { error } = await ctx.apiClient + .mutation(async ({ ctx: { logger, apiClient }, input }) => { + const { error } = await apiClient .mutation(UpdateCategoryMappingDocument, { id: input.categoryId, googleCategoryId: input.googleCategoryId || "", }) .toPromise(); + logger.debug( + { + input, + }, + "Updated category mapping" + ); + if (error) { logger.error(`Error during the GraphqlAPI call: ${error.message}`); + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Can't save the category", diff --git a/apps/products-feed/src/modules/category-mapping/construct-category-breadcrumbs.tsx b/apps/products-feed/src/modules/category-mapping/construct-category-breadcrumbs.tsx new file mode 100644 index 0000000..bdd14ef --- /dev/null +++ b/apps/products-feed/src/modules/category-mapping/construct-category-breadcrumbs.tsx @@ -0,0 +1,27 @@ +import { CategoryWithMappingFragmentFragment } from "../../../generated/graphql"; +import React from "react"; +import { Box, ChevronRightIcon, Text } from "@saleor/macaw-ui/next"; + +export const constructCategoryBreadcrumbs = (category: CategoryWithMappingFragmentFragment) => + [category.parent?.parent?.name, category.parent?.name, category.name].filter((segment) => + Boolean(segment) + ); + +export const CategoryBreadcrumbs = (props: { category: CategoryWithMappingFragmentFragment }) => { + const breadcrumbs = constructCategoryBreadcrumbs(props.category); + + return ( + + {breadcrumbs.map((category, index) => { + const isLast = index === breadcrumbs.length - 1; + + return ( + + {category} + {!isLast && } + + ); + })} + + ); +}; diff --git a/apps/products-feed/src/modules/category-mapping/ui/category-mapping-form.tsx b/apps/products-feed/src/modules/category-mapping/ui/category-mapping-form.tsx index 351b5b1..62dc424 100644 --- a/apps/products-feed/src/modules/category-mapping/ui/category-mapping-form.tsx +++ b/apps/products-feed/src/modules/category-mapping/ui/category-mapping-form.tsx @@ -1,6 +1,5 @@ import { Controller, useForm } from "react-hook-form"; -import { FormControl, Grid, InputLabel, MenuItem, Select, Typography } from "@material-ui/core"; -import { Button, makeStyles } from "@saleor/macaw-ui"; + import React from "react"; import { zodResolver } from "@hookform/resolvers/zod"; import { @@ -8,29 +7,24 @@ import { SetCategoryMappingInputType, } from "../category-mapping-input-schema"; import { CategoryWithMappingFragmentFragment } from "../../../../generated/graphql"; -import { GoogleProductCategories } from "../google-product-categories"; import { trpcClient } from "../../trpc/trpc-client"; import { useDashboardNotification } from "@saleor/apps-shared"; - -const useStyles = makeStyles({ - field: { - marginBottom: 20, - }, - form: { - padding: 20, - }, - channelName: { - fontFamily: "monospace", - cursor: "pointer", - }, -}); +import { Box, Button, PropsWithBox } from "@saleor/macaw-ui/next"; +import { GoogleProductCategories } from "../google-product-categories"; +import { CategoryBreadcrumbs } from "../construct-category-breadcrumbs"; type CategoryMappingFormProps = { category: CategoryWithMappingFragmentFragment; }; -export const CategoryMappingForm = ({ category }: CategoryMappingFormProps) => { - const styles = useStyles(); +/** + * Use Combobox, when virtualized. For such amount of data it almost crashes app due to slow rendering + * https://github.com/saleor/macaw-ui/issues/452 + */ +export const CategoryMappingForm = ({ + category, + ...props +}: PropsWithBox) => { const { notifySuccess, notifyError } = useDashboardNotification(); const { control, handleSubmit, formState } = useForm({ @@ -49,61 +43,42 @@ export const CategoryMappingForm = ({ category }: CategoryMappingFormProps) => { }, }); - const categoryBreadcrumbs = [category.parent?.parent?.name, category.parent?.name, category.name] - .filter((segment) => segment) - .join(" > "); - return ( -
{ mutate(data); })} - className={styles.form} > - - {categoryBreadcrumbs} - - - - { - return ( - - Google Product Category - - - ); - }} - /> - - - - - -
+ + + { + return ( + + ); + }} + /> + + + ); }; diff --git a/apps/products-feed/src/modules/category-mapping/ui/category-mapping-preview.tsx b/apps/products-feed/src/modules/category-mapping/ui/category-mapping-preview.tsx new file mode 100644 index 0000000..259de09 --- /dev/null +++ b/apps/products-feed/src/modules/category-mapping/ui/category-mapping-preview.tsx @@ -0,0 +1,50 @@ +import { Box, Text } from "@saleor/macaw-ui/next"; +import { trpcClient } from "../../trpc/trpc-client"; +import { CategoryBreadcrumbs } from "../construct-category-breadcrumbs"; +import { GoogleProductCategories } from "../google-product-categories"; +import { useMemo } from "react"; + +const GoogleCategory = (props: { categoryId: string }) => { + const googleCategory = useMemo(() => { + return GoogleProductCategories.find((cat) => cat.id === props.categoryId); + }, [props.categoryId]); + + return {googleCategory?.name}; +}; + +export const CategoryMappingPreview = () => { + const { data: categories, isLoading } = trpcClient.categoryMapping.getCategoryMappings.useQuery(); + + if (isLoading) { + return Loading; + } + + if (categories?.length === 0) { + return No categories; + } + + return ( + + + Mapped categories + + {categories! + .filter((c) => c.googleCategoryId) + .map((category) => { + return ( + + + + + ); + })} + + ); +}; diff --git a/apps/products-feed/src/modules/category-mapping/ui/category-mapping.tsx b/apps/products-feed/src/modules/category-mapping/ui/category-mapping.tsx index 3974e74..6d40180 100644 --- a/apps/products-feed/src/modules/category-mapping/ui/category-mapping.tsx +++ b/apps/products-feed/src/modules/category-mapping/ui/category-mapping.tsx @@ -1,18 +1,25 @@ import { trpcClient } from "../../trpc/trpc-client"; -import { Paper } from "@material-ui/core"; + import React from "react"; import { CategoryMappingForm } from "./category-mapping-form"; +import { Box, Text } from "@saleor/macaw-ui/next"; export const CategoryMapping = () => { - const categories = trpcClient.categoryMapping.getCategoryMappings.useQuery(); + const { data: categories, isLoading } = trpcClient.categoryMapping.getCategoryMappings.useQuery(); + + if (isLoading) { + return Loading; + } + + if (categories && categories.length === 0) { + return No categories to map; + } return ( - - {categories.data?.length - ? categories.data.map((category) => ( - - )) - : null} - + + {categories!.map((category) => ( + + ))} + ); }; diff --git a/apps/products-feed/src/modules/category-mapping/ui/instructions.tsx b/apps/products-feed/src/modules/category-mapping/ui/instructions.tsx deleted file mode 100644 index 050af5e..0000000 --- a/apps/products-feed/src/modules/category-mapping/ui/instructions.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Link, Paper, Typography } from "@material-ui/core"; -import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge"; -import { makeStyles } from "@saleor/macaw-ui"; - -const useStyles = makeStyles((theme) => { - return { - instructionsContainer: { - padding: 15, - }, - }; -}); - -export const Instructions = () => { - const styles = useStyles(); - - const { appBridge } = useAppBridge(); - - return ( - - - Using the feed - - - Configure your Google Merchant account to schedule fetches of the feed. Instructions can be - found{" "} - { - appBridge?.dispatch( - actions.Redirect({ - to: "https://support.google.com/merchants/answer/1219255", - newContext: true, - }) - ); - }} - > - here - - . - - - - URL templates - - - URLs to products in your storefront are generated dynamically, based on the product data. - For example, the template - - {"https://example.com/product/{productSlug}"} - Will produce - {"https://example.com/product/red-t-shirt"} - Available fields: productId, productSlug, variantId - - ); -}; diff --git a/apps/products-feed/src/modules/channels/channels.router.ts b/apps/products-feed/src/modules/channels/channels.router.ts deleted file mode 100644 index ef5c44a..0000000 --- a/apps/products-feed/src/modules/channels/channels.router.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { router } from "../trpc/trpc-server"; -import { protectedClientProcedure } from "../trpc/protected-client-procedure"; -import { ChannelsFetcher } from "./channels-fetcher"; -import { ChannelFragment } from "../../../generated/graphql"; -import { createClient } from "../../lib/create-graphq-client"; - -export const channelsRouter = router({ - fetch: protectedClientProcedure.query(async ({ ctx, input }): Promise => { - const client = createClient(ctx.saleorApiUrl, async () => - Promise.resolve({ token: ctx.appToken }) - ); - - const fetcher = new ChannelsFetcher(client); - - return fetcher.fetchChannels().then((channels) => channels ?? []); - }), -}); diff --git a/apps/products-feed/src/lib/editor-js-plaintext-renderer.test.ts b/apps/products-feed/src/modules/editor-js/editor-js-plaintext-renderer.test.ts similarity index 100% rename from apps/products-feed/src/lib/editor-js-plaintext-renderer.test.ts rename to apps/products-feed/src/modules/editor-js/editor-js-plaintext-renderer.test.ts diff --git a/apps/products-feed/src/lib/editor-js-plaintext-renderer.ts b/apps/products-feed/src/modules/editor-js/editor-js-plaintext-renderer.ts similarity index 100% rename from apps/products-feed/src/lib/editor-js-plaintext-renderer.ts rename to apps/products-feed/src/modules/editor-js/editor-js-plaintext-renderer.ts diff --git a/apps/products-feed/src/modules/editor-js/readme.md b/apps/products-feed/src/modules/editor-js/readme.md new file mode 100644 index 0000000..07f0b0e --- /dev/null +++ b/apps/products-feed/src/modules/editor-js/readme.md @@ -0,0 +1,3 @@ +Editor.js is used in Dashboard for product description. Feed description should be rendered to standard text. + +This module contains logic that transforms Editor.js to plain text diff --git a/apps/products-feed/src/modules/feed-url/fill-url-template.test.ts b/apps/products-feed/src/modules/feed-url/fill-url-template.test.ts new file mode 100644 index 0000000..befb30f --- /dev/null +++ b/apps/products-feed/src/modules/feed-url/fill-url-template.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { fillUrlTemplate } from "./fill-url-template"; +import { z } from "zod"; + +describe("fillUrlTemplate", () => { + it("Replaces template strings in url", () => { + expect( + fillUrlTemplate({ + urlTemplate: `https://example.com/p/{productSlug}/{productId}/{variantId}`, + productId: "PRODUCT_ID", + productSlug: "PRODUCT_SLUG", + variantId: "VARIANT_ID", + }) + ).toEqual("https://example.com/p/PRODUCT_SLUG/PRODUCT_ID/VARIANT_ID"); + }); + + /** + * Not likely to happen, but better safe than sorry + */ + it("Encodes components so special characters are not passed to URL", () => { + const resultUrl = fillUrlTemplate({ + urlTemplate: `https://example.com/p/{productSlug}/{productId}/{variantId}`, + productId: "productId < ", + productSlug: "product/slug", + variantId: "variantId%12!", + }); + + /** + * Validate URL with URL api + */ + expect(() => new URL(resultUrl)).not.toThrow(); + + expect(resultUrl).toEqual( + "https://example.com/p/product%2Fslug/productId%20%3C%20%20%20/variantId%2512!" + ); + }); +}); diff --git a/apps/products-feed/src/lib/fill-url-template.ts b/apps/products-feed/src/modules/feed-url/fill-url-template.ts similarity index 58% rename from apps/products-feed/src/lib/fill-url-template.ts rename to apps/products-feed/src/modules/feed-url/fill-url-template.ts index 4d14f46..438846c 100644 --- a/apps/products-feed/src/lib/fill-url-template.ts +++ b/apps/products-feed/src/modules/feed-url/fill-url-template.ts @@ -12,6 +12,6 @@ export const fillUrlTemplate = ({ variantId, }: FillUrlTemplateArgs) => urlTemplate - .replace("{productId}", productId) - .replace("{productSlug}", productSlug) - .replace("{variantId}", variantId); + .replace("{productId}", encodeURIComponent(productId)) + .replace("{productSlug}", encodeURIComponent(productSlug)) + .replace("{variantId}", encodeURIComponent(variantId)); diff --git a/apps/products-feed/src/modules/feed-url/use-get-feed-api-url.test.tsx b/apps/products-feed/src/modules/feed-url/use-get-feed-api-url.test.tsx new file mode 100644 index 0000000..0c1c191 --- /dev/null +++ b/apps/products-feed/src/modules/feed-url/use-get-feed-api-url.test.tsx @@ -0,0 +1,23 @@ +import React, { PropsWithChildren } from "react"; +import { describe, expect, it } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useGetFeedApiUrl } from "./use-get-feed-api-url"; +import { AppBridge, AppBridgeProvider } from "@saleor/app-sdk/app-bridge"; + +describe("useGetFeedApiUrl", function () { + const appBridge = new AppBridge({ saleorApiUrl: "https://example.com/graphql/" }); + + const HookWrapper = ({ children }: PropsWithChildren<{}>) => { + return {children}; + }; + + it("Constructs valid URL from window origin and channel slug", () => { + const { result } = renderHook(() => useGetFeedApiUrl("test-slug"), { + wrapper: HookWrapper, + }); + + expect(result.current).toEqual( + "http://localhost:3000/api/feed/https%3A%2F%2Fexample.com%2Fgraphql%2F/test-slug/google.xml" + ); + }); +}); diff --git a/apps/products-feed/src/modules/feed-url/use-get-feed-api-url.tsx b/apps/products-feed/src/modules/feed-url/use-get-feed-api-url.tsx new file mode 100644 index 0000000..3c09f1d --- /dev/null +++ b/apps/products-feed/src/modules/feed-url/use-get-feed-api-url.tsx @@ -0,0 +1,13 @@ +import { useAppBridge } from "@saleor/app-sdk/app-bridge"; + +export const useGetFeedApiUrl = (channelSlug: string) => { + const { appBridgeState } = useAppBridge(); + + if (!appBridgeState || !window.location.origin) { + return null; + } + + return `${window.location.origin}/api/feed/${encodeURIComponent( + appBridgeState.saleorApiUrl as string + )}/${channelSlug}/google.xml`; +}; diff --git a/apps/products-feed/src/modules/file-storage/s3/create-s3-client-from-configuration.ts b/apps/products-feed/src/modules/file-storage/s3/create-s3-client-from-configuration.ts index 0be4e3e..9676b9d 100644 --- a/apps/products-feed/src/modules/file-storage/s3/create-s3-client-from-configuration.ts +++ b/apps/products-feed/src/modules/file-storage/s3/create-s3-client-from-configuration.ts @@ -1,11 +1,11 @@ import { S3Client } from "@aws-sdk/client-s3"; -import { S3BucketConfiguration } from "../../app-configuration/app-config"; +import { RootConfig } from "../../app-configuration/app-config"; export const createS3ClientFromConfiguration = ({ accessKeyId, secretAccessKey, region, -}: S3BucketConfiguration) => { +}: Exclude) => { return new S3Client({ credentials: { accessKeyId: accessKeyId, diff --git a/apps/products-feed/src/modules/file-storage/s3/urls-and-names.ts b/apps/products-feed/src/modules/file-storage/s3/urls-and-names.ts index ce80a0c..2545cc0 100644 --- a/apps/products-feed/src/modules/file-storage/s3/urls-and-names.ts +++ b/apps/products-feed/src/modules/file-storage/s3/urls-and-names.ts @@ -1,7 +1,7 @@ -import { S3BucketConfiguration } from "../../app-configuration/app-config"; +import { RootConfig } from "../../app-configuration/app-config"; interface GetDownloadUrlArgs { - s3BucketConfiguration: S3BucketConfiguration; + s3BucketConfiguration: Exclude; saleorApiUrl: string; channel: string; } diff --git a/apps/products-feed/src/lib/google-feed/fetch-product-data.ts b/apps/products-feed/src/modules/google-feed/fetch-product-data.ts similarity index 93% rename from apps/products-feed/src/lib/google-feed/fetch-product-data.ts rename to apps/products-feed/src/modules/google-feed/fetch-product-data.ts index 99b6b75..b7ad503 100644 --- a/apps/products-feed/src/lib/google-feed/fetch-product-data.ts +++ b/apps/products-feed/src/modules/google-feed/fetch-product-data.ts @@ -13,7 +13,7 @@ export const getCursors = async ({ client, channel }: { client: Client; channel: logger.debug(`Fetching cursors for channel ${channel}`); let result = await client - .query(FetchProductCursorsDocument, { channel: channel as string, first: 100 }) + .query(FetchProductCursorsDocument, { channel: channel, first: 100 }) .toPromise(); const cursors: Array = []; @@ -21,7 +21,7 @@ export const getCursors = async ({ client, channel }: { client: Client; channel: while (result.data?.productVariants?.pageInfo.hasNextPage) { result = await client .query(FetchProductCursorsDocument, { - channel: channel as string, + channel: channel, first: 100, after: result.data.productVariants.pageInfo.endCursor, }) @@ -51,7 +51,7 @@ const fetchVariants = async ({ const result = await client .query(FetchProductDataForFeedDocument, { - channel: channel as string, + channel: channel, first: 100, after, }) diff --git a/apps/products-feed/src/lib/google-feed/fetch-shop-data.ts b/apps/products-feed/src/modules/google-feed/fetch-shop-data.ts similarity index 100% rename from apps/products-feed/src/lib/google-feed/fetch-shop-data.ts rename to apps/products-feed/src/modules/google-feed/fetch-shop-data.ts diff --git a/apps/products-feed/src/modules/google-feed/generate-google-xml-feed.test.ts b/apps/products-feed/src/modules/google-feed/generate-google-xml-feed.test.ts new file mode 100644 index 0000000..7a3f892 --- /dev/null +++ b/apps/products-feed/src/modules/google-feed/generate-google-xml-feed.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { GoogleFeedProductVariantFragment } from "../../../generated/graphql"; +import { generateGoogleXmlFeed } from "./generate-google-xml-feed"; + +const productBase: GoogleFeedProductVariantFragment["product"] = { + name: "Product", + __typename: "Product", + id: "product-id", + category: { + id: "cat-id", + __typename: "Category", + name: "Category Name", + googleCategoryId: "1", + }, + description: "Product description", + seoDescription: "Seo description", + slug: "product-slug", + thumbnail: { __typename: "Image", url: "" }, +}; + +const priceBase: GoogleFeedProductVariantFragment["pricing"] = { + __typename: "VariantPricingInfo", + price: { + __typename: "TaxedMoney", + gross: { + __typename: "Money", + amount: 1, + currency: "USD", + }, + }, +}; + +describe("generateGoogleXmlFeed", () => { + it("Generates feed", () => { + const result = generateGoogleXmlFeed({ + productStorefrontUrl: "https://example.com/p/{productSlug}", + shopDescription: "Description", + shopName: "Example", + storefrontUrl: "https://example.com", + productVariants: [ + { + id: "id1", + __typename: "ProductVariant", + sku: "sku1", + quantityAvailable: 1, + pricing: priceBase, + name: "Product variant", + product: productBase, + }, + { + id: "id2", + __typename: "ProductVariant", + sku: "sku2", + quantityAvailable: 0, + pricing: priceBase, + name: "Product variant 2", + product: productBase, + }, + ], + }); + + expect(result).toMatchInlineSnapshot(` + " + + + Example + https://example.com + Description + + sku1 + Product - Product variant + new + in_stock + Category Name + 1 + https://example.com/p/product-slug + 1.00 USD + + + sku2 + Product - Product variant 2 + new + out_of_stock + Category Name + 1 + https://example.com/p/product-slug + 1.00 USD + + + " + `); + }); +}); diff --git a/apps/products-feed/src/lib/google-feed/generate-google-xml-feed.ts b/apps/products-feed/src/modules/google-feed/generate-google-xml-feed.ts similarity index 52% rename from apps/products-feed/src/lib/google-feed/generate-google-xml-feed.ts rename to apps/products-feed/src/modules/google-feed/generate-google-xml-feed.ts index e3e6d2e..642b882 100644 --- a/apps/products-feed/src/lib/google-feed/generate-google-xml-feed.ts +++ b/apps/products-feed/src/modules/google-feed/generate-google-xml-feed.ts @@ -2,7 +2,7 @@ import { XMLBuilder } from "fast-xml-parser"; import { GoogleFeedProductVariantFragment } from "../../../generated/graphql"; import { productToProxy } from "./product-to-proxy"; import { shopDetailsToProxy } from "./shop-details-to-proxy"; -import { EditorJsPlaintextRenderer } from "../editor-js-plaintext-renderer"; +import { EditorJsPlaintextRenderer } from "../editor-js/editor-js-plaintext-renderer"; interface GenerateGoogleXmlFeedArgs { productVariants: GoogleFeedProductVariantFragment[]; @@ -12,6 +12,22 @@ interface GenerateGoogleXmlFeedArgs { shopDescription?: string; } +/** + * Price format has to be altered from the en format to the one expected by Google + * eg. 1.00 USD, 5.00 PLN + */ +const formatCurrency = (currency: string, amount: number) => { + return ( + new Intl.NumberFormat("en-EN", { + useGrouping: false, + minimumFractionDigits: 2, + style: "decimal", + currencyDisplay: "code", + currency: currency, + }).format(amount) + ` ${currency}` + ); +}; + export const generateGoogleXmlFeed = ({ productVariants, storefrontUrl, @@ -19,33 +35,29 @@ export const generateGoogleXmlFeed = ({ shopName, shopDescription, }: GenerateGoogleXmlFeedArgs) => { - const items = productVariants.map((v) => { - // Price format has to be altered from the en format to the one expected by Google - const price = v.pricing?.price?.gross.currency - ? new Intl.NumberFormat("en-EN", { - useGrouping: false, - minimumFractionDigits: 2, - style: "decimal", - currencyDisplay: "code", - currency: v.pricing?.price?.gross.currency, - }).format(v.pricing?.price?.gross.amount) + ` ${v.pricing?.price?.gross.currency}` - : undefined; + const items = productVariants.map((variant) => { + const currency = variant.pricing?.price?.gross.currency; + const amount = variant.pricing?.price?.gross.amount; + + const price = currency ? formatCurrency(currency, amount!) : undefined; return productToProxy({ storefrontUrlTemplate: productStorefrontUrl, - id: v.product.id, - name: `${v.product.name} - ${v.name}`, - slug: v.product.slug, - variantId: v.id, - sku: v.sku || undefined, - description: EditorJsPlaintextRenderer({ stringData: v.product.description }), - availability: v.quantityAvailable && v.quantityAvailable > 0 ? "in_stock" : "out_of_stock", - category: v.product.category?.name || "unknown", - googleProductCategory: v.product.category?.googleCategoryId || "", + id: variant.product.id, + name: `${variant.product.name} - ${variant.name}`, + slug: variant.product.slug, + variantId: variant.id, + sku: variant.sku || undefined, + description: EditorJsPlaintextRenderer({ stringData: variant.product.description }), + availability: + variant.quantityAvailable && variant.quantityAvailable > 0 ? "in_stock" : "out_of_stock", + category: variant.product.category?.name || "unknown", + googleProductCategory: variant.product.category?.googleCategoryId || "", price: price, - imageUrl: v.product.thumbnail?.url || "", + imageUrl: variant.product.thumbnail?.url || "", }); }); + const builder = new XMLBuilder({ attributeNamePrefix: "@_", attributesGroupName: "@", @@ -79,6 +91,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" channel: channelData.concat(items), }, ], diff --git a/apps/products-feed/src/modules/google-feed/get-google-feed-settings.test.ts b/apps/products-feed/src/modules/google-feed/get-google-feed-settings.test.ts new file mode 100644 index 0000000..6665f85 --- /dev/null +++ b/apps/products-feed/src/modules/google-feed/get-google-feed-settings.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from "vitest"; +import { GoogleFeedSettingsFetcher } from "./get-google-feed-settings"; +import { AppConfig } from "../app-configuration/app-config"; + +describe("GoogleFeedSettingsFetcher", () => { + it("Fetches channel-specific config with S3 config from Metadata", async () => { + const getMetadataMock = vi.fn().mockImplementation(async () => { + const appConfig = new AppConfig({ + channelConfig: { + testChannel: { + storefrontUrls: { + storefrontUrl: "https://example.com", + productStorefrontUrl: "https://example.com/p/{productSlug}/v/{variantId}", + }, + }, + anotherChannel: { + storefrontUrls: { + storefrontUrl: "https://another.example.com", + productStorefrontUrl: "https://another.example.com/p/{productSlug}/v/{variantId}", + }, + }, + }, + s3: { + accessKeyId: "accessKeyId", + bucketName: "bucketName", + region: "region", + secretAccessKey: "secretAccessKey", + }, + }); + + return appConfig.serialize(); + }); + + const instance = new GoogleFeedSettingsFetcher({ + settingsManager: { + get: getMetadataMock, + }, + }); + + const result = await instance.fetch("testChannel"); + + expect(result).toEqual({ + storefrontUrl: "https://example.com", + productStorefrontUrl: "https://example.com/p/{productSlug}/v/{variantId}", + s3BucketConfiguration: { + bucketName: "bucketName", + secretAccessKey: "secretAccessKey", + accessKeyId: "accessKeyId", + region: "region", + }, + }); + }); +}); diff --git a/apps/products-feed/src/modules/google-feed/get-google-feed-settings.ts b/apps/products-feed/src/modules/google-feed/get-google-feed-settings.ts new file mode 100644 index 0000000..c4dd166 --- /dev/null +++ b/apps/products-feed/src/modules/google-feed/get-google-feed-settings.ts @@ -0,0 +1,89 @@ +import { AuthData } from "@saleor/app-sdk/APL"; +import { AppConfigMetadataManager } from "../app-configuration/app-config-metadata-manager"; +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({ + settingsManager: new AppConfigMetadataManager( + createSettingsManager(GraphqlClientFactory.fromAuthData(authData)) + ), + }); + } + + settingsManager: Pick; + + constructor(params: { settingsManager: Pick }) { + this.settingsManager = params.settingsManager; + } + + async fetch(channelSlug: string) { + const configString = await this.settingsManager.get(); + + if (!configString) { + throw new Error("App is not configured"); + } + + const appConfig = AppConfig.parse(configString); + const channelConfig = appConfig.getUrlsForChannel(channelSlug); + + 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(), + }; + } +} diff --git a/apps/products-feed/src/modules/google-feed/product-to-proxy.test.ts b/apps/products-feed/src/modules/google-feed/product-to-proxy.test.ts new file mode 100644 index 0000000..3741a91 --- /dev/null +++ b/apps/products-feed/src/modules/google-feed/product-to-proxy.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect } from "vitest"; +import { productToProxy } from "./product-to-proxy"; + +describe("productToProxy", () => { + it("Falls back product ID, if product SKU doesnt exist", () => { + const result = productToProxy({ + slug: "slug", + availability: "in_stock", + category: "1", + condition: "new", + id: "id", + name: "Name", + variantId: "variant-id", + }); + + expect(result.item).toEqual( + expect.arrayContaining([ + { + "g:id": expect.arrayContaining([{ "#text": "id" }]), + }, + ]) + ); + }); + + it('Falls back g:condition to "new" if product condition doesnt exist', () => { + const result = productToProxy({ + slug: "slug", + availability: "in_stock", + category: "1", + /* + * Missing condition field: + * condition: "new", + */ + id: "id", + name: "Name", + variantId: "variant-id", + }); + + expect(result.item).toEqual( + expect.arrayContaining([ + { + "g:condition": expect.arrayContaining([{ "#text": "new" }]), + }, + ]) + ); + }); + + it("Fills product description if exist", () => { + const result = productToProxy({ + slug: "slug", + availability: "in_stock", + category: "1", + condition: "new", + id: "id", + name: "Name", + variantId: "variant-id", + description: "Product description", + }); + + expect(result.item).toEqual( + expect.arrayContaining([ + { + "g:description": expect.arrayContaining([{ "#text": "Product description" }]), + }, + ]) + ); + }); + + it("Fills google product category if exist", () => { + const result = productToProxy({ + slug: "slug", + availability: "in_stock", + category: "1", + condition: "new", + googleProductCategory: "1", + id: "id", + name: "Name", + variantId: "variant-id", + }); + + expect(result.item).toEqual( + expect.arrayContaining([ + { + "g:google_product_category": expect.arrayContaining([{ "#text": "1" }]), + }, + ]) + ); + }); + + it("Adds link section with filled product url template", () => { + const result = productToProxy({ + slug: "slug", + availability: "in_stock", + category: "1", + condition: "new", + googleProductCategory: "1", + id: "id", + name: "Name", + variantId: "variant-id", + storefrontUrlTemplate: "https://example.com/p/{productSlug}/{productId}/{variantId}", + }); + + expect(result.item).toEqual( + expect.arrayContaining([ + { + link: expect.arrayContaining([ + { + "#text": "https://example.com/p/slug/id/variant-id", + }, + ]), + }, + ]) + ); + }); + + it("Adds g:image_link if imageUrl exist in product", () => { + const result = productToProxy({ + slug: "slug", + availability: "in_stock", + category: "1", + condition: "new", + googleProductCategory: "1", + id: "id", + name: "Name", + variantId: "variant-id", + imageUrl: "https://image.example.com", + }); + + expect(result.item).toEqual( + expect.arrayContaining([ + { + "g:image_link": expect.arrayContaining([{ "#text": "https://image.example.com" }]), + }, + ]) + ); + }); + + it("Adds g:price if product price exists", () => { + const result = productToProxy({ + slug: "slug", + availability: "in_stock", + category: "1", + condition: "new", + googleProductCategory: "1", + id: "id", + name: "Name", + variantId: "variant-id", + imageUrl: "https://image.example.com", + price: "50.00 USD", + }); + + expect(result.item).toEqual( + expect.arrayContaining([ + { + "g:price": expect.arrayContaining([{ "#text": "50.00 USD" }]), + }, + ]) + ); + }); +}); diff --git a/apps/products-feed/src/lib/google-feed/product-to-proxy.ts b/apps/products-feed/src/modules/google-feed/product-to-proxy.ts similarity index 90% rename from apps/products-feed/src/lib/google-feed/product-to-proxy.ts rename to apps/products-feed/src/modules/google-feed/product-to-proxy.ts index 240a3b9..415821f 100644 --- a/apps/products-feed/src/lib/google-feed/product-to-proxy.ts +++ b/apps/products-feed/src/modules/google-feed/product-to-proxy.ts @@ -1,8 +1,11 @@ -import { fillUrlTemplate } from "../fill-url-template"; -import { ProductEntry } from "./types"; +import { fillUrlTemplate } from "../feed-url/fill-url-template"; +import { GoogleProxyItem, ProductEntry } from "./types"; +/** + * TODO Test + */ export const productToProxy = (p: ProductEntry) => { - const item: any[] = [ + const item: GoogleProxyItem[] = [ { "g:id": [ { diff --git a/apps/products-feed/src/modules/google-feed/shop-details-to-proxy.test.ts b/apps/products-feed/src/modules/google-feed/shop-details-to-proxy.test.ts new file mode 100644 index 0000000..5159693 --- /dev/null +++ b/apps/products-feed/src/modules/google-feed/shop-details-to-proxy.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { shopDetailsToProxy } from "./shop-details-to-proxy"; + +describe("shopDetailsToProxy", () => { + it("Renders title and storefront url", () => { + expect( + shopDetailsToProxy({ + description: "Shop description", + storefrontUrl: "https://example.com", + title: "Shop title", + }) + ).toMatchInlineSnapshot(` + [ + { + "title": [ + { + "#text": "Shop title", + }, + ], + }, + { + "link": [ + { + "#text": "https://example.com", + }, + ], + }, + { + "description": [ + { + "#text": "Shop description", + }, + ], + }, + ] + `); + }); + + it("Renders without description if not provided", () => { + expect( + shopDetailsToProxy({ + storefrontUrl: "https://example.com", + title: "Shop title", + }) + ).toMatchInlineSnapshot(` + [ + { + "title": [ + { + "#text": "Shop title", + }, + ], + }, + { + "link": [ + { + "#text": "https://example.com", + }, + ], + }, + ] + `); + }); +}); diff --git a/apps/products-feed/src/lib/google-feed/shop-details-to-proxy.ts b/apps/products-feed/src/modules/google-feed/shop-details-to-proxy.ts similarity index 81% rename from apps/products-feed/src/lib/google-feed/shop-details-to-proxy.ts rename to apps/products-feed/src/modules/google-feed/shop-details-to-proxy.ts index b20ef84..6d9e61e 100644 --- a/apps/products-feed/src/lib/google-feed/shop-details-to-proxy.ts +++ b/apps/products-feed/src/modules/google-feed/shop-details-to-proxy.ts @@ -1,7 +1,7 @@ -import { ShopDetailsEntry } from "./types"; +import { GoogleProxyItem, ShopDetailsEntry } from "./types"; export const shopDetailsToProxy = ({ title, storefrontUrl, description }: ShopDetailsEntry) => { - const data: any[] = [ + const data: GoogleProxyItem[] = [ { title: [ { diff --git a/apps/products-feed/src/lib/google-feed/types.ts b/apps/products-feed/src/modules/google-feed/types.ts similarity index 80% rename from apps/products-feed/src/lib/google-feed/types.ts rename to apps/products-feed/src/modules/google-feed/types.ts index 2dc74b5..21e5409 100644 --- a/apps/products-feed/src/lib/google-feed/types.ts +++ b/apps/products-feed/src/modules/google-feed/types.ts @@ -9,7 +9,7 @@ export type ProductEntry = { imageUrl?: string; condition?: "new" | "refurbished" | "used"; price?: string; - googleProductCategory: string; + googleProductCategory?: string; availability: "in_stock" | "out_of_stock" | "preorder" | "backorder"; category: string; }; @@ -19,3 +19,5 @@ export type ShopDetailsEntry = { storefrontUrl: string; description?: string; }; + +export type GoogleProxyItem = Record>>; diff --git a/apps/products-feed/src/modules/metadata-cache/cache-configurator.ts b/apps/products-feed/src/modules/metadata-cache/cache-configurator.ts index 4ebce03..a272717 100644 --- a/apps/products-feed/src/modules/metadata-cache/cache-configurator.ts +++ b/apps/products-feed/src/modules/metadata-cache/cache-configurator.ts @@ -5,25 +5,27 @@ export class CacheConfigurator { constructor(private metadataManager: SettingsManager, private saleorApiUrl: string) {} - get({ channel }: { channel: string }): Promise { - return this.metadataManager - .get(this.metadataKeyPrefix + channel, this.saleorApiUrl) - .then((data) => { - if (!data) { - return undefined; - } + private constructKey(channel: string) { + return this.metadataKeyPrefix + channel; + } - try { - return JSON.parse(data); - } catch (e) { - throw new Error("Invalid metadata value, can't be parsed"); - } - }); + get({ channel }: { channel: string }): Promise { + return this.metadataManager.get(this.constructKey(channel), this.saleorApiUrl).then((data) => { + if (!data) { + return undefined; + } + + try { + return JSON.parse(data); + } catch (e) { + throw new Error("Invalid metadata value, can't be parsed"); + } + }); } set({ channel, value }: { channel: string; value: string[] }): Promise { return this.metadataManager.set({ - key: this.metadataKeyPrefix + channel, + key: this.constructKey(channel), value: JSON.stringify(value), domain: this.saleorApiUrl, }); diff --git a/apps/products-feed/src/modules/metadata-cache/update-cache-for-configurations.ts b/apps/products-feed/src/modules/metadata-cache/update-cache-for-configurations.ts index 36910e5..36c81fe 100644 --- a/apps/products-feed/src/modules/metadata-cache/update-cache-for-configurations.ts +++ b/apps/products-feed/src/modules/metadata-cache/update-cache-for-configurations.ts @@ -1,35 +1,33 @@ import { createLogger } from "@saleor/apps-shared"; import { CacheConfigurator } from "./cache-configurator"; import { createSettingsManager } from "../../lib/metadata-manager"; -import { getCursors } from "../../lib/google-feed/fetch-product-data"; +import { getCursors } from "../google-feed/fetch-product-data"; import { Client } from "urql"; -import { z } from "zod"; -import { appConfigInputSchema } from "../app-configuration/app-config-input-schema"; interface UpdateCacheForConfigurationsArgs { client: Client; saleorApiUrl: string; - configurations: z.infer; + channelsSlugs: string[]; } export const updateCacheForConfigurations = async ({ client, - configurations, + channelsSlugs, saleorApiUrl, }: UpdateCacheForConfigurationsArgs) => { const logger = createLogger({ saleorApiUrl: saleorApiUrl }); logger.debug("Updating the cursor cache"); + const cache = new CacheConfigurator(createSettingsManager(client), saleorApiUrl); - const channelsToUpdate = Object.keys(configurations.shopConfigPerChannel); - - const cacheUpdatePromises = channelsToUpdate.map(async (channel) => { + const cacheUpdatePromises = channelsSlugs.map(async (channel) => { const cursors = await getCursors({ client, channel }); await cache.set({ channel, value: cursors }); }); await Promise.all(cacheUpdatePromises); + logger.debug("Cursor cache updated"); }; diff --git a/apps/products-feed/src/modules/metadata-cache/update-cache-on-webhook.ts b/apps/products-feed/src/modules/metadata-cache/update-cache-on-webhook.ts new file mode 100644 index 0000000..da88ff4 --- /dev/null +++ b/apps/products-feed/src/modules/metadata-cache/update-cache-on-webhook.ts @@ -0,0 +1,41 @@ +import { GraphqlClientFactory } from "../../lib/create-graphq-client"; +import { updateCacheForConfigurations } from "./update-cache-for-configurations"; +import { AuthData } from "@saleor/app-sdk/APL"; +import { + ProductVariantWebhookPayloadFragment, + ProductWebhookPayloadFragment, +} from "../../../generated/graphql"; +import { NextApiResponse } from "next"; + +type ChannelFragment = + | Pick + | Pick; + +export const updateCacheOnWebhook = async ({ + channels, + authData, + res, +}: { + authData: AuthData; + channels: ChannelFragment; + res: NextApiResponse; +}) => { + const client = GraphqlClientFactory.fromAuthData(authData); + + const channelsSlugs = [ + channels.channel, + ...(channels.channelListings?.map((cl) => cl.channel.slug) ?? []), + ].filter((c) => c) as string[]; + + if (channelsSlugs.length === 0) { + return res.status(200).end(); + } + + await updateCacheForConfigurations({ + channelsSlugs, + client, + saleorApiUrl: authData.saleorApiUrl, + }); + + return res.status(200).end(); +}; diff --git a/apps/products-feed/src/modules/shop-info/shop-info-fetcher.ts b/apps/products-feed/src/modules/shop-info/shop-info-fetcher.ts deleted file mode 100644 index ba3f0c8..0000000 --- a/apps/products-feed/src/modules/shop-info/shop-info-fetcher.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Client, gql } from "urql"; -import { ShopInfoDocument, ShopInfoFragment } from "../../../generated/graphql"; - -gql` - fragment ShopInfo on Shop { - companyAddress { - country { - country - code - } - city - firstName - lastName - streetAddress1 - streetAddress2 - companyName - phone - postalCode - countryArea - cityArea - } - } - query ShopInfo { - shop { - ...ShopInfo - } - } -`; - -export interface IShopInfoFetcher { - fetchShopInfo(): Promise; -} - -export class ShopInfoFetcher implements IShopInfoFetcher { - constructor(private client: Client) {} - - fetchShopInfo(): Promise { - return this.client - .query(ShopInfoDocument, {}) - .toPromise() - .then((resp) => resp.data?.shop ?? null); - } -} diff --git a/apps/products-feed/src/modules/trpc/protected-client-procedure.ts b/apps/products-feed/src/modules/trpc/protected-client-procedure.ts index e976bfb..a0a244c 100644 --- a/apps/products-feed/src/modules/trpc/protected-client-procedure.ts +++ b/apps/products-feed/src/modules/trpc/protected-client-procedure.ts @@ -3,8 +3,11 @@ import { middleware, procedure } from "./trpc-server"; import { TRPCError } from "@trpc/server"; import { ProtectedHandlerError } from "@saleor/app-sdk/handlers/next"; import { saleorApp } from "../../saleor-app"; -import { logger } from "@saleor/apps-shared"; -import { createClient } from "../../lib/create-graphq-client"; +import { createLogger, logger } from "@saleor/apps-shared"; +import { GraphqlClientFactory } from "../../lib/create-graphq-client"; +import { AppConfigMetadataManager } from "../app-configuration/app-config-metadata-manager"; +import { createSettingsManager } from "../../lib/metadata-manager"; +import { AppConfig } from "../app-configuration/app-config"; const attachAppToken = middleware(async ({ ctx, next }) => { logger.debug("attachAppToken middleware"); @@ -98,22 +101,36 @@ const validateClientToken = middleware(async ({ ctx, next, meta }) => { * * Can be used only if called from the frontend (react-query), * otherwise jwks validation will fail (if createCaller used) - * - * TODO Rethink middleware composition to enable safe server-side router calls */ export const protectedClientProcedure = procedure .use(attachAppToken) .use(validateClientToken) - .use(async ({ ctx, next }) => { - const client = createClient(ctx.saleorApiUrl, async () => - Promise.resolve({ token: ctx.appToken }) - ); + .use(async ({ ctx, next, path, type }) => { + const client = GraphqlClientFactory.fromAuthData({ + token: ctx.appToken!, + saleorApiUrl: ctx.saleorApiUrl, + }); + + const metadataManager = new AppConfigMetadataManager(createSettingsManager(client)); return next({ ctx: { apiClient: client, appToken: ctx.appToken, saleorApiUrl: ctx.saleorApiUrl, + appId: ctx.appId, + appConfigMetadataManager: metadataManager, + getConfig: async () => { + const metadata = await metadataManager.get(); + + return metadata ? AppConfig.parse(metadata) : new AppConfig(); + }, + logger: createLogger({ + appId: ctx.appId, + apiUrl: ctx.saleorApiUrl, + type, + path, + }), }, }); }); diff --git a/apps/products-feed/src/modules/trpc/trpc-app-router.ts b/apps/products-feed/src/modules/trpc/trpc-app-router.ts index 20897d9..155fc47 100644 --- a/apps/products-feed/src/modules/trpc/trpc-app-router.ts +++ b/apps/products-feed/src/modules/trpc/trpc-app-router.ts @@ -1,4 +1,4 @@ -import { channelsRouter } from "../channels/channels.router"; +import { channelsRouter } from "../app-configuration/channels/channels.router"; import { router } from "./trpc-server"; import { appConfigurationRouter } from "../app-configuration/app-configuration.router"; import { categoryMappingRouter } from "../category-mapping/category-mapping.router"; diff --git a/apps/products-feed/src/modules/trpc/trpc-context.ts b/apps/products-feed/src/modules/trpc/trpc-context.ts index 598ce20..b524084 100644 --- a/apps/products-feed/src/modules/trpc/trpc-context.ts +++ b/apps/products-feed/src/modules/trpc/trpc-context.ts @@ -1,5 +1,5 @@ import * as trpcNext from "@trpc/server/adapters/next"; -import { SALEOR_AUTHORIZATION_BEARER_HEADER, SALEOR_API_URL_HEADER } from "@saleor/app-sdk/const"; +import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "@saleor/app-sdk/const"; import { inferAsyncReturnType } from "@trpc/server"; export const createTrpcContext = async ({ res, req }: trpcNext.CreateNextContextOptions) => { diff --git a/apps/products-feed/src/modules/ui/app-columns-layout.tsx b/apps/products-feed/src/modules/ui/app-columns-layout.tsx deleted file mode 100644 index 2bd7e6a..0000000 --- a/apps/products-feed/src/modules/ui/app-columns-layout.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { makeStyles } from "@saleor/macaw-ui"; -import { PropsWithChildren } from "react"; - -const useStyles = makeStyles((theme) => ({ - root: { - display: "grid", - gridTemplateColumns: "280px auto 400px", - alignItems: "start", - gap: theme.spacing(3), - marginTop: theme.spacing(3), - marginLeft: theme.spacing(3), - }, -})); - -type AppColumnsLayoutProps = PropsWithChildren<{}>; - -export const AppColumnsLayout = ({ children }: AppColumnsLayoutProps) => { - const styles = useStyles(); - - return
{children}
; -}; diff --git a/apps/products-feed/src/modules/ui/app-section.tsx b/apps/products-feed/src/modules/ui/app-section.tsx new file mode 100644 index 0000000..79ce08b --- /dev/null +++ b/apps/products-feed/src/modules/ui/app-section.tsx @@ -0,0 +1,36 @@ +import { Box, PropsWithBox, Text } from "@saleor/macaw-ui/next"; +import { ReactNode } from "react"; + +// todo move to shared +export const AppSection = ({ + heading, + sideContent, + mainContent, + includePadding = false, + ...props +}: PropsWithBox<{ + heading: string; + sideContent?: ReactNode; + mainContent: ReactNode; + includePadding?: boolean; +}>) => { + return ( + + + + {heading} + + {sideContent} + + + {mainContent} + + + ); +}; diff --git a/apps/products-feed/src/modules/ui/configuration-page-base-layout.tsx b/apps/products-feed/src/modules/ui/configuration-page-base-layout.tsx deleted file mode 100644 index c0ae053..0000000 --- a/apps/products-feed/src/modules/ui/configuration-page-base-layout.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import React, { PropsWithChildren } from "react"; -import { makeStyles, PageTab, PageTabs } from "@saleor/macaw-ui"; -import { useRouter } from "next/router"; - -const useStyles = makeStyles((theme) => ({ - appContainer: { - marginTop: theme.spacing(3), - marginLeft: theme.spacing(3), - }, -})); - -type Props = PropsWithChildren<{}>; - -export const ConfigurationPageBaseLayout = ({ children }: Props) => { - const styles = useStyles(); - - const router = useRouter(); - const tabs = [ - { - key: "channels", - label: "Channels", - url: "/configuration", - }, - { key: "categories", label: "Category mapping", url: "/categories" }, - ]; - - const activePath = tabs.find((tab) => router.pathname === tab.url); - - const navigateToTab = (value: string) => { - const redirectionUrl = tabs.find((tab) => tab.key === value)?.url; - - if (redirectionUrl) { - router.push(redirectionUrl); - } - }; - - return ( -
- - {tabs.map((tab) => ( - - ))} - - {children} -
- ); -}; diff --git a/apps/products-feed/src/modules/ui/paragraph.tsx b/apps/products-feed/src/modules/ui/paragraph.tsx new file mode 100644 index 0000000..10e113b --- /dev/null +++ b/apps/products-feed/src/modules/ui/paragraph.tsx @@ -0,0 +1,3 @@ +import { Text, TextProps } from "@saleor/macaw-ui/next"; + +export const Paragraph = (props: TextProps) => ; diff --git a/apps/products-feed/src/pages/_app.tsx b/apps/products-feed/src/pages/_app.tsx index dbb68aa..8c2742e 100644 --- a/apps/products-feed/src/pages/_app.tsx +++ b/apps/products-feed/src/pages/_app.tsx @@ -1,97 +1,39 @@ -import "../styles/globals.css"; - -import { createGenerateClassName, Theme } from "@material-ui/core/styles"; +import "@saleor/macaw-ui/next/style"; import { AppBridge, AppBridgeProvider } from "@saleor/app-sdk/app-bridge"; -import { RoutePropagator } from "@saleor/app-sdk/app-bridge/next"; -import { - dark, - light, - SaleorThemeColors, - ThemeProvider as MacawUIThemeProvider, -} from "@saleor/macaw-ui"; -import React, { PropsWithChildren, useEffect } from "react"; +import React from "react"; import { AppProps } from "next/app"; - +import { QueryClient, QueryClientProvider } from "react-query"; +import { RoutePropagator } from "@saleor/app-sdk/app-bridge/next"; import { ThemeSynchronizer } from "../lib/theme-synchronizer"; -import { NoSSRWrapper } from "../lib/no-ssr-wrapper"; +import { Box, ThemeProvider } from "@saleor/macaw-ui/next"; +import { NoSSRWrapper } from "@saleor/apps-shared"; import { trpcClient } from "../modules/trpc/trpc-client"; -type PalettesOverride = Record<"light" | "dark", SaleorThemeColors>; - -/** - * Temporary override of colors, to match new dashboard palette. - * Long term this will be replaced with Macaw UI 2.x with up to date design tokens - */ -const palettes: PalettesOverride = { - light: { - ...light, - background: { - default: "#fff", - paper: "#fff", - }, - }, - dark: { - ...dark, - background: { - default: "hsla(211, 42%, 14%, 1)", - paper: "hsla(211, 42%, 14%, 1)", - }, - }, -}; - -const themeOverrides: Partial = { - overrides: { - MuiTableCell: { - body: { - paddingBottom: 8, - paddingTop: 8, - }, - root: { - height: 56, - paddingBottom: 4, - paddingTop: 4, - }, - }, - }, -}; - -const generateClassName = createGenerateClassName({ - productionPrefix: "c", - disableGlobal: true, -}); /** * Ensure instance is a singleton. - * TODO: This is React 18 issue, consider hiding this workaround inside app-sdk */ - export const appBridgeInstance = typeof window !== "undefined" ? new AppBridge() : undefined; -/** - * That's a hack required by Macaw-UI incompatibility with React@18 - */ -const ThemeProvider = MacawUIThemeProvider as React.FC< - PropsWithChildren<{ overrides?: Partial; ssr: boolean; palettes: PalettesOverride }> ->; +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + }, + }, +}); function NextApp({ Component, pageProps }: AppProps) { - /** - * Configure JSS (used by MacawUI) for SSR. If Macaw is not used, can be removed. - */ - useEffect(() => { - const jssStyles = document.querySelector("#jss-server-side"); - - if (jssStyles) { - jssStyles?.parentElement?.removeChild(jssStyles); - } - }, []); - return ( - + - + + + + + diff --git a/apps/products-feed/src/pages/api/feed/[url]/[channel]/google.xml.ts b/apps/products-feed/src/pages/api/feed/[url]/[channel]/google.xml.ts index f6e982d..d5adaca 100644 --- a/apps/products-feed/src/pages/api/feed/[url]/[channel]/google.xml.ts +++ b/apps/products-feed/src/pages/api/feed/[url]/[channel]/google.xml.ts @@ -3,24 +3,37 @@ import { initUrqlClient } from "next-urql"; import { GoogleFeedProductVariantFragment } from "../../../../../../generated/graphql"; import { apl } from "../../../../../saleor-app"; import { createLogger } from "@saleor/apps-shared"; -import { fetchProductData } from "../../../../../lib/google-feed/fetch-product-data"; -import { getGoogleFeedSettings } from "../../../../../lib/google-feed/get-google-feed-settings"; -import { generateGoogleXmlFeed } from "../../../../../lib/google-feed/generate-google-xml-feed"; -import { fetchShopData } from "../../../../../lib/google-feed/fetch-shop-data"; +import { fetchProductData } from "../../../../../modules/google-feed/fetch-product-data"; +import { GoogleFeedSettingsFetcher } from "../../../../../modules/google-feed/get-google-feed-settings"; +import { generateGoogleXmlFeed } from "../../../../../modules/google-feed/generate-google-xml-feed"; +import { fetchShopData } from "../../../../../modules/google-feed/fetch-shop-data"; import { CacheConfigurator } from "../../../../../modules/metadata-cache/cache-configurator"; import { createSettingsManager } from "../../../../../lib/metadata-manager"; -import { createClient } from "../../../../../lib/create-graphq-client"; +import { GraphqlClientFactory } from "../../../../../lib/create-graphq-client"; import { uploadFile } from "../../../../../modules/file-storage/s3/upload-file"; import { createS3ClientFromConfiguration } from "../../../../../modules/file-storage/s3/create-s3-client-from-configuration"; -import { S3BucketConfiguration } from "../../../../../modules/app-configuration/app-config"; import { getFileDetails } from "../../../../../modules/file-storage/s3/get-file-details"; import { getDownloadUrl, getFileName } from "../../../../../modules/file-storage/s3/urls-and-names"; +import { RootConfig } from "../../../../../modules/app-configuration/app-config"; +import { z, ZodError } from "zod"; // By default we cache the feed for 5 minutes. This can be changed by setting the FEED_CACHE_MAX_AGE const FEED_CACHE_MAX_AGE = process.env.FEED_CACHE_MAX_AGE ? parseInt(process.env.FEED_CACHE_MAX_AGE, 10) : 60 * 5; +const validateRequestParams = (req: NextApiRequest) => { + const queryShape = z.object({ + url: z.string().url("Valid API URL must be provided"), + channel: z.string().min(1, "Provide valid channel slug"), + }); + + queryShape.parse(req.query); +}; + +/** + * TODO Refactor and test + */ export const handler = async (req: NextApiRequest, res: NextApiResponse) => { const url = req.query.url as string; const channel = req.query.channel as string; @@ -33,14 +46,12 @@ export const handler = async (req: NextApiRequest, res: NextApiResponse) => { logger.debug("Feed route visited"); - if (!url.length) { - logger.error("Missing URL param"); - return res.status(400).json({ error: "No url parameter" }); - } + try { + validateRequestParams(req); + } catch (e) { + const error = e as ZodError; - if (!channel.length) { - logger.error("Missing channel param"); - return res.status(400).json({ error: "No channel parameter" }); + return res.status(400).json({ error: error.flatten().fieldErrors }); } logger.debug("Checking if app is installed in the given env"); @@ -53,31 +64,36 @@ export const handler = async (req: NextApiRequest, res: NextApiResponse) => { logger.debug("The app is registered for the given URL, checking the configuration"); - // use unauthorized client to eliminate possibility of spilling the non-public data + /** + * use unauthorized client to eliminate possibility of spilling the non-public data + */ const client = initUrqlClient( { url: authData.saleorApiUrl, }, - false /* set to false to disable suspense */ + false ); if (!client) { logger.error("Can't create the gql client"); - return res.status(500).end(); + + return res.status(500).send("Error creating feed"); } let storefrontUrl: string; let productStorefrontUrl: string; - let bucketConfiguration: S3BucketConfiguration | undefined; + let bucketConfiguration: RootConfig["s3"] | undefined; try { - const settings = await getGoogleFeedSettings({ authData, channel }); + const settingsFetcher = GoogleFeedSettingsFetcher.createFromAuthData(authData); + const settings = await settingsFetcher.fetch(channel); storefrontUrl = settings.storefrontUrl; productStorefrontUrl = settings.productStorefrontUrl; bucketConfiguration = settings.s3BucketConfiguration; } catch (error) { logger.warn("The application has not been configured"); + return res .status(400) .json({ error: "Please configure the Google Feed settings at the dashboard" }); @@ -93,11 +109,13 @@ export const handler = async (req: NextApiRequest, res: NextApiResponse) => { shopDescription = shopDetails.shopDescription; } catch (error) { logger.error("Could not fetch the shop details"); + return res.status(500).json({ error: "Could not fetch the shop details" }); } if (bucketConfiguration) { logger.debug("Bucket configuration found, checking if the feed has been generated recently"); + const s3Client = createS3ClientFromConfiguration(bucketConfiguration); const fileName = getFileName({ saleorApiUrl: authData.saleorApiUrl, @@ -136,9 +154,7 @@ export const handler = async (req: NextApiRequest, res: NextApiResponse) => { logger.debug("Generating a new feed"); - const cacheClient = createClient(authData.saleorApiUrl, async () => - Promise.resolve({ token: authData.token }) - ); + const cacheClient = GraphqlClientFactory.fromAuthData(authData); if (!cacheClient) { logger.error("Can't create the gql client"); diff --git a/apps/products-feed/src/pages/api/manifest.ts b/apps/products-feed/src/pages/api/manifest.ts index d9d88a6..1f08138 100644 --- a/apps/products-feed/src/pages/api/manifest.ts +++ b/apps/products-feed/src/pages/api/manifest.ts @@ -2,6 +2,11 @@ import { createManifestHandler } from "@saleor/app-sdk/handlers/next"; import { AppManifest } from "@saleor/app-sdk/types"; import packageJson from "../../../package.json"; +import { webhookProductCreated } from "./webhooks/product_created"; +import { webhookProductDeleted } from "./webhooks/product_deleted"; +import { webhookProductVariantCreated } from "./webhooks/product_variant_created"; +import { webhookProductVariantDeleted } from "./webhooks/product_variant_deleted"; +import { webhookProductVariantUpdated } from "./webhooks/product_variant_updated"; export default createManifestHandler({ async manifestFactory(context) { @@ -10,9 +15,15 @@ export default createManifestHandler({ tokenTargetUrl: `${context.appBaseUrl}/api/register`, appUrl: context.appBaseUrl, permissions: ["MANAGE_PRODUCTS"], - id: "saleor.app.products-feed", + id: "saleor.app.product-feed", version: packageJson.version, - webhooks: [], + webhooks: [ + webhookProductCreated.getWebhookManifest(context.appBaseUrl), + webhookProductDeleted.getWebhookManifest(context.appBaseUrl), + webhookProductVariantCreated.getWebhookManifest(context.appBaseUrl), + webhookProductVariantDeleted.getWebhookManifest(context.appBaseUrl), + webhookProductVariantUpdated.getWebhookManifest(context.appBaseUrl), + ], extensions: [], author: "Saleor Commerce", supportUrl: "https://github.com/saleor/apps/discussions", diff --git a/apps/products-feed/src/pages/api/webhooks/product_created.ts b/apps/products-feed/src/pages/api/webhooks/product_created.ts new file mode 100644 index 0000000..d06aa92 --- /dev/null +++ b/apps/products-feed/src/pages/api/webhooks/product_created.ts @@ -0,0 +1,41 @@ +import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next"; +import { createLogger } from "@saleor/apps-shared"; +import { saleorApp } from "../../../saleor-app"; +import { + ProductCreatedDocument, + ProductWebhookPayloadFragment, +} from "../../../../generated/graphql"; +import { updateCacheOnWebhook } from "../../../modules/metadata-cache/update-cache-on-webhook"; + +export const config = { + api: { + bodyParser: false, + }, +}; + +export const webhookProductCreated = new SaleorAsyncWebhook({ + webhookPath: "api/webhooks/product_created", + event: "PRODUCT_CREATED", + apl: saleorApp.apl, + query: ProductCreatedDocument, + // todo make it disabled by default, enable when app is configured + isActive: true, +}); + +const logger = createLogger({ + service: "webhook-product_created", +}); + +export const handler: NextWebhookApiHandler = async ( + req, + res, + context +) => { + await updateCacheOnWebhook({ + authData: context.authData, + channels: context.payload, + res, + }); +}; + +export default webhookProductCreated.createHandler(handler); diff --git a/apps/products-feed/src/pages/api/webhooks/product_deleted.ts b/apps/products-feed/src/pages/api/webhooks/product_deleted.ts new file mode 100644 index 0000000..98c94dc --- /dev/null +++ b/apps/products-feed/src/pages/api/webhooks/product_deleted.ts @@ -0,0 +1,40 @@ +import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next"; +import { + ProductDeletedDocument, + ProductWebhookPayloadFragment, +} from "../../../../generated/graphql"; +import { saleorApp } from "../../../saleor-app"; +import { createLogger } from "@saleor/apps-shared"; +import { updateCacheOnWebhook } from "../../../modules/metadata-cache/update-cache-on-webhook"; + +export const config = { + api: { + bodyParser: false, + }, +}; + +export const webhookProductDeleted = new SaleorAsyncWebhook({ + webhookPath: "api/webhooks/product_deleted", + event: "PRODUCT_DELETED", + apl: saleorApp.apl, + query: ProductDeletedDocument, + isActive: true, +}); + +const logger = createLogger({ + service: "webhook_product_deleted", +}); + +export const handler: NextWebhookApiHandler = async ( + req, + res, + context +) => { + await updateCacheOnWebhook({ + authData: context.authData, + channels: context.payload, + res, + }); +}; + +export default webhookProductDeleted.createHandler(handler); diff --git a/apps/products-feed/src/pages/api/webhooks/product_updated.ts b/apps/products-feed/src/pages/api/webhooks/product_updated.ts new file mode 100644 index 0000000..8c03561 --- /dev/null +++ b/apps/products-feed/src/pages/api/webhooks/product_updated.ts @@ -0,0 +1,40 @@ +import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next"; +import { + ProductUpdatedDocument, + ProductWebhookPayloadFragment, +} from "../../../../generated/graphql"; +import { saleorApp } from "../../../saleor-app"; +import { createLogger } from "@saleor/apps-shared"; +import { updateCacheOnWebhook } from "../../../modules/metadata-cache/update-cache-on-webhook"; + +export const config = { + api: { + bodyParser: false, + }, +}; + +export const webhookProductUpdated = new SaleorAsyncWebhook({ + webhookPath: "api/webhooks/product_updated", + event: "PRODUCT_UPDATED", + apl: saleorApp.apl, + query: ProductUpdatedDocument, + isActive: true, +}); + +const logger = createLogger({ + service: "webhookProductUpdatedWebhookHandler", +}); + +export const handler: NextWebhookApiHandler = async ( + req, + res, + context +) => { + await updateCacheOnWebhook({ + authData: context.authData, + channels: context.payload, + res, + }); +}; + +export default webhookProductUpdated.createHandler(handler); diff --git a/apps/products-feed/src/pages/api/webhooks/product_variant_created.ts b/apps/products-feed/src/pages/api/webhooks/product_variant_created.ts new file mode 100644 index 0000000..30c2a54 --- /dev/null +++ b/apps/products-feed/src/pages/api/webhooks/product_variant_created.ts @@ -0,0 +1,41 @@ +import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next"; +import { + ProductVariantCreatedDocument, + ProductVariantWebhookPayloadFragment, +} from "../../../../generated/graphql"; +import { saleorApp } from "../../../saleor-app"; +import { createLogger } from "@saleor/apps-shared"; +import { updateCacheOnWebhook } from "../../../modules/metadata-cache/update-cache-on-webhook"; + +export const config = { + api: { + bodyParser: false, + }, +}; + +export const webhookProductVariantCreated = + new SaleorAsyncWebhook({ + webhookPath: "api/webhooks/product_variant_created", + event: "PRODUCT_VARIANT_CREATED", + apl: saleorApp.apl, + query: ProductVariantCreatedDocument, + isActive: true, + }); + +const logger = createLogger({ + service: "PRODUCT_VARIANT_CREATED webhook", +}); + +export const handler: NextWebhookApiHandler = async ( + req, + res, + context +) => { + await updateCacheOnWebhook({ + authData: context.authData, + channels: context.payload, + res, + }); +}; + +export default webhookProductVariantCreated.createHandler(handler); diff --git a/apps/products-feed/src/pages/api/webhooks/product_variant_deleted.ts b/apps/products-feed/src/pages/api/webhooks/product_variant_deleted.ts new file mode 100644 index 0000000..533b73f --- /dev/null +++ b/apps/products-feed/src/pages/api/webhooks/product_variant_deleted.ts @@ -0,0 +1,41 @@ +import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next"; +import { createLogger } from "@saleor/apps-shared"; +import { + ProductVariantDeletedDocument, + ProductVariantWebhookPayloadFragment, +} from "../../../../generated/graphql"; +import { saleorApp } from "../../../saleor-app"; +import { updateCacheOnWebhook } from "../../../modules/metadata-cache/update-cache-on-webhook"; + +export const config = { + api: { + bodyParser: false, + }, +}; + +export const webhookProductVariantDeleted = + new SaleorAsyncWebhook({ + webhookPath: "api/webhooks/product_variant_deleted", + event: "PRODUCT_VARIANT_DELETED", + apl: saleorApp.apl, + query: ProductVariantDeletedDocument, + isActive: true, + }); + +const logger = createLogger({ + service: "PRODUCT_VARIANT_DELETED", +}); + +export const handler: NextWebhookApiHandler = async ( + req, + res, + context +) => { + await updateCacheOnWebhook({ + authData: context.authData, + channels: context.payload, + res, + }); +}; + +export default webhookProductVariantDeleted.createHandler(handler); diff --git a/apps/products-feed/src/pages/api/webhooks/product_variant_updated.ts b/apps/products-feed/src/pages/api/webhooks/product_variant_updated.ts new file mode 100644 index 0000000..f932bc9 --- /dev/null +++ b/apps/products-feed/src/pages/api/webhooks/product_variant_updated.ts @@ -0,0 +1,41 @@ +import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next"; +import { createLogger } from "@saleor/apps-shared"; +import { + ProductVariantUpdatedDocument, + ProductVariantWebhookPayloadFragment, +} from "../../../../generated/graphql"; +import { saleorApp } from "../../../saleor-app"; +import { updateCacheOnWebhook } from "../../../modules/metadata-cache/update-cache-on-webhook"; + +export const config = { + api: { + bodyParser: false, + }, +}; + +export const webhookProductVariantUpdated = + new SaleorAsyncWebhook({ + webhookPath: "api/webhooks/product_variant_updated", + event: "PRODUCT_VARIANT_UPDATED", + apl: saleorApp.apl, + query: ProductVariantUpdatedDocument, + isActive: true, + }); + +const logger = createLogger({ + service: "webhookProductVariantUpdatedWebhookHandler", +}); + +export const handler: NextWebhookApiHandler = async ( + req, + res, + context +) => { + await updateCacheOnWebhook({ + authData: context.authData, + channels: context.payload, + res, + }); +}; + +export default webhookProductVariantUpdated.createHandler(handler); diff --git a/apps/products-feed/src/pages/categories.tsx b/apps/products-feed/src/pages/categories.tsx index b7429aa..6e773e3 100644 --- a/apps/products-feed/src/pages/categories.tsx +++ b/apps/products-feed/src/pages/categories.tsx @@ -1,25 +1,18 @@ import { NextPage } from "next"; -import React, { useEffect } from "react"; -import { trpcClient } from "../modules/trpc/trpc-client"; -import { useRouter } from "next/router"; -import { ConfigurationPageBaseLayout } from "../modules/ui/configuration-page-base-layout"; import { CategoryMapping } from "../modules/category-mapping/ui/category-mapping"; +import { Breadcrumbs } from "@saleor/apps-ui"; +import { Box } from "@saleor/macaw-ui/next"; -const ConfigurationPage: NextPage = () => { - const channels = trpcClient.channels.fetch.useQuery(); - const router = useRouter(); - - useEffect(() => { - if (channels.isSuccess && channels.data.length === 0) { - router.push("/not-ready"); - } - }, [channels.data, channels.isSuccess]); - +const CategoriesPage: NextPage = () => { return ( - + + + Configuration + Categories Mapping + - + ); }; -export default ConfigurationPage; +export default CategoriesPage; diff --git a/apps/products-feed/src/pages/configuration.tsx b/apps/products-feed/src/pages/configuration.tsx index 807c5c2..aa60be2 100644 --- a/apps/products-feed/src/pages/configuration.tsx +++ b/apps/products-feed/src/pages/configuration.tsx @@ -1,24 +1,142 @@ import { NextPage } from "next"; -import React, { useEffect } from "react"; -import { ChannelsConfiguration } from "../modules/app-configuration/ui/channels-configuration"; -import { trpcClient } from "../modules/trpc/trpc-client"; +import React from "react"; + +import { useChannelsExistenceChecking } from "../modules/app-configuration/channels/use-channels-existence-checking"; +import { Box, Button, Text } from "@saleor/macaw-ui/next"; +import { AppSection } from "../modules/ui/app-section"; +import { Paragraph } from "../modules/ui/paragraph"; +import { TextLink } from "@saleor/apps-ui"; +import { ConnectedS3ConfigurationForm } from "../modules/app-configuration/s3-configuration-form"; +import { ChannelsConfigAccordion } from "../modules/app-configuration/channels-config-accordion"; import { useRouter } from "next/router"; -import { ConfigurationPageBaseLayout } from "../modules/ui/configuration-page-base-layout"; +import { CategoryMappingPreview } from "../modules/category-mapping/ui/category-mapping-preview"; const ConfigurationPage: NextPage = () => { - const channels = trpcClient.channels.fetch.useQuery(); - const router = useRouter(); - - useEffect(() => { - if (channels.isSuccess && channels.data.length === 0) { - router.push("/not-ready"); - } - }, [channels.data, channels.isSuccess]); + useChannelsExistenceChecking(); + const { push } = useRouter(); return ( - - - + + + + Configuration + + + Configure app to enable Product Feed, that{" "} + + Google Merchant Center + {" "} + can consume + + + Check{" "} + + this article how to configure feed + + + + } + sideContent={ + + + Your product database can be quite large. To generate a big XML file, we need to put + it somewhere so Google can consume it. You need to generate an S3 bucket with public + read access and provide its credentials to the app. + + + Please use this bucket only for XMLs. They meant to be public - but other files may + leak if you put them to the same bucket. + + + Read about generating AWS credentials{" "} + + here + + + + } + /> + + + + } + sideContent={ + + App will generate separate feed for each channel + + Provide your storefront homepage URL and product template. Use following placeholders: + +
    +
  • + + {"{productSlug}"} - to inject slug of the product + +
  • +
  • + + {"{variantId}"} - to inject id of the variant + +
  • +
  • + + {"{productId}"} - to inject id of the product + +
  • +
+ For example following pattern: + + {"https://my-shop.com/p/{productSlug}/{variantId}"} + + Will produce: + + {"https://my-shop.com/p/t-shirt/Xyp2asZ"} + +
+ } + /> + + + + + + + } + sideContent={ + + + Map Saleor categories to pre-defined Google categories. It is not required.{" "} + + Read more + + + + } + /> + ); }; diff --git a/apps/products-feed/src/pages/index.tsx b/apps/products-feed/src/pages/index.tsx index e00e760..7caf642 100644 --- a/apps/products-feed/src/pages/index.tsx +++ b/apps/products-feed/src/pages/index.tsx @@ -3,8 +3,8 @@ import { useAppBridge } from "@saleor/app-sdk/app-bridge"; import { useEffect } from "react"; import { useIsMounted } from "usehooks-ts"; import { useRouter } from "next/router"; -import { LinearProgress } from "@material-ui/core"; -import { isInIframe } from "../lib/is-in-iframe"; +import { isInIframe } from "@saleor/apps-shared"; +import { Text } from "@saleor/macaw-ui/next"; const IndexPage: NextPage = () => { const { appBridgeState } = useAppBridge(); @@ -18,14 +18,16 @@ const IndexPage: NextPage = () => { }, [isMounted, appBridgeState?.ready]); if (isInIframe()) { - return ; + return Loading...; } return (
-

Saleor Product Feed

-

This is Saleor App that allows product feed generation

-

Install app in your Saleor instance and open in with Dashboard

+ + Saleor Product Feed + + This is Saleor App that allows product feed generation + Install app in your Saleor instance and open in with Dashboard
); }; diff --git a/apps/products-feed/src/pages/not-ready.tsx b/apps/products-feed/src/pages/not-ready.tsx index e9c73bc..71a1442 100644 --- a/apps/products-feed/src/pages/not-ready.tsx +++ b/apps/products-feed/src/pages/not-ready.tsx @@ -1,6 +1,5 @@ -import { AlertBase, Button } from "@saleor/macaw-ui"; +import { Box, Button, Text } from "@saleor/macaw-ui/next"; import React from "react"; -import { Typography } from "@material-ui/core"; import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge"; const NotReadyPage = () => { @@ -8,14 +7,10 @@ const NotReadyPage = () => { return (
-

Saleor Invoices App

- - - App can not be used - - - To configure Invoices App you need to create at least 1 channel - +

Saleor Product Feed App

+ + App can not be used + To configure Product Feed App you need to create at least 1 channel -
+
); }; diff --git a/apps/products-feed/src/styles/globals.css b/apps/products-feed/src/styles/globals.css deleted file mode 100644 index 3a624b6..0000000 --- a/apps/products-feed/src/styles/globals.css +++ /dev/null @@ -1,21 +0,0 @@ -body { - font-family: Inter, -apple-system, "system-ui", "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, - "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; - color: #111; -} - -code { - background: #f6f8fa; - border: 1px solid #eaeaea; - border-radius: 5px; - display: inline-block; - margin-top: 10px; - padding: 0.75rem; - font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, - Bitstream Vera Sans Mono, Courier New, monospace; -} - -code::before { - content: "$ "; - opacity: 0.6; -} diff --git a/apps/search/src/components/WebhooksStatus.tsx b/apps/search/src/components/WebhooksStatus.tsx index 8b7a24c..c72928d 100644 --- a/apps/search/src/components/WebhooksStatus.tsx +++ b/apps/search/src/components/WebhooksStatus.tsx @@ -1,4 +1,4 @@ -import { Accordion, Box, Chip, Text } from "@saleor/macaw-ui/next"; +import { Accordion, Box, Text } from "@saleor/macaw-ui/next"; import { EventDeliveryStatusEnum } from "../../generated/graphql"; import { useWebhooksStatus } from "../lib/useWebhooksStatus"; import { SemanticChip } from "@saleor/apps-ui"; diff --git a/apps/search/src/pages/_app.tsx b/apps/search/src/pages/_app.tsx index 4ada940..43cd1bd 100644 --- a/apps/search/src/pages/_app.tsx +++ b/apps/search/src/pages/_app.tsx @@ -25,17 +25,6 @@ const queryClient = new QueryClient({ }); function NextApp({ Component, pageProps }: AppProps) { - /** - * Configure JSS (used by MacawUI) for SSR. If Macaw is not used, can be removed. - */ - useEffect(() => { - const jssStyles = document.querySelector("#jss-server-side"); - - if (jssStyles) { - jssStyles?.parentElement?.removeChild(jssStyles); - } - }, []); - return ( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1dc47a6..5d3f63a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1072,26 +1072,23 @@ importers: specifier: ^3.332.0 version: 3.332.0 '@hookform/resolvers': - specifier: ^2.9.10 - version: 2.9.11(react-hook-form@7.43.1) - '@material-ui/core': - specifier: ^4.12.4 - version: 4.12.4(@types/react@18.0.27)(react-dom@18.2.0)(react@18.2.0) - '@material-ui/icons': - specifier: ^4.11.3 - version: 4.11.3(@material-ui/core@4.12.4)(@types/react@18.0.27)(react-dom@18.2.0)(react@18.2.0) - '@material-ui/lab': - specifier: 4.0.0-alpha.61 - version: 4.0.0-alpha.61(@material-ui/core@4.12.4)(@types/react@18.0.27)(react-dom@18.2.0)(react@18.2.0) + specifier: ^3.1.0 + version: 3.1.0(react-hook-form@7.43.9) '@saleor/app-sdk': specifier: 0.39.1 version: 0.39.1(next@13.3.0)(react-dom@18.2.0)(react@18.2.0) '@saleor/apps-shared': specifier: workspace:* version: link:../../packages/shared + '@saleor/apps-ui': + specifier: workspace:* + version: link:../../packages/ui '@saleor/macaw-ui': - specifier: ^0.7.2 - version: 0.7.2(@material-ui/core@4.12.4)(@material-ui/icons@4.11.3)(@material-ui/lab@4.0.0-alpha.61)(@types/react@18.0.27)(react-dom@18.2.0)(react-helmet@6.1.0)(react@18.2.0) + specifier: 0.8.0-pre.84 + version: 0.8.0-pre.84(@types/react@18.0.27)(react-dom@18.2.0)(react@18.2.0) + '@saleor/react-hook-form-macaw': + specifier: workspace:* + version: link:../../packages/react-hook-form-macaw '@sentry/nextjs': specifier: ^7.52.1 version: 7.52.1(next@13.3.0)(react@18.2.0) @@ -1150,11 +1147,14 @@ importers: specifier: 18.2.0 version: 18.2.0(react@18.2.0) react-hook-form: - specifier: ^7.43.0 - version: 7.43.1(react@18.2.0) + specifier: ^7.43.9 + version: 7.43.9(react@18.2.0) react-is: specifier: ^18.2.0 version: 18.2.0 + react-query: + specifier: ^3.39.3 + version: 3.39.3(react-dom@18.2.0)(react@18.2.0) urql: specifier: ^3.0.3 version: 3.0.3(graphql@16.6.0)(react@18.2.0)