diff --git a/apps/invoices/package.json b/apps/invoices/package.json index 607d508..2a83d15 100644 --- a/apps/invoices/package.json +++ b/apps/invoices/package.json @@ -17,12 +17,9 @@ "schemaVersion": "3.10" }, "dependencies": { - "@material-ui/core": "^4.12.4", - "@material-ui/icons": "^4.11.3", - "@material-ui/lab": "4.0.0-alpha.61", "@saleor/app-sdk": "0.37.3", "@saleor/apps-shared": "workspace:*", - "@saleor/macaw-ui": "^0.7.2", + "@saleor/macaw-ui": "^0.8.0-pre.80", "@sentry/nextjs": "^7.36.0", "@tanstack/react-query": "^4.24.4", "@trpc/client": "^10.10.0", @@ -44,7 +41,8 @@ "tiny-invariant": "^1.3.1", "urql": "^3.0.3", "usehooks-ts": "^2.9.1", - "zod": "^3.20.2" + "zod": "^3.20.2", + "@hookform/resolvers": "^3.1.0" }, "devDependencies": { "@graphql-codegen/cli": "3.2.2", diff --git a/apps/invoices/src/app-invoices-icon.svg b/apps/invoices/src/app-invoices-icon.svg deleted file mode 100644 index 6ccb2e2..0000000 --- a/apps/invoices/src/app-invoices-icon.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - diff --git a/apps/invoices/src/fixtures/mock-address.ts b/apps/invoices/src/fixtures/mock-address.ts index 20e4cb5..cb6fdee 100644 --- a/apps/invoices/src/fixtures/mock-address.ts +++ b/apps/invoices/src/fixtures/mock-address.ts @@ -1,4 +1,4 @@ -import { SellerShopConfig } from "../modules/app-configuration/app-config"; +import { SellerShopConfig } from "../modules/app-configuration/schema-v1/app-config-v1"; export const getMockAddress = (): SellerShopConfig["address"] => { return { diff --git a/apps/invoices/src/lib/no-ssr-wrapper.tsx b/apps/invoices/src/lib/no-ssr-wrapper.tsx deleted file mode 100644 index 4917e33..0000000 --- a/apps/invoices/src/lib/no-ssr-wrapper.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import React, { PropsWithChildren } from "react"; -import dynamic from "next/dynamic"; - -const Wrapper = (props: PropsWithChildren<{}>) => {props.children}; - -export const NoSSRWrapper = dynamic(() => Promise.resolve(Wrapper), { - ssr: false, -}); diff --git a/apps/invoices/src/lib/theme-synchronizer.tsx b/apps/invoices/src/lib/theme-synchronizer.tsx index 7e5ce17..2ae0fe6 100644 --- a/apps/invoices/src/lib/theme-synchronizer.tsx +++ b/apps/invoices/src/lib/theme-synchronizer.tsx @@ -1,33 +1,25 @@ import { useAppBridge } from "@saleor/app-sdk/app-bridge"; -import { useTheme } from "@saleor/macaw-ui"; +import { useTheme } from "@saleor/macaw-ui/next"; import { memo, 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/invoices/src/modules/app-configuration/__mocks__/metadata-manager.ts b/apps/invoices/src/modules/app-configuration/__mocks__/metadata-manager.ts new file mode 100644 index 0000000..ebd1110 --- /dev/null +++ b/apps/invoices/src/modules/app-configuration/__mocks__/metadata-manager.ts @@ -0,0 +1,13 @@ +import { vi } from "vitest"; + +import { SettingsManager } from "@saleor/app-sdk/settings-manager"; +import { SimpleGraphqlClient } from "../metadata-manager"; + +export const mockMetadataManager = { + set: vi.fn().mockImplementation(async () => {}), + get: vi.fn().mockImplementation(async () => {}), +}; + +export const createSettingsManager = (client: SimpleGraphqlClient): SettingsManager => { + return mockMetadataManager; +}; diff --git a/apps/invoices/src/modules/app-configuration/app-config-container.test.ts b/apps/invoices/src/modules/app-configuration/app-config-container.test.ts deleted file mode 100644 index ef5fdc2..0000000 --- a/apps/invoices/src/modules/app-configuration/app-config-container.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { AppConfigContainer } from "./app-config-container"; -import { AppConfig, SellerShopConfig } from "./app-config"; - -const getDefaultAddressData = (): SellerShopConfig["address"] => ({ - city: "", - cityArea: "", - companyName: "Saleor", - country: "", - countryArea: "", - postalCode: "", - streetAddress1: "", - streetAddress2: "", -}); - -describe("AppConfigContainer", () => { - describe("Get address from config", () => { - it("Gets address if exists", () => { - expect( - AppConfigContainer.getChannelAddress({ - shopConfigPerChannel: { - channel: { - address: getDefaultAddressData(), - }, - }, - })("channel") - ).toEqual( - expect.objectContaining({ - companyName: "Saleor", - }) - ); - }); - - it("Returns null if entire config is null", () => { - expect(AppConfigContainer.getChannelAddress(null)("channel")).toEqual(null); - }); - }); - - describe("Set address to config per slug of the channel", () => { - it("Will create entire config object if initially was null", () => { - const newConfig = AppConfigContainer.setChannelAddress(null)("channel")( - getDefaultAddressData() - ); - - expect(newConfig).toEqual({ - shopConfigPerChannel: expect.objectContaining({ - channel: expect.objectContaining({ - address: expect.objectContaining({ companyName: "Saleor" }), - }), - }), - }); - }); - - it("Will preserve another existing config for another channel after setting a new one", () => { - const config: AppConfig = { - shopConfigPerChannel: { - c1: { - address: { - ...getDefaultAddressData(), - companyName: "Mirumee", - }, - }, - }, - }; - - const newConfig = AppConfigContainer.setChannelAddress(config)("c2")(getDefaultAddressData()); - - expect(newConfig.shopConfigPerChannel.c1.address.companyName).toEqual("Mirumee"); - expect(newConfig.shopConfigPerChannel.c2.address.companyName).toEqual("Saleor"); - }); - }); -}); diff --git a/apps/invoices/src/modules/app-configuration/app-config-container.ts b/apps/invoices/src/modules/app-configuration/app-config-container.ts deleted file mode 100644 index 6abc269..0000000 --- a/apps/invoices/src/modules/app-configuration/app-config-container.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { AppConfig, SellerShopConfig } from "./app-config"; - -const getDefaultEmptyAddress = (): SellerShopConfig["address"] => ({ - city: "", - cityArea: "", - companyName: "", - country: "", - countryArea: "", - postalCode: "", - streetAddress1: "", - streetAddress2: "", -}); - -const getChannelAddress = (appConfig: AppConfig | null | undefined) => (channelSlug: string) => { - try { - return appConfig?.shopConfigPerChannel[channelSlug].address ?? null; - } catch (e) { - return null; - } -}; - -const setChannelAddress = - (appConfig: AppConfig | null | undefined) => - (channelSlug: string) => - (address: SellerShopConfig["address"]) => { - const appConfigNormalized = structuredClone(appConfig) ?? { shopConfigPerChannel: {} }; - - appConfigNormalized.shopConfigPerChannel[channelSlug] ??= { address: getDefaultEmptyAddress() }; - appConfigNormalized.shopConfigPerChannel[channelSlug].address = address; - - return appConfigNormalized; - }; - -export const AppConfigContainer = { - getChannelAddress, - setChannelAddress, -}; diff --git a/apps/invoices/src/modules/app-configuration/app-config-input-schema.test.ts b/apps/invoices/src/modules/app-configuration/app-config-input-schema.test.ts deleted file mode 100644 index 6cea42e..0000000 --- a/apps/invoices/src/modules/app-configuration/app-config-input-schema.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { appConfigInputSchema } from "./app-config-input-schema"; -import { AppConfig, SellerShopConfig } from "./app-config"; -import { getMockAddress } from "../../fixtures/mock-address"; - -describe("appConfigInputSchema", () => { - it("Passes with no channels at all", () => { - expect(() => - appConfigInputSchema.parse({ - shopConfigPerChannel: {}, - } satisfies AppConfig) - ).not.to.throw(); - }); - - it("Passes with all address fields empty", () => { - expect(() => - appConfigInputSchema.parse({ - shopConfigPerChannel: { - channel: { - address: { - city: "", - cityArea: "", - companyName: "", - country: "", - countryArea: "", - postalCode: "", - streetAddress1: "", - streetAddress2: "", - }, - }, - }, - } satisfies AppConfig) - ).not.to.throw(); - }); - - it("Passes with partial address", () => { - expect(() => - appConfigInputSchema.parse({ - shopConfigPerChannel: { - channel: { - address: getMockAddress(), - }, - }, - } satisfies AppConfig) - ).not.to.throw(); - }); -}); diff --git a/apps/invoices/src/modules/app-configuration/app-config-input-schema.ts b/apps/invoices/src/modules/app-configuration/app-config-input-schema.ts deleted file mode 100644 index 5ff4d80..0000000 --- a/apps/invoices/src/modules/app-configuration/app-config-input-schema.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { z } from "zod"; - -export const appConfigInputSchema = z.object({ - shopConfigPerChannel: z.record( - z.object({ - address: z.object({ - /** - * min() to allow empty strings - */ - companyName: z.string().min(0), - cityArea: z.string().min(0), - countryArea: z.string().min(0), - streetAddress1: z.string().min(0), - streetAddress2: z.string().min(0), - postalCode: z.string().min(0), - city: z.string().min(0), - country: z.string().min(0), - }), - }) - ), -}); diff --git a/apps/invoices/src/modules/app-configuration/app-config.ts b/apps/invoices/src/modules/app-configuration/app-config.ts deleted file mode 100644 index 685f340..0000000 --- a/apps/invoices/src/modules/app-configuration/app-config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { SellerAddress } from "./address"; - -export interface SellerShopConfig { - address: SellerAddress; -} - -export type ShopConfigPerChannelSlug = Record; - -export type AppConfig = { - shopConfigPerChannel: ShopConfigPerChannelSlug; -}; diff --git a/apps/invoices/src/modules/app-configuration/app-configuration-router.test.ts b/apps/invoices/src/modules/app-configuration/app-configuration-router.test.ts new file mode 100644 index 0000000..aebe150 --- /dev/null +++ b/apps/invoices/src/modules/app-configuration/app-configuration-router.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from "vitest"; +import { appConfigurationRouter } from "./app-configuration-router"; +import { getMockAddress } from "../../fixtures/mock-address"; +import { mockMetadataManager } from "./__mocks__/metadata-manager"; + +vi.mock("./metadata-manager"); + +vi.mock("../../saleor-app", () => { + const apl = { + async get() { + return { + appId: "app", + saleorApiUrl: "http://localhost:8000/graphql/", + token: "TOKEN", + domain: "localhost:8000", + }; + }, + async set() {}, + async delete() {}, + async getAll() { + return []; + }, + async isReady() { + return { + ready: true, + }; + }, + async isConfigured() { + return { + configured: true, + }; + }, + }; + + return { + saleorApp: { + apl, + }, + }; +}); + +vi.mock("@saleor/app-sdk/verify-jwt", () => { + return { + verifyJWT: vi.fn().mockImplementation((async) => {}), + }; +}); + +describe("appConfigurationRouter", function () { + describe("upsertChannelOverride", function () { + it("Calls metadata manager with proper value to save", async () => { + await appConfigurationRouter + .createCaller({ + token: "TOKEN", + saleorApiUrl: "http://localhost:8000/graphql/", + appId: "app", + }) + .upsertChannelOverride({ + channelSlug: "test", + address: getMockAddress(), + }); + + expect(mockMetadataManager.set).toHaveBeenCalledWith({ + key: "app-config-v2", + value: expect.any(String), + }); + }); + }); +}); diff --git a/apps/invoices/src/modules/app-configuration/app-configuration-router.ts b/apps/invoices/src/modules/app-configuration/app-configuration-router.ts new file mode 100644 index 0000000..b268df2 --- /dev/null +++ b/apps/invoices/src/modules/app-configuration/app-configuration-router.ts @@ -0,0 +1,100 @@ +import { createLogger } from "@saleor/apps-shared"; +import { z } from "zod"; +import { protectedClientProcedure } from "../trpc/protected-client-procedure"; +import { router } from "../trpc/trpc-server"; +import { createSettingsManager } from "./metadata-manager"; +import { AppConfigV2MetadataManager } from "./schema-v2/app-config-v2-metadata-manager"; +import { GetAppConfigurationV2Service } from "./schema-v2/get-app-configuration.v2.service"; +import { ConfigV1ToV2MigrationService } from "./schema-v2/config-v1-to-v2-migration.service"; +import { AddressV2Schema } from "./schema-v2/app-config-schema.v2"; + +const UpsertAddressSchema = z.object({ + address: AddressV2Schema, + channelSlug: z.string(), +}); + +export const appConfigurationRouter = router({ + fetchChannelsOverrides: protectedClientProcedure.query(async ({ ctx, input }) => { + const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl }); + + logger.debug("appConfigurationRouterV2.fetch called"); + + const appConfigV2 = await new GetAppConfigurationV2Service(ctx).getConfiguration(); + + /** + * MIGRATION CODE START - remove when metadata migrated + */ + if (!appConfigV2) { + const migrationService = new ConfigV1ToV2MigrationService(ctx.apiClient, ctx.saleorApiUrl); + + return migrationService.migrate().then((config) => config.getChannelsOverrides()); + } + /** + * MIGRATION CODE END + */ + + return appConfigV2.getChannelsOverrides(); + }), + upsertChannelOverride: protectedClientProcedure + .meta({ + requiredClientPermissions: ["MANAGE_APPS"], + }) + .input(UpsertAddressSchema) + .mutation(async ({ ctx, input }) => { + const appConfigV2 = await new GetAppConfigurationV2Service(ctx).getConfiguration(); + + /** + * MIGRATION CODE START - remove when metadata migrated + */ + if (!appConfigV2) { + const migrationService = new ConfigV1ToV2MigrationService(ctx.apiClient, ctx.saleorApiUrl); + + await migrationService.migrate((config) => + config.upsertOverride(input.channelSlug, input.address) + ); + + return; + } + /** + * MIGRATION CODE END + */ + + appConfigV2.upsertOverride(input.channelSlug, input.address); + + const mm = new AppConfigV2MetadataManager(createSettingsManager(ctx.apiClient)); + + await mm.set(appConfigV2.serialize()); + }), + removeChannelOverride: protectedClientProcedure + .meta({ + requiredClientPermissions: ["MANAGE_APPS"], + }) + .input( + z.object({ + channelSlug: z.string(), + }) + ) + .mutation(async ({ ctx, input }) => { + const appConfigV2 = await new GetAppConfigurationV2Service(ctx).getConfiguration(); + + /** + * MIGRATION CODE START - remove when metadata migrated + */ + if (!appConfigV2) { + const migrationService = new ConfigV1ToV2MigrationService(ctx.apiClient, ctx.saleorApiUrl); + + await migrationService.migrate((config) => config.removeOverride(input.channelSlug)); + + return; + } + /** + * MIGRATION CODE END + */ + + appConfigV2.removeOverride(input.channelSlug); + + const mm = new AppConfigV2MetadataManager(createSettingsManager(ctx.apiClient)); + + return mm.set(appConfigV2.serialize()); + }), +}); diff --git a/apps/invoices/src/modules/app-configuration/app-configuration.router.ts b/apps/invoices/src/modules/app-configuration/app-configuration.router.ts deleted file mode 100644 index bd6330e..0000000 --- a/apps/invoices/src/modules/app-configuration/app-configuration.router.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { router } from "../trpc/trpc-server"; -import { protectedClientProcedure } from "../trpc/protected-client-procedure"; -import { PrivateMetadataAppConfigurator } from "./app-configurator"; -import { createSettingsManager } from "./metadata-manager"; -import { createLogger } from "@saleor/apps-shared"; -import { appConfigInputSchema } from "./app-config-input-schema"; -import { GetAppConfigurationService } from "./get-app-configuration.service"; - -export const appConfigurationRouter = router({ - fetch: protectedClientProcedure.query(async ({ ctx, input }) => { - const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl }); - - logger.debug("appConfigurationRouter.fetch called"); - - return new GetAppConfigurationService({ - apiClient: ctx.apiClient, - saleorApiUrl: ctx.saleorApiUrl, - }).getConfiguration(); - }), - setAndReplace: protectedClientProcedure - .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) - .input(appConfigInputSchema) - .mutation(async ({ ctx, input }) => { - const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl }); - - logger.debug(input, "appConfigurationRouter.setAndReplace called with input"); - - const appConfigurator = new PrivateMetadataAppConfigurator( - createSettingsManager(ctx.apiClient), - ctx.saleorApiUrl - ); - - await appConfigurator.setConfig(input); - - return null; - }), -}); diff --git a/apps/invoices/src/modules/app-configuration/fallback-app-config.ts b/apps/invoices/src/modules/app-configuration/fallback-app-config.ts deleted file mode 100644 index 8487958..0000000 --- a/apps/invoices/src/modules/app-configuration/fallback-app-config.ts +++ /dev/null @@ -1,29 +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[], - shopAddress: ShopInfoFragment | null - ) { - return (channels ?? []).reduce( - (state, channel) => { - return AppConfigContainer.setChannelAddress(state)(channel.slug)({ - city: shopAddress?.companyAddress?.city ?? "", - cityArea: shopAddress?.companyAddress?.cityArea ?? "", - companyName: shopAddress?.companyAddress?.companyName ?? "", - country: shopAddress?.companyAddress?.country.country ?? "", - countryArea: shopAddress?.companyAddress?.countryArea ?? "", - postalCode: shopAddress?.companyAddress?.postalCode ?? "", - streetAddress1: shopAddress?.companyAddress?.streetAddress1 ?? "", - streetAddress2: shopAddress?.companyAddress?.streetAddress2 ?? "", - }); - }, - { shopConfigPerChannel: {} } - ); - }, -}; diff --git a/apps/invoices/src/modules/app-configuration/get-app-configuration.service.ts b/apps/invoices/src/modules/app-configuration/get-app-configuration.service.ts deleted file mode 100644 index 03e18d0..0000000 --- a/apps/invoices/src/modules/app-configuration/get-app-configuration.service.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { PrivateMetadataAppConfigurator } from "./app-configurator"; -import { createSettingsManager } from "./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"; - -// todo test -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, shopAddress] = await Promise.all([ - channelsFetcher.fetchChannels(), - shopInfoFetcher.fetchShopInfo(), - ]); - - logger.debug(channels, "Fetched channels"); - logger.debug(shopAddress, "Fetched shop address"); - - const appConfig = FallbackAppConfig.createFallbackConfigFromExistingShopAndChannels( - channels ?? [], - shopAddress - ); - - 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/invoices/src/modules/app-configuration/metadata-manager.ts b/apps/invoices/src/modules/app-configuration/metadata-manager.ts index f63ba63..2ef2f99 100644 --- a/apps/invoices/src/modules/app-configuration/metadata-manager.ts +++ b/apps/invoices/src/modules/app-configuration/metadata-manager.ts @@ -1,4 +1,8 @@ -import { MetadataEntry, EncryptedMetadataManager } from "@saleor/app-sdk/settings-manager"; +import { + MetadataEntry, + EncryptedMetadataManager, + SettingsManager, +} from "@saleor/app-sdk/settings-manager"; import { Client, gql } from "urql"; import { FetchAppDetailsDocument, @@ -31,7 +35,9 @@ gql` } `; -export async function fetchAllMetadata(client: Client): Promise { +export type SimpleGraphqlClient = Pick; + +export async function fetchAllMetadata(client: SimpleGraphqlClient): Promise { const { error, data } = await client .query(FetchAppDetailsDocument, {}) .toPromise(); @@ -43,7 +49,7 @@ export async function fetchAllMetadata(client: Client): Promise return data?.app?.privateMetadata.map((md) => ({ key: md.key, value: md.value })) || []; } -export async function mutateMetadata(client: Client, metadata: MetadataEntry[]) { +export async function mutateMetadata(client: SimpleGraphqlClient, metadata: MetadataEntry[]) { // to update the metadata, ID is required const { error: idQueryError, data: idQueryData } = await client .query(FetchAppDetailsDocument, {}) @@ -80,7 +86,7 @@ export async function mutateMetadata(client: Client, metadata: MetadataEntry[]) ); } -export const createSettingsManager = (client: Client) => { +export const createSettingsManager = (client: SimpleGraphqlClient): SettingsManager => { /* * EncryptedMetadataManager gives you interface to manipulate metadata and cache values in memory. * We recommend it for production, because all values are encrypted. diff --git a/apps/invoices/src/modules/app-configuration/schema-v1/app-config-v1.ts b/apps/invoices/src/modules/app-configuration/schema-v1/app-config-v1.ts new file mode 100644 index 0000000..c68a044 --- /dev/null +++ b/apps/invoices/src/modules/app-configuration/schema-v1/app-config-v1.ts @@ -0,0 +1,23 @@ +import { SellerAddress } from "../address"; + +/** + * @deprecated + * Remove when SchemaV1 is migrated to SchemaV2 + */ +export interface SellerShopConfig { + address: SellerAddress; +} + +/** + * @deprecated + * Remove when SchemaV1 is migrated to SchemaV2 + */ +export type ShopConfigPerChannelSlug = Record; + +/** + * @deprecated + * Remove when SchemaV1 is migrated to SchemaV2 + */ +export type AppConfigV1 = { + shopConfigPerChannel: ShopConfigPerChannelSlug; +}; diff --git a/apps/invoices/src/modules/app-configuration/app-configurator.ts b/apps/invoices/src/modules/app-configuration/schema-v1/app-configurator.ts similarity index 59% rename from apps/invoices/src/modules/app-configuration/app-configurator.ts rename to apps/invoices/src/modules/app-configuration/schema-v1/app-configurator.ts index 18a1b2d..adbdd07 100644 --- a/apps/invoices/src/modules/app-configuration/app-configurator.ts +++ b/apps/invoices/src/modules/app-configuration/schema-v1/app-configurator.ts @@ -1,17 +1,25 @@ -import { AppConfig } from "./app-config"; +import { AppConfigV1 } from "./app-config-v1"; import { SettingsManager } from "@saleor/app-sdk/settings-manager"; +/** + * @deprecated + * Remove when SchemaV1 is migrated to SchemaV2 + */ export interface AppConfigurator { - setConfig(config: AppConfig): Promise; - getConfig(): Promise; + setConfig(config: AppConfigV1): Promise; + getConfig(): Promise; } -export class PrivateMetadataAppConfigurator implements AppConfigurator { +/** + * @deprecated + * Remove when SchemaV1 is migrated to SchemaV2 + */ +export class PrivateMetadataAppConfiguratorV1 implements AppConfigurator { private metadataKey = "app-config"; constructor(private metadataManager: SettingsManager, private saleorApiUrl: string) {} - getConfig(): Promise { + getConfig(): Promise { return this.metadataManager.get(this.metadataKey, this.saleorApiUrl).then((data) => { if (!data) { return data; @@ -25,7 +33,7 @@ export class PrivateMetadataAppConfigurator implements AppConfigurator { }); } - setConfig(config: AppConfig): Promise { + setConfig(config: AppConfigV1): Promise { return this.metadataManager.set({ key: this.metadataKey, value: JSON.stringify(config), diff --git a/apps/invoices/src/modules/app-configuration/schema-v2/app-config-schema.v2.ts b/apps/invoices/src/modules/app-configuration/schema-v2/app-config-schema.v2.ts new file mode 100644 index 0000000..5678146 --- /dev/null +++ b/apps/invoices/src/modules/app-configuration/schema-v2/app-config-schema.v2.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; + +export const AddressV2Schema = z.object({ + /** + * min() to allow empty strings + */ + companyName: z.string().min(0), + cityArea: z.string().min(0), + countryArea: z.string().min(0), + streetAddress1: z.string().min(0), + streetAddress2: z.string().min(0), + postalCode: z.string().min(0), + city: z.string().min(0), + country: z.string().min(0), +}); +export const AppConfigV2Schema = z.object({ + channelsOverrides: z.record(AddressV2Schema), +}); + +export type AppConfigV2Shape = z.infer; +export type AddressV2Shape = z.infer; diff --git a/apps/invoices/src/modules/app-configuration/schema-v2/app-config-v2-metadata-manager.ts b/apps/invoices/src/modules/app-configuration/schema-v2/app-config-v2-metadata-manager.ts new file mode 100644 index 0000000..44d9626 --- /dev/null +++ b/apps/invoices/src/modules/app-configuration/schema-v2/app-config-v2-metadata-manager.ts @@ -0,0 +1,18 @@ +import { SettingsManager } from "@saleor/app-sdk/settings-manager"; + +export class AppConfigV2MetadataManager { + public readonly metadataKey = "app-config-v2"; + + constructor(private mm: SettingsManager) {} + + get() { + return this.mm.get(this.metadataKey); + } + + set(stringMetadata: string) { + return this.mm.set({ + key: this.metadataKey, + value: stringMetadata, + }); + } +} diff --git a/apps/invoices/src/modules/app-configuration/schema-v2/app-config.test.ts b/apps/invoices/src/modules/app-configuration/schema-v2/app-config.test.ts new file mode 100644 index 0000000..7f6e339 --- /dev/null +++ b/apps/invoices/src/modules/app-configuration/schema-v2/app-config.test.ts @@ -0,0 +1,95 @@ +import { vi, expect, describe, it } from "vitest"; +import { getMockAddress } from "../../../fixtures/mock-address"; +import { AppConfigV2 } from "./app-config"; + +describe("AppConfig", function () { + it("Serializes internal state", () => { + const appConfig = new AppConfigV2(); + + appConfig.upsertOverride("test", getMockAddress()); + + const serialized = appConfig.serialize(); + + expect(serialized).toMatchInlineSnapshot( + '"{\\"channelsOverrides\\":{\\"test\\":{\\"companyName\\":\\"Saleor\\",\\"cityArea\\":\\"\\",\\"countryArea\\":\\"Dolnoslaskie\\",\\"streetAddress1\\":\\"Techowa 7\\",\\"streetAddress2\\":\\"\\",\\"postalCode\\":\\"12-123\\",\\"city\\":\\"Wrocław\\",\\"country\\":\\"Poland\\"}}}"' + ); + }); + + it("Parses from serialized form", () => { + const appConfig = new AppConfigV2(); + + appConfig.upsertOverride("test", getMockAddress()); + + const serialized = appConfig.serialize(); + + const parsed = AppConfigV2.parse(serialized); + + expect(parsed.getChannelsOverrides()).toEqual({ + test: getMockAddress(), + }); + }); + + it("Accepts initial state in constructor", () => { + const appConfig = new AppConfigV2({ channelsOverrides: { test: getMockAddress() } }); + + expect(appConfig.getChannelsOverrides()).toEqual({ + test: getMockAddress(), + }); + }); + + it("upsertOverride stores new channel address override", () => { + const appConfig = new AppConfigV2({ + channelsOverrides: { + existing: getMockAddress(), + }, + }); + + appConfig.upsertOverride("test", getMockAddress()); + + expect(appConfig.getChannelsOverrides()).toEqual({ + test: getMockAddress(), + existing: getMockAddress(), + }); + }); + + it("upsertOverride updates channel address override if exists", () => { + const appConfig = new AppConfigV2({ + channelsOverrides: { + test: getMockAddress(), + }, + }); + + appConfig.upsertOverride("test", { + ...getMockAddress(), + cityArea: "override", + }); + + expect(appConfig.getChannelsOverrides()).toEqual({ + test: { ...getMockAddress(), cityArea: "override" }, + }); + }); + + it("removeOverride removes channel override from state", () => { + const appConfig = new AppConfigV2({ + channelsOverrides: { + test: getMockAddress(), + }, + }); + + appConfig.removeOverride("test"); + + expect(appConfig.getChannelsOverrides()).toEqual({}); + }); + + it("getChannelsOverrides returns record with overrides", () => { + const appConfig = new AppConfigV2({ + channelsOverrides: { + test: getMockAddress(), + }, + }); + + expect(appConfig.getChannelsOverrides()).toEqual({ + test: getMockAddress(), + }); + }); +}); diff --git a/apps/invoices/src/modules/app-configuration/schema-v2/app-config.ts b/apps/invoices/src/modules/app-configuration/schema-v2/app-config.ts new file mode 100644 index 0000000..4218762 --- /dev/null +++ b/apps/invoices/src/modules/app-configuration/schema-v2/app-config.ts @@ -0,0 +1,44 @@ +import { AddressV2Schema, AppConfigV2Schema, AppConfigV2Shape } from "./app-config-schema.v2"; +import { z } from "zod"; + +export class AppConfigV2 { + private rootData: AppConfigV2Shape = { channelsOverrides: {} }; + + constructor(initialData?: AppConfigV2Shape) { + if (initialData) { + this.rootData = AppConfigV2Schema.parse(initialData); + } + } + + static parse(serializedSchema: string) { + return new AppConfigV2(JSON.parse(serializedSchema)); + } + + serialize() { + return JSON.stringify(this.rootData); + } + + upsertOverride(channelSlug: string, address: z.infer) { + const parsedAddress = AddressV2Schema.parse(address); + /** + * TODO Here we cant be sure if this is slug or name. Service / controller should verify it if possible + */ + const channelSlugParsed = z.string().parse(channelSlug); + + this.rootData.channelsOverrides[channelSlugParsed] = parsedAddress; + + return this; + } + + removeOverride(channelSlug: string) { + const channelSlugParsed = z.string().parse(channelSlug); + + delete this.rootData.channelsOverrides[channelSlugParsed]; + + return this; + } + + getChannelsOverrides() { + return this.rootData.channelsOverrides; + } +} diff --git a/apps/invoices/src/modules/app-configuration/schema-v2/config-v1-to-v2-migration.service.test.ts b/apps/invoices/src/modules/app-configuration/schema-v2/config-v1-to-v2-migration.service.test.ts new file mode 100644 index 0000000..0187e6d --- /dev/null +++ b/apps/invoices/src/modules/app-configuration/schema-v2/config-v1-to-v2-migration.service.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ConfigV1ToV2MigrationService } from "./config-v1-to-v2-migration.service"; +import { SimpleGraphqlClient } from "../metadata-manager"; +import { getMockAddress } from "../../../fixtures/mock-address"; +import { AppConfigV2 } from "./app-config"; + +describe("config-v1-to-v2-migration.service", () => { + const mockClient: SimpleGraphqlClient = { + mutation: vi.fn(), + query: vi.fn(), + }; + + let service: ConfigV1ToV2MigrationService; + + beforeEach(() => { + vi.resetAllMocks(); + + service = new ConfigV1ToV2MigrationService(mockClient, "https://example.com/graphql/"); + + vi.spyOn(service.configMetadataManager, "set").mockImplementationOnce(async () => + Promise.resolve() + ); + }); + + it("Returns a pure V2 config if V1 config is not present", async () => { + vi.spyOn(service.metadataV1AppConfigurator, "getConfig").mockImplementationOnce(async () => + Promise.resolve(undefined) + ); + + const migrationResult = await service.migrate(); + + expect(migrationResult.getChannelsOverrides()).toEqual({}); + expect(service.configMetadataManager.set).toHaveBeenCalledWith(migrationResult.serialize()); + }); + + it("Returns a migrated V2 config from V1 if V1 config is present", async () => { + vi.spyOn(service.metadataV1AppConfigurator, "getConfig").mockImplementationOnce(async () => + Promise.resolve({ + shopConfigPerChannel: { + "default-channel": { + address: getMockAddress(), + }, + }, + }) + ); + + const migrationResult = await service.migrate(); + + expect(migrationResult.getChannelsOverrides()).toEqual( + expect.objectContaining({ + "default-channel": expect.objectContaining(getMockAddress()), + }) + ); + }); + + it("Runs a beforeSave callback and saves modified state in metadata - missing v1 config scenario", async () => { + vi.spyOn(service.metadataV1AppConfigurator, "getConfig").mockImplementationOnce(async () => + Promise.resolve(undefined) + ); + + const beforeSaveCb = vi.fn().mockImplementationOnce((config: AppConfigV2) => { + config.upsertOverride("test", getMockAddress()); + }); + + const migrationResult = await service.migrate(beforeSaveCb); + + expect(migrationResult.getChannelsOverrides()).toEqual({ + test: expect.objectContaining(getMockAddress()), + }); + expect(service.configMetadataManager.set).toHaveBeenCalledWith(migrationResult.serialize()); + expect(beforeSaveCb).toHaveBeenCalledWith(migrationResult); + }); + + it("Runs a beforeSave callback and saves modified state in metadata - present v1 config scenario", async () => { + vi.spyOn(service.metadataV1AppConfigurator, "getConfig").mockImplementationOnce(async () => + Promise.resolve({ + shopConfigPerChannel: { + "default-channel": { + address: getMockAddress(), + }, + }, + }) + ); + + const beforeSaveCb = vi.fn().mockImplementationOnce((config: AppConfigV2) => { + config.removeOverride("default-channel"); + }); + + const migrationResult = await service.migrate(beforeSaveCb); + + expect(migrationResult.getChannelsOverrides()).toEqual({}); + expect(service.configMetadataManager.set).toHaveBeenCalledWith(migrationResult.serialize()); + expect(beforeSaveCb).toHaveBeenCalledWith(migrationResult); + }); +}); diff --git a/apps/invoices/src/modules/app-configuration/schema-v2/config-v1-to-v2-migration.service.ts b/apps/invoices/src/modules/app-configuration/schema-v2/config-v1-to-v2-migration.service.ts new file mode 100644 index 0000000..1e59c2b --- /dev/null +++ b/apps/invoices/src/modules/app-configuration/schema-v2/config-v1-to-v2-migration.service.ts @@ -0,0 +1,54 @@ +import { PrivateMetadataAppConfiguratorV1 } from "../schema-v1/app-configurator"; +import { createSettingsManager, SimpleGraphqlClient } from "../metadata-manager"; +import { AppConfigV2 } from "./app-config"; +import { ConfigV1ToV2Transformer } from "./config-v1-to-v2-transformer"; +import { AppConfigV2MetadataManager } from "./app-config-v2-metadata-manager"; +import { SettingsManager } from "@saleor/app-sdk/settings-manager"; + +export class ConfigV1ToV2MigrationService { + settingsManager: SettingsManager; + configMetadataManager: AppConfigV2MetadataManager; + metadataV1AppConfigurator: PrivateMetadataAppConfiguratorV1; + + constructor(private client: SimpleGraphqlClient, private saleorApiUrl: string) { + this.settingsManager = createSettingsManager(client); + this.configMetadataManager = new AppConfigV2MetadataManager(this.settingsManager); + this.metadataV1AppConfigurator = new PrivateMetadataAppConfiguratorV1( + this.settingsManager, + this.saleorApiUrl + ); + } + + async migrate(beforeSave?: (config: AppConfigV2) => void): Promise { + const v1Config = await this.metadataV1AppConfigurator.getConfig(); + + /** + * If no v1 config, it means clean install - return pure config + */ + if (!v1Config) { + const pureConfig = new AppConfigV2(); + + if (beforeSave) { + beforeSave(pureConfig); + } + + await this.configMetadataManager.set(pureConfig.serialize()); + + return pureConfig; + } + + /** + * Otherwise, transform v1 config to v2 and save it + */ + const transformer = new ConfigV1ToV2Transformer(); + const appConfigV2FromV1 = transformer.transform(v1Config); + + if (beforeSave) { + beforeSave(appConfigV2FromV1); + } + + await this.configMetadataManager.set(appConfigV2FromV1.serialize()); + + return appConfigV2FromV1; + } +} diff --git a/apps/invoices/src/modules/app-configuration/schema-v2/config-v1-to-v2-transformer.test.ts b/apps/invoices/src/modules/app-configuration/schema-v2/config-v1-to-v2-transformer.test.ts new file mode 100644 index 0000000..8c02d7a --- /dev/null +++ b/apps/invoices/src/modules/app-configuration/schema-v2/config-v1-to-v2-transformer.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { ConfigV1ToV2Transformer } from "./config-v1-to-v2-transformer"; +import { getMockAddress } from "../../../fixtures/mock-address"; + +describe("ConfigV1ToV2Transformer", function () { + it("Returns empty V2 instance if config is null", () => { + // @ts-expect-error + const v2 = new ConfigV1ToV2Transformer().transform(null); + + expect(v2.serialize()).toMatchInlineSnapshot('"{\\"channelsOverrides\\":{}}"'); + }); + + it("Maps V1 address overrides to V2 - single channel override", () => { + const v2 = new ConfigV1ToV2Transformer().transform({ + shopConfigPerChannel: { + "default-channel": { + address: getMockAddress(), + }, + }, + }); + + expect(v2.getChannelsOverrides()).toEqual( + expect.objectContaining({ + "default-channel": getMockAddress(), + }) + ); + }); + + it("Maps V1 address overrides to V2 - multiple channels override", () => { + const v2 = new ConfigV1ToV2Transformer().transform({ + shopConfigPerChannel: { + "default-channel": { + address: getMockAddress(), + }, + "custom-channel": { + address: getMockAddress(), + }, + }, + }); + + expect(v2.getChannelsOverrides()).toEqual( + expect.objectContaining({ + "default-channel": getMockAddress(), + "custom-channel": getMockAddress(), + }) + ); + }); + + it("Falls back to empty string for address property if not set", () => { + const addressMock = getMockAddress(); + + // @ts-expect-error + delete addressMock.city; + + const v2 = new ConfigV1ToV2Transformer().transform({ + shopConfigPerChannel: { + "default-channel": { + address: addressMock, + }, + }, + }); + + expect(v2.getChannelsOverrides()).toEqual( + expect.objectContaining({ + "default-channel": { + ...getMockAddress(), + city: "", + }, + }) + ); + }); +}); diff --git a/apps/invoices/src/modules/app-configuration/schema-v2/config-v1-to-v2-transformer.ts b/apps/invoices/src/modules/app-configuration/schema-v2/config-v1-to-v2-transformer.ts new file mode 100644 index 0000000..af7e9bc --- /dev/null +++ b/apps/invoices/src/modules/app-configuration/schema-v2/config-v1-to-v2-transformer.ts @@ -0,0 +1,29 @@ +import { AppConfigV1 } from "../schema-v1/app-config-v1"; +import { AppConfigV2 } from "./app-config"; + +export class ConfigV1ToV2Transformer { + transform(v1Config: AppConfigV1): AppConfigV2 { + const configV2 = new AppConfigV2(); + + if (!v1Config || !v1Config.shopConfigPerChannel) { + return configV2; + } + + Object.entries(v1Config.shopConfigPerChannel).forEach(([channelSlug, channelConfigV1]) => { + const addressV1 = channelConfigV1.address; + + configV2.upsertOverride(channelSlug, { + city: addressV1.city ?? "", + country: addressV1.country ?? "", + streetAddress2: addressV1.streetAddress2 ?? "", + postalCode: addressV1.postalCode ?? "", + companyName: addressV1.companyName ?? "", + streetAddress1: addressV1.streetAddress1 ?? "", + countryArea: addressV1.countryArea ?? "", + cityArea: addressV1.cityArea ?? "", + }); + }); + + return configV2; + } +} diff --git a/apps/invoices/src/modules/app-configuration/schema-v2/get-app-configuration.v2.service.test.ts b/apps/invoices/src/modules/app-configuration/schema-v2/get-app-configuration.v2.service.test.ts new file mode 100644 index 0000000..34b264c --- /dev/null +++ b/apps/invoices/src/modules/app-configuration/schema-v2/get-app-configuration.v2.service.test.ts @@ -0,0 +1,49 @@ +import { describe, it, vi, expect, beforeEach } from "vitest"; +import { GetAppConfigurationV2Service } from "./get-app-configuration.v2.service"; +import { AppConfigV2 } from "./app-config"; +import { getMockAddress } from "../../../fixtures/mock-address"; + +describe("GetAppConfigurationV2Service", function () { + let service: GetAppConfigurationV2Service; + + beforeEach(() => { + vi.resetAllMocks(); + + service = new GetAppConfigurationV2Service({ + saleorApiUrl: "https://example.com/graphql/", + apiClient: { + mutation: vi.fn(), + query: vi.fn(), + }, + }); + }); + + it("Returns parsed AppConfigV2 when metadata is found", async () => { + vi.spyOn(service.appConfigMetadataManager, "get").mockImplementationOnce(async () => { + const configSavedInMetadata = new AppConfigV2({ + channelsOverrides: { + test: getMockAddress(), + }, + }).serialize(); + + return configSavedInMetadata; + }); + + const configuration = await service.getConfiguration(); + + expect(configuration).toBeDefined(); + expect(configuration!.getChannelsOverrides()).toEqual({ + test: getMockAddress(), + }); + }); + + it("Returns null if metadata is not found", async () => { + vi.spyOn(service.appConfigMetadataManager, "get").mockImplementationOnce(async () => { + return undefined; + }); + + const configuration = await service.getConfiguration(); + + expect(configuration).toBeNull(); + }); +}); diff --git a/apps/invoices/src/modules/app-configuration/schema-v2/get-app-configuration.v2.service.ts b/apps/invoices/src/modules/app-configuration/schema-v2/get-app-configuration.v2.service.ts new file mode 100644 index 0000000..e7d09c8 --- /dev/null +++ b/apps/invoices/src/modules/app-configuration/schema-v2/get-app-configuration.v2.service.ts @@ -0,0 +1,36 @@ +import { createLogger } from "@saleor/apps-shared"; +import { AppConfigV2MetadataManager } from "./app-config-v2-metadata-manager"; +import { createSettingsManager, SimpleGraphqlClient } from "../metadata-manager"; +import { AppConfigV2 } from "./app-config"; + +export class GetAppConfigurationV2Service { + appConfigMetadataManager: AppConfigV2MetadataManager; + + constructor( + private settings: { + apiClient: SimpleGraphqlClient; + saleorApiUrl: string; + } + ) { + this.appConfigMetadataManager = new AppConfigV2MetadataManager( + createSettingsManager(settings.apiClient) + ); + } + + async getConfiguration() { + const logger = createLogger({ + service: "GetAppConfigurationV2Service", + saleorApiUrl: this.settings.saleorApiUrl, + }); + + const stringMetadata = await this.appConfigMetadataManager.get(); + + if (stringMetadata) { + logger.debug("Found app configuration v2 metadata"); + return AppConfigV2.parse(stringMetadata); + } else { + logger.debug("v2 metadata not found"); + return null; + } + } +} diff --git a/apps/invoices/src/modules/app-configuration/ui/address-form.tsx b/apps/invoices/src/modules/app-configuration/ui/address-form.tsx index 1c73a13..05b9838 100644 --- a/apps/invoices/src/modules/app-configuration/ui/address-form.tsx +++ b/apps/invoices/src/modules/app-configuration/ui/address-form.tsx @@ -1,79 +1,196 @@ -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"; +import { Controller, useForm } from "react-hook-form"; -const useStyles = makeStyles((theme) => ({ - field: { - marginBottom: 20, - }, - form: { - padding: 20, - paddingTop: 0, - }, - channelName: { - cursor: "pointer", - borderBottom: `2px solid ${theme.palette.secondary.main}`, - }, -})); +import React, { useCallback, useEffect } from "react"; +import { Box, Button, Input, Text } from "@saleor/macaw-ui/next"; +import { SellerAddress } from "../address"; +import { trpcClient } from "../../trpc/trpc-client"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { useDashboardNotification } from "@saleor/apps-shared"; +import { useRouter } from "next/router"; +import { AddressV2Schema, AddressV2Shape } from "../schema-v2/app-config-schema.v2"; type Props = { channelSlug: string; - channelName: string; - channelID: string; - onSubmit(data: SellerShopConfig["address"]): Promise; - initialData?: SellerShopConfig["address"] | null; }; -export const AddressForm = (props: Props) => { - const { register, handleSubmit } = useForm({ - defaultValues: props.initialData ?? undefined, +type InnerFormProps = { + address: AddressV2Shape; + onSubmit(fields: AddressV2Shape): Promise; + onCancel(): void; +}; + +/** + * Use the same form structure as metadata to avoid mapping and distributed validation. + * If extra rules are needed, it can be separated and mapped + */ +const FormSchema = AddressV2Schema; + +type FormSchemaType = z.infer; + +/** + * Divide fields into blocks to make it easier to create a form layout + */ +const fieldsBlock1: Array = [ + "companyName", + "streetAddress1", + "streetAddress2", +]; +const fieldsBlock2: Array = ["postalCode", "city"]; +const fieldsBlock3: Array = ["cityArea", "country", "countryArea"]; + +const fieldLabels: Record = { + countryArea: "Country Area", + country: "Country", + cityArea: "City Area", + streetAddress2: "Street Address 2", + streetAddress1: "Street Address 1", + companyName: "Company Name", + city: "City", + postalCode: "Postal Code", +}; + +export const AddressForm = (props: Props & InnerFormProps) => { + const { handleSubmit, formState, control, reset } = useForm({ + defaultValues: props.address, + resolver: zodResolver(FormSchema), }); - 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); + return props.onSubmit(data); })} - className={styles.form} > - - Configure - - {` ${props.channelName} `} - - channel: - - - - -
- - -
- - - - + + {fieldsBlock1.map((fieldName) => ( + { + return ( + + ); + }} + name={fieldName} + /> + ))} + + + {fieldsBlock2.map((fieldName) => ( + { + return ( + + ); + }} + name={fieldName} + /> + ))} + + + {fieldsBlock3.map((fieldName) => ( + { + return ( + + ); + }} + name={fieldName} + /> + ))} + + + + + ); }; + +export const ConnectedAddressForm = (props: Props) => { + const { notifySuccess, notifyError } = useDashboardNotification(); + + const channelOverrideConfigQuery = trpcClient.appConfiguration.fetchChannelsOverrides.useQuery(); + + const upsertConfigMutation = trpcClient.appConfiguration.upsertChannelOverride.useMutation({ + onSuccess() { + notifySuccess("Success", "Updated channel configuration"); + + push("/configuration"); + }, + onError() { + notifyError("Error", "Failed to save configuration"); + }, + }); + + const { push } = useRouter(); + + const addressData = + channelOverrideConfigQuery.data && channelOverrideConfigQuery.data[props.channelSlug]; + + const submitHandler = useCallback( + async (data: AddressV2Shape) => { + return upsertConfigMutation.mutate({ + address: data, + channelSlug: props.channelSlug, + }); + }, + [props.channelSlug, upsertConfigMutation] + ); + + const onCancelHandler = useCallback(() => { + push("/configuration"); + }, [push]); + + if (channelOverrideConfigQuery.isLoading || !addressData) { + return Loading; + } + + return ( + + ); +}; diff --git a/apps/invoices/src/modules/app-configuration/ui/channels-configuration.tsx b/apps/invoices/src/modules/app-configuration/ui/channels-configuration.tsx deleted file mode 100644 index 429b92c..0000000 --- a/apps/invoices/src/modules/app-configuration/ui/channels-configuration.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { trpcClient } from "../../trpc/trpc-client"; -import { Link, Paper, Typography } from "@material-ui/core"; -import React, { useEffect, useMemo, useState } from "react"; -import { makeStyles } from "@saleor/macaw-ui"; -import { AppConfigContainer } from "../app-config-container"; -import { AddressForm } from "./address-form"; -import { ChannelsList } from "./channels-list"; -import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge"; -import { AppColumnsLayout } from "../../ui/app-columns-layout"; -import { useDashboardNotification } from "@saleor/apps-shared"; - -const useStyles = makeStyles((theme) => { - return { - header: { marginBottom: 20 }, - grid: { display: "grid", gridTemplateColumns: "1fr 1fr", alignItems: "start", gap: 40 }, - formContainer: {}, - instructionsContainer: { - marginTop: 12, - padding: 15, - }, - }; -}); - -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 null; - } - - if (!activeChannel) { - return
Error. No channel available
; - } - - return ( - - { - setActiveChannelSlug(slug); - window.scrollTo({ top: 0, behavior: "smooth" }); - }} - /> - - {activeChannel ? ( - - { - const newConfig = AppConfigContainer.setChannelAddress(configurationData)( - activeChannel.slug - )(data); - - mutate(newConfig); - }} - initialData={AppConfigContainer.getChannelAddress(configurationData)( - activeChannel.slug - )} - channelName={activeChannel?.name ?? activeChannelSlug} - /> - {saveError && {saveError.message}} - - ) : null} - - - Generate invoices for orders in your shop - - - Shop data on the invoice an be configured per channel. If not set it will use shop data - from{" "} - { - appBridge?.dispatch( - actions.Redirect({ - to: "/site-settings", - }) - ); - }} - > - the configuration - - - - Go to{" "} - { - appBridge?.dispatch( - actions.Redirect({ - to: "/orders", - }) - ); - }} - > - Orders - {" "} - and open any Order. Then click Invoices -{">"} Generate. Invoice will be - added to the order page - - - - ); -}; diff --git a/apps/invoices/src/modules/app-configuration/ui/channels-list.tsx b/apps/invoices/src/modules/app-configuration/ui/channels-list.tsx deleted file mode 100644 index 35a8e3e..0000000 --- a/apps/invoices/src/modules/app-configuration/ui/channels-list.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { - makeStyles, - OffsettedList, - OffsettedListBody, - OffsettedListHeader, - OffsettedListItem, - OffsettedListItemCell, -} from "@saleor/macaw-ui"; -import clsx from "clsx"; -import { Typography } from "@material-ui/core"; -import React from "react"; -import { ChannelFragment } from "../../../../generated/graphql"; - -const useStyles = makeStyles((theme) => { - return { - listItem: { - cursor: "pointer", - height: "auto !important", - }, - listItemActive: { - background: "#f4f4f4", - borderRadius: 4, - overflow: "hidden", - }, - channelSlug: { - fontFamily: "monospace", - opacity: 0.8, - }, - }; -}); - -type Props = { - channels: ChannelFragment[]; - activeChannelSlug: string; - onChannelClick(channelSlug: string): void; -}; - -export const ChannelsList = ({ channels, activeChannelSlug, onChannelClick }: Props) => { - const styles = useStyles(); - - return ( - - - - Available channels - - - - {channels.map((c) => { - return ( - { - onChannelClick(c.slug); - }} - > - - {c.name} - - {c.slug} - - - - ); - })} - - - ); -}; diff --git a/apps/invoices/src/modules/app-configuration/views/app-config.view.tsx b/apps/invoices/src/modules/app-configuration/views/app-config.view.tsx new file mode 100644 index 0000000..aa54eb2 --- /dev/null +++ b/apps/invoices/src/modules/app-configuration/views/app-config.view.tsx @@ -0,0 +1,61 @@ +import { Box, Text } from "@saleor/macaw-ui/next"; +import { DefaultShopAddress } from "../../shop-info/ui/default-shop-address"; +import { AppSection } from "../../ui/AppSection"; +import { PerChannelConfigList } from "../../channels/ui/per-channel-config-list"; +import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge"; + +export const AppConfigView = () => { + const { appBridge } = useAppBridge(); + + return ( + + + + + Configuration + + + The Invoices App will generate invoices for each order, for which{" "} + INVOICE_REQUESTED event will be triggered + + + By default it will use{" "} + { + appBridge?.dispatch( + actions.Redirect({ + to: "/site-settings", + }) + ); + }} + > + site settings + {" "} + address, but each channel can be configured separately + + + + + + + } + sideContent={ + + Configure custom billing address for each channel. If not set, default shop address will + be used + + } + /> + + ); +}; diff --git a/apps/invoices/src/modules/app-configuration/views/channel-config.view.tsx b/apps/invoices/src/modules/app-configuration/views/channel-config.view.tsx new file mode 100644 index 0000000..549ba6d --- /dev/null +++ b/apps/invoices/src/modules/app-configuration/views/channel-config.view.tsx @@ -0,0 +1,57 @@ +import { Box, ChevronRightIcon, Text, Button } from "@saleor/macaw-ui/next"; +import { AppSection } from "../../ui/AppSection"; +import { useRouter } from "next/router"; +import { ConnectedAddressForm } from "../ui/address-form"; +import { trpcClient } from "../../trpc/trpc-client"; +import { useDashboardNotification } from "@saleor/apps-shared"; + +export const ChannelConfigView = () => { + const { + push, + query: { channel }, + } = useRouter(); + + const { mutateAsync } = trpcClient.appConfiguration.removeChannelOverride.useMutation(); + const { notifySuccess } = useDashboardNotification(); + + if (!channel) { + return null; + } + + return ( + + + + Configuration + + Edit channel + + {channel} + + + } + sideContent={ + + + Set custom billing address for {channel} channel. + + + + } + /> + + ); +}; diff --git a/apps/invoices/src/modules/channels/ui/per-channel-config-list.tsx b/apps/invoices/src/modules/channels/ui/per-channel-config-list.tsx new file mode 100644 index 0000000..dd8ca97 --- /dev/null +++ b/apps/invoices/src/modules/channels/ui/per-channel-config-list.tsx @@ -0,0 +1,108 @@ +import { Box, Text, Chip, Button } from "@saleor/macaw-ui/next"; +import { trpcClient } from "../../trpc/trpc-client"; +import { useRouter } from "next/router"; + +const defaultAddressChip = ( + + + Default + + +); + +export const PerChannelConfigList = () => { + const shopChannelsQuery = trpcClient.channels.fetch.useQuery(); + const channelsOverridesQuery = trpcClient.appConfiguration.fetchChannelsOverrides.useQuery(); + + const { push } = useRouter(); + + if (shopChannelsQuery.isLoading || channelsOverridesQuery.isLoading) { + return Loading...; + } + + const renderChannelAddress = (slug: string) => { + const overridesDataRecord = channelsOverridesQuery.data; + + if (!overridesDataRecord) { + return null; // todo should throw + } + + if (overridesDataRecord[slug]) { + const address = overridesDataRecord[slug]; + + /** + * TODO extract address rendering + */ + return ( + + + {address.companyName} + + + {address.streetAddress1} + + + {address.streetAddress2} + + + {address.postalCode} {address.city} + + + {address.country} + + + ); + } else { + return defaultAddressChip; + } + }; + + const renderActionButtonAddress = (slug: string) => { + const overridesDataRecord = channelsOverridesQuery.data; + + if (!overridesDataRecord) { + return null; // todo should throw + } + + return ( + + ); + }; + + return ( + + + + Channel + + + Address + + + {shopChannelsQuery.data?.map((channel) => ( + + {channel.name} + {renderChannelAddress(channel.slug)} + {renderActionButtonAddress(channel.slug)} + + ))} + + ); +}; diff --git a/apps/invoices/src/modules/invoice-create-notifier/invoice-create-notifier.ts b/apps/invoices/src/modules/invoices/invoice-create-notifier/invoice-create-notifier.ts similarity index 93% rename from apps/invoices/src/modules/invoice-create-notifier/invoice-create-notifier.ts rename to apps/invoices/src/modules/invoices/invoice-create-notifier/invoice-create-notifier.ts index 7c2ca69..13223ac 100644 --- a/apps/invoices/src/modules/invoice-create-notifier/invoice-create-notifier.ts +++ b/apps/invoices/src/modules/invoices/invoice-create-notifier/invoice-create-notifier.ts @@ -1,5 +1,5 @@ import { Client, gql } from "urql"; -import { InvoiceCreateDocument } from "../../../generated/graphql"; +import { InvoiceCreateDocument } from "../../../../generated/graphql"; import { logger } from "@saleor/apps-shared"; gql` diff --git a/apps/invoices/src/modules/invoice-file-name/hash-invoice-filename.test.ts b/apps/invoices/src/modules/invoices/invoice-file-name/hash-invoice-filename.test.ts similarity index 100% rename from apps/invoices/src/modules/invoice-file-name/hash-invoice-filename.test.ts rename to apps/invoices/src/modules/invoices/invoice-file-name/hash-invoice-filename.test.ts diff --git a/apps/invoices/src/modules/invoice-file-name/hash-invoice-filename.ts b/apps/invoices/src/modules/invoices/invoice-file-name/hash-invoice-filename.ts similarity index 100% rename from apps/invoices/src/modules/invoice-file-name/hash-invoice-filename.ts rename to apps/invoices/src/modules/invoices/invoice-file-name/hash-invoice-filename.ts diff --git a/apps/invoices/src/modules/invoice-file-name/resolve-temp-pdf-file-location.test.ts b/apps/invoices/src/modules/invoices/invoice-file-name/resolve-temp-pdf-file-location.test.ts similarity index 100% rename from apps/invoices/src/modules/invoice-file-name/resolve-temp-pdf-file-location.test.ts rename to apps/invoices/src/modules/invoices/invoice-file-name/resolve-temp-pdf-file-location.test.ts diff --git a/apps/invoices/src/modules/invoice-file-name/resolve-temp-pdf-file-location.ts b/apps/invoices/src/modules/invoices/invoice-file-name/resolve-temp-pdf-file-location.ts similarity index 100% rename from apps/invoices/src/modules/invoice-file-name/resolve-temp-pdf-file-location.ts rename to apps/invoices/src/modules/invoices/invoice-file-name/resolve-temp-pdf-file-location.ts diff --git a/apps/invoices/src/modules/invoice-generator/invoice-generator.ts b/apps/invoices/src/modules/invoices/invoice-generator/invoice-generator.ts similarity index 58% rename from apps/invoices/src/modules/invoice-generator/invoice-generator.ts rename to apps/invoices/src/modules/invoices/invoice-generator/invoice-generator.ts index 8c587c0..377b01d 100644 --- a/apps/invoices/src/modules/invoice-generator/invoice-generator.ts +++ b/apps/invoices/src/modules/invoices/invoice-generator/invoice-generator.ts @@ -1,5 +1,5 @@ -import { OrderPayloadFragment } from "../../../generated/graphql"; -import { SellerShopConfig } from "../app-configuration/app-config"; +import { OrderPayloadFragment } from "../../../../generated/graphql"; +import { SellerShopConfig } from "../../app-configuration/schema-v1/app-config-v1"; export interface InvoiceGenerator { generate(input: { diff --git a/apps/invoices/src/modules/invoice-generator/microinvoice/microinvoice-invoice-generator.test.ts b/apps/invoices/src/modules/invoices/invoice-generator/microinvoice/microinvoice-invoice-generator.test.ts similarity index 89% rename from apps/invoices/src/modules/invoice-generator/microinvoice/microinvoice-invoice-generator.test.ts rename to apps/invoices/src/modules/invoices/invoice-generator/microinvoice/microinvoice-invoice-generator.test.ts index 424ac21..a70d25d 100644 --- a/apps/invoices/src/modules/invoice-generator/microinvoice/microinvoice-invoice-generator.test.ts +++ b/apps/invoices/src/modules/invoices/invoice-generator/microinvoice/microinvoice-invoice-generator.test.ts @@ -3,8 +3,8 @@ import { MicroinvoiceInvoiceGenerator } from "./microinvoice-invoice-generator"; import { readFile } from "fs/promises"; import { join } from "path"; import rimraf from "rimraf"; -import { mockOrder } from "../../../fixtures/mock-order"; -import { getMockAddress } from "../../../fixtures/mock-address"; +import { mockOrder } from "../../../../fixtures/mock-order"; +import { getMockAddress } from "../../../../fixtures/mock-address"; const dirToSet = process.env.TEMP_PDF_STORAGE_DIR as string; const filePath = join(dirToSet, "test-invoice.pdf"); diff --git a/apps/invoices/src/modules/invoice-generator/microinvoice/microinvoice-invoice-generator.ts b/apps/invoices/src/modules/invoices/invoice-generator/microinvoice/microinvoice-invoice-generator.ts similarity index 96% rename from apps/invoices/src/modules/invoice-generator/microinvoice/microinvoice-invoice-generator.ts rename to apps/invoices/src/modules/invoices/invoice-generator/microinvoice/microinvoice-invoice-generator.ts index c57fd4f..fe6ff5a 100644 --- a/apps/invoices/src/modules/invoice-generator/microinvoice/microinvoice-invoice-generator.ts +++ b/apps/invoices/src/modules/invoices/invoice-generator/microinvoice/microinvoice-invoice-generator.ts @@ -1,6 +1,6 @@ import { InvoiceGenerator } from "../invoice-generator"; -import { Order, OrderPayloadFragment } from "../../../../generated/graphql"; -import { SellerShopConfig } from "../../app-configuration/app-config"; +import { Order, OrderPayloadFragment } from "../../../../../generated/graphql"; +import { SellerShopConfig } from "../../../app-configuration/schema-v1/app-config-v1"; const Microinvoice = require("microinvoice"); export class MicroinvoiceInvoiceGenerator implements InvoiceGenerator { diff --git a/apps/invoices/src/modules/invoice-number-generator/invoice-number-generator.test.ts b/apps/invoices/src/modules/invoices/invoice-number-generator/invoice-number-generator.test.ts similarity index 100% rename from apps/invoices/src/modules/invoice-number-generator/invoice-number-generator.test.ts rename to apps/invoices/src/modules/invoices/invoice-number-generator/invoice-number-generator.test.ts diff --git a/apps/invoices/src/modules/invoice-number-generator/invoice-number-generator.ts b/apps/invoices/src/modules/invoices/invoice-number-generator/invoice-number-generator.ts similarity index 89% rename from apps/invoices/src/modules/invoice-number-generator/invoice-number-generator.ts rename to apps/invoices/src/modules/invoices/invoice-number-generator/invoice-number-generator.ts index 25de828..043e41d 100644 --- a/apps/invoices/src/modules/invoice-number-generator/invoice-number-generator.ts +++ b/apps/invoices/src/modules/invoices/invoice-number-generator/invoice-number-generator.ts @@ -1,4 +1,4 @@ -import { OrderPayloadFragment } from "../../../generated/graphql"; +import { OrderPayloadFragment } from "../../../../generated/graphql"; interface IInvoiceNumberGenerationStrategy { (order: OrderPayloadFragment): string; diff --git a/apps/invoices/src/modules/invoice-uploader/invoice-uploader.ts b/apps/invoices/src/modules/invoices/invoice-uploader/invoice-uploader.ts similarity index 100% rename from apps/invoices/src/modules/invoice-uploader/invoice-uploader.ts rename to apps/invoices/src/modules/invoices/invoice-uploader/invoice-uploader.ts diff --git a/apps/invoices/src/modules/invoice-uploader/saleor-invoice-uploader.ts b/apps/invoices/src/modules/invoices/invoice-uploader/saleor-invoice-uploader.ts similarity index 95% rename from apps/invoices/src/modules/invoice-uploader/saleor-invoice-uploader.ts rename to apps/invoices/src/modules/invoices/invoice-uploader/saleor-invoice-uploader.ts index 44ed3d4..4454721 100644 --- a/apps/invoices/src/modules/invoice-uploader/saleor-invoice-uploader.ts +++ b/apps/invoices/src/modules/invoices/invoice-uploader/saleor-invoice-uploader.ts @@ -1,7 +1,7 @@ import { InvoiceUploader } from "./invoice-uploader"; import { Client, gql } from "urql"; import { readFile } from "fs/promises"; -import { FileUploadMutation } from "../../../generated/graphql"; +import { FileUploadMutation } from "../../../../generated/graphql"; /** * Polyfill file because Node doesn't have it yet * https://github.com/nodejs/node/commit/916af4ef2d63fe936a369bcf87ee4f69ec7c67ce diff --git a/apps/invoices/src/modules/shop-info/shop-info-query-to-address-shape.ts b/apps/invoices/src/modules/shop-info/shop-info-query-to-address-shape.ts new file mode 100644 index 0000000..ca37457 --- /dev/null +++ b/apps/invoices/src/modules/shop-info/shop-info-query-to-address-shape.ts @@ -0,0 +1,32 @@ +import { ShopInfoFragment, ShopInfoQuery } from "../../../generated/graphql"; +import { AddressV2Shape } from "../app-configuration/schema-v2/app-config-schema.v2"; + +export const shopInfoQueryToAddressShape = ( + shopFragment: ShopInfoFragment | null +): AddressV2Shape | null => { + if (!shopFragment?.companyAddress) { + return null; + } + + const { + streetAddress2, + streetAddress1, + country, + countryArea, + postalCode, + cityArea, + companyName, + city, + } = shopFragment.companyAddress; + + return { + city, + cityArea, + companyName, + country: country.country, + countryArea, + postalCode, + streetAddress1, + streetAddress2, + }; +}; diff --git a/apps/invoices/src/modules/shop-info/shop-info.router.ts b/apps/invoices/src/modules/shop-info/shop-info.router.ts new file mode 100644 index 0000000..a930644 --- /dev/null +++ b/apps/invoices/src/modules/shop-info/shop-info.router.ts @@ -0,0 +1,14 @@ +import { router } from "../trpc/trpc-server"; +import { protectedClientProcedure } from "../trpc/protected-client-procedure"; +import { createLogger } from "@saleor/apps-shared"; +import { ShopInfoFetcher } from "./shop-info-fetcher"; + +export const shopInfoRouter = router({ + fetchShopAddress: protectedClientProcedure.query(async ({ ctx, input }) => { + const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl }); + + logger.debug("shopInfoRouter.fetchShopAddress called"); + + return new ShopInfoFetcher(ctx.apiClient).fetchShopInfo(); + }), +}); diff --git a/apps/invoices/src/modules/shop-info/ui/default-shop-address.tsx b/apps/invoices/src/modules/shop-info/ui/default-shop-address.tsx new file mode 100644 index 0000000..3d69117 --- /dev/null +++ b/apps/invoices/src/modules/shop-info/ui/default-shop-address.tsx @@ -0,0 +1,93 @@ +import { Box, Text, Button } from "@saleor/macaw-ui/next"; +import { trpcClient } from "../../trpc/trpc-client"; +import { PropsWithChildren } from "react"; +import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge"; + +const Wrapper = ({ children }: PropsWithChildren<{}>) => { + const { appBridge } = useAppBridge(); + + return ( + + + Default address of the shop + + + {children} + + ); +}; + +export const DefaultShopAddress = () => { + const { data, isLoading, error, refetch } = trpcClient.shopInfo.fetchShopAddress.useQuery(); + + if (error) { + return ( + + + Error while fetching shop address + + + + ); + } + + if (isLoading) { + return ( + + Loading... + + ); + } + + if (data && data.companyAddress === null) { + return ( + + + No default address set + + + Set default shop address or channel overrides + + + If no address is set, invoices will not be generated + + + ); + } + + if (data && data.companyAddress) { + return ( + + + {data.companyAddress.companyName} + + + {data.companyAddress.streetAddress1} + + + {data.companyAddress.streetAddress2} + + + {data.companyAddress.postalCode} {data.companyAddress.city} + + + {data.companyAddress.country.country} + + + ); + } + + return null; +}; diff --git a/apps/invoices/src/modules/trpc/trpc-app-router.ts b/apps/invoices/src/modules/trpc/trpc-app-router.ts index 354df74..cdd706d 100644 --- a/apps/invoices/src/modules/trpc/trpc-app-router.ts +++ b/apps/invoices/src/modules/trpc/trpc-app-router.ts @@ -1,10 +1,12 @@ import { channelsRouter } from "../channels/channels.router"; import { router } from "./trpc-server"; -import { appConfigurationRouter } from "../app-configuration/app-configuration.router"; +import { shopInfoRouter } from "../shop-info/shop-info.router"; +import { appConfigurationRouter } from "../app-configuration/app-configuration-router"; export const appRouter = router({ channels: channelsRouter, appConfiguration: appConfigurationRouter, + shopInfo: shopInfoRouter, }); export type AppRouter = typeof appRouter; diff --git a/apps/invoices/src/modules/ui/AppSection.tsx b/apps/invoices/src/modules/ui/AppSection.tsx new file mode 100644 index 0000000..79ce08b --- /dev/null +++ b/apps/invoices/src/modules/ui/AppSection.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/invoices/src/modules/ui/app-columns-layout.tsx b/apps/invoices/src/modules/ui/app-columns-layout.tsx deleted file mode 100644 index 94ee503..0000000 --- a/apps/invoices/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({ - root: { - display: "grid", - gridTemplateColumns: "280px auto 280px", - alignItems: "start", - gap: 32, - maxWidth: 1180, - margin: "0 auto", - }, -}); - -type Props = PropsWithChildren<{}>; - -export const AppColumnsLayout = ({ children }: Props) => { - const styles = useStyles(); - - return
{children}
; -}; diff --git a/apps/invoices/src/pages/_app.tsx b/apps/invoices/src/pages/_app.tsx index dfb65fa..a272d00 100644 --- a/apps/invoices/src/pages/_app.tsx +++ b/apps/invoices/src/pages/_app.tsx @@ -1,41 +1,58 @@ -import "@saleor/apps-shared/src/globals.css"; +import "@saleor/macaw-ui/next/style"; import "../styles/globals.css"; + import { AppBridge, AppBridgeProvider } from "@saleor/app-sdk/app-bridge"; import { RoutePropagator } from "@saleor/app-sdk/app-bridge/next"; -import React, { useEffect } from "react"; +import React, { ReactElement } from "react"; import { AppProps } from "next/app"; -import { ThemeSynchronizer } from "../lib/theme-synchronizer"; -import { NoSSRWrapper } from "../lib/no-ssr-wrapper"; + +import { NoSSRWrapper } from "@saleor/apps-shared"; import { trpcClient } from "../modules/trpc/trpc-client"; -import { MacawThemeProvider } from "@saleor/apps-shared"; +import { Box, ThemeProvider } from "@saleor/macaw-ui/next"; + +import { NextPage } from "next"; +import { ThemeSynchronizer } from "../lib/theme-synchronizer"; /** * 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({ autoNotifyReady: false }) : undefined; +export let appBridgeInstance: AppBridge | undefined; -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 (typeof window !== "undefined" && !appBridgeInstance) { + appBridgeInstance = new AppBridge(); +} - if (jssStyles) { - jssStyles?.parentElement?.removeChild(jssStyles); - } - }, []); +/** + * Implementation of layout pattern + * https://nextjs.org/docs/basic-features/layouts#per-page-layouts + * + * In this app, there are pages inside the iframe, which will not use AppBridge etc, so they need + * to provider custom tree of wrappers + */ +export type NextPageWithLayoutOverwrite

= NextPage & { + overwriteLayout?: (page: ReactElement) => ReactElement; +}; + +type AppPropsWithLayout = AppProps & { + Component: NextPageWithLayoutOverwrite; +}; + +function NextApp({ Component, pageProps: { session, ...pageProps } }: AppPropsWithLayout) { + if (Component.overwriteLayout) { + return Component.overwriteLayout(); + } return ( - + - - + + + + ); diff --git a/apps/invoices/src/pages/api/webhooks/invoice-requested.ts b/apps/invoices/src/pages/api/webhooks/invoice-requested.ts index 752f391..5a06102 100644 --- a/apps/invoices/src/pages/api/webhooks/invoice-requested.ts +++ b/apps/invoices/src/pages/api/webhooks/invoice-requested.ts @@ -6,18 +6,26 @@ import { OrderPayloadFragment, } from "../../../../generated/graphql"; import { createClient } from "../../../lib/graphql"; -import { SaleorInvoiceUploader } from "../../../modules/invoice-uploader/saleor-invoice-uploader"; -import { InvoiceCreateNotifier } from "../../../modules/invoice-create-notifier/invoice-create-notifier"; +import { SaleorInvoiceUploader } from "../../../modules/invoices/invoice-uploader/saleor-invoice-uploader"; +import { InvoiceCreateNotifier } from "../../../modules/invoices/invoice-create-notifier/invoice-create-notifier"; import { InvoiceNumberGenerationStrategy, InvoiceNumberGenerator, -} from "../../../modules/invoice-number-generator/invoice-number-generator"; -import { MicroinvoiceInvoiceGenerator } from "../../../modules/invoice-generator/microinvoice/microinvoice-invoice-generator"; -import { hashInvoiceFilename } from "../../../modules/invoice-file-name/hash-invoice-filename"; -import { resolveTempPdfFileLocation } from "../../../modules/invoice-file-name/resolve-temp-pdf-file-location"; +} from "../../../modules/invoices/invoice-number-generator/invoice-number-generator"; +import { MicroinvoiceInvoiceGenerator } from "../../../modules/invoices/invoice-generator/microinvoice/microinvoice-invoice-generator"; +import { hashInvoiceFilename } from "../../../modules/invoices/invoice-file-name/hash-invoice-filename"; +import { resolveTempPdfFileLocation } from "../../../modules/invoices/invoice-file-name/resolve-temp-pdf-file-location"; import { createLogger } from "@saleor/apps-shared"; -import { GetAppConfigurationService } from "../../../modules/app-configuration/get-app-configuration.service"; import { SALEOR_API_URL_HEADER } from "@saleor/app-sdk/const"; +import { GetAppConfigurationV2Service } from "../../../modules/app-configuration/schema-v2/get-app-configuration.v2.service"; +import { ShopInfoFetcher } from "../../../modules/shop-info/shop-info-fetcher"; +import { z } from "zod"; +import { + AddressV2Schema, + AddressV2Shape, +} from "../../../modules/app-configuration/schema-v2/app-config-schema.v2"; +import { ConfigV1ToV2MigrationService } from "../../../modules/app-configuration/schema-v2/config-v1-to-v2-migration.service"; +import { shopInfoQueryToAddressShape } from "../../../modules/shop-info/shop-info-query-to-address-shape"; const OrderPayload = gql` fragment Address on Address { @@ -136,6 +144,13 @@ export const invoiceRequestedWebhook = new SaleorAsyncWebhook = async ( req, res, @@ -160,14 +175,6 @@ export const handler: NextWebhookApiHandler = a logger.debug({ invoiceName }, "Generated invoice name"); - if (!authData) { - logger.error("Auth data not found"); - - return res.status(403).json({ - error: `Could not find auth data. Check if app is installed.`, - }); - } - try { const client = createClient(authData.saleorApiUrl, async () => Promise.resolve({ token: authData.token }) @@ -182,17 +189,39 @@ export const handler: NextWebhookApiHandler = a logger.debug({ tempPdfLocation }, "Resolved PDF location for temporary files"); - const appConfig = await new GetAppConfigurationService({ + let appConfigV2 = await new GetAppConfigurationV2Service({ saleorApiUrl: authData.saleorApiUrl, apiClient: client, }).getConfiguration(); + /** + * MIGRATION CODE START - remove when metadata migrated + */ + if (!appConfigV2) { + const migrationService = new ConfigV1ToV2MigrationService(client, authData.saleorApiUrl); + + appConfigV2 = await migrationService.migrate(); + } + /** + * MIGRATION CODE END + */ + + const address: AddressV2Shape | null = + appConfigV2.getChannelsOverrides()[order.channel.slug] ?? + (await new ShopInfoFetcher(client).fetchShopInfo().then(shopInfoQueryToAddressShape)); + + if (!address) { + // todo disable webhook + + return res.status(200).end("App not configured"); + } + await new MicroinvoiceInvoiceGenerator() .generate({ order, invoiceNumber: invoiceName, filename: tempPdfLocation, - companyAddressData: appConfig.shopConfigPerChannel[order.channel.slug]?.address, + companyAddressData: address, }) .catch((err) => { logger.error(err, "Error generating invoice"); diff --git a/apps/invoices/src/pages/configuration.tsx b/apps/invoices/src/pages/configuration.tsx index 0937fa5..27db92d 100644 --- a/apps/invoices/src/pages/configuration.tsx +++ b/apps/invoices/src/pages/configuration.tsx @@ -1,31 +1,9 @@ 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 { useRouter } from "next/router"; -import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge"; +import React from "react"; +import { AppConfigView } from "../modules/app-configuration/views/app-config.view"; const ConfigurationPage: NextPage = () => { - const channels = trpcClient.channels.fetch.useQuery(); - const router = useRouter(); - - const { appBridge, appBridgeState } = useAppBridge(); - - useEffect(() => { - if (channels.isFetched && appBridge && !appBridgeState?.ready) { - if (appBridge && channels.isFetched) { - appBridge.dispatch(actions.NotifyReady()); - } - } - }, [channels.isFetched, appBridge, appBridgeState?.ready]); - - useEffect(() => { - if (channels.isSuccess && channels.data.length === 0) { - router.push("/not-ready"); - } - }, [channels.data, channels.isSuccess]); - - return ; + return ; }; export default ConfigurationPage; diff --git a/apps/invoices/src/pages/configuration/[channel].tsx b/apps/invoices/src/pages/configuration/[channel].tsx new file mode 100644 index 0000000..d011565 --- /dev/null +++ b/apps/invoices/src/pages/configuration/[channel].tsx @@ -0,0 +1,8 @@ +import { NextPage } from "next"; +import { ChannelConfigView } from "../../modules/app-configuration/views/channel-config.view"; + +const ChannelConfigPage: NextPage = () => { + return ; +}; + +export default ChannelConfigPage; diff --git a/apps/invoices/src/pages/index.tsx b/apps/invoices/src/pages/index.tsx index 536b7b3..2e5c110 100644 --- a/apps/invoices/src/pages/index.tsx +++ b/apps/invoices/src/pages/index.tsx @@ -4,6 +4,7 @@ import { useEffect } from "react"; import { useIsMounted } from "usehooks-ts"; import { useRouter } from "next/router"; import { isInIframe } from "@saleor/apps-shared"; +import { Box, Text } from "@saleor/macaw-ui/next"; const IndexPage: NextPage = () => { const { appBridgeState } = useAppBridge(); @@ -21,11 +22,16 @@ const IndexPage: NextPage = () => { } return ( -

-

Saleor Invoices

-

This is Saleor App that allows invoices generation

-

Install app in your Saleor instance and open in with Dashboard

-
+ + + Saleor Invoices + + This is Saleor App that allows invoices generation + + Install app in your Saleor instance and open in with Dashboard{" "} + or check it on Github + + ); }; diff --git a/apps/invoices/src/pages/not-ready.tsx b/apps/invoices/src/pages/not-ready.tsx deleted file mode 100644 index e9c73bc..0000000 --- a/apps/invoices/src/pages/not-ready.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { AlertBase, Button } from "@saleor/macaw-ui"; -import React from "react"; -import { Typography } from "@material-ui/core"; -import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge"; - -const NotReadyPage = () => { - const { appBridge } = useAppBridge(); - - return ( -
-

Saleor Invoices App

- - - App can not be used - - - To configure Invoices App you need to create at least 1 channel - - - -
- ); -}; - -export default NotReadyPage; diff --git a/apps/invoices/src/styles/globals.css b/apps/invoices/src/styles/globals.css index 220e555..2366a15 100644 --- a/apps/invoices/src/styles/globals.css +++ b/apps/invoices/src/styles/globals.css @@ -1,10 +1,9 @@ body { font-family: Inter, -apple-system, "system-ui", "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; - color: #111; - padding-top: 32px; } a { cursor: pointer; + text-decoration: none; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f820209..ea685da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -626,15 +626,9 @@ importers: apps/invoices: dependencies: - '@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) + '@hookform/resolvers': + specifier: ^3.1.0 + version: 3.1.0(react-hook-form@7.43.1) '@saleor/app-sdk': specifier: 0.37.3 version: 0.37.3(next@13.3.0)(react-dom@18.2.0)(react@18.2.0) @@ -642,8 +636,8 @@ importers: specifier: workspace:* version: link:../../packages/shared '@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.80 + version: 0.8.0-pre.80(@types/react@18.0.27)(react-dom@18.2.0)(react@18.2.0) '@sentry/nextjs': specifier: ^7.36.0 version: 7.36.0(next@13.3.0)(react@18.2.0) @@ -4091,6 +4085,14 @@ packages: react-hook-form: 7.43.1(react@18.2.0) dev: false + /@hookform/resolvers@3.1.0(react-hook-form@7.43.1): + resolution: {integrity: sha512-z0A8K+Nxq+f83Whm/ajlwE6VtQlp/yPHZnXw7XWVPIGm1Vx0QV8KThU3BpbBRfAZ7/dYqCKKBNnQh85BkmBKkA==} + peerDependencies: + react-hook-form: ^7.0.0 + dependencies: + react-hook-form: 7.43.1(react@18.2.0) + dev: false + /@hookform/resolvers@3.1.0(react-hook-form@7.43.9): resolution: {integrity: sha512-z0A8K+Nxq+f83Whm/ajlwE6VtQlp/yPHZnXw7XWVPIGm1Vx0QV8KThU3BpbBRfAZ7/dYqCKKBNnQh85BkmBKkA==} peerDependencies: @@ -5004,6 +5006,34 @@ packages: - '@types/react' dev: false + /@radix-ui/react-popover@1.0.5(@types/react@18.0.27)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-GRHZ8yD12MrN2NLobHPE8Rb5uHTxd9x372DE9PPNnBjpczAQHcZ5ne0KXG4xpf+RDdXSzdLv9ym6mYJCDTaUZg==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + dependencies: + '@babel/runtime': 7.20.13 + '@radix-ui/primitive': 1.0.0 + '@radix-ui/react-compose-refs': 1.0.0(react@18.2.0) + '@radix-ui/react-context': 1.0.0(react@18.2.0) + '@radix-ui/react-dismissable-layer': 1.0.3(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-focus-guards': 1.0.0(react@18.2.0) + '@radix-ui/react-focus-scope': 1.0.2(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-id': 1.0.0(react@18.2.0) + '@radix-ui/react-popper': 1.1.1(@types/react@18.0.27)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-portal': 1.0.2(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-presence': 1.0.0(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.2(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-slot': 1.0.1(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.0.0(react@18.2.0) + aria-hidden: 1.2.2(@types/react@18.0.27)(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-remove-scroll: 2.5.5(@types/react@18.0.27)(react@18.2.0) + transitivePeerDependencies: + - '@types/react' + dev: false + /@radix-ui/react-popover@1.0.5(@types/react@18.0.38)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-GRHZ8yD12MrN2NLobHPE8Rb5uHTxd9x372DE9PPNnBjpczAQHcZ5ne0KXG4xpf+RDdXSzdLv9ym6mYJCDTaUZg==} peerDependencies: @@ -5248,6 +5278,31 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /@radix-ui/react-tooltip@1.0.5(@types/react@18.0.27)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-cDKVcfzyO6PpckZekODJZDe5ZxZ2fCZlzKzTmPhe4mX9qTHRfLcKgqb0OKf22xLwDequ2tVleim+ZYx3rabD5w==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + dependencies: + '@babel/runtime': 7.20.13 + '@radix-ui/primitive': 1.0.0 + '@radix-ui/react-compose-refs': 1.0.0(react@18.2.0) + '@radix-ui/react-context': 1.0.0(react@18.2.0) + '@radix-ui/react-dismissable-layer': 1.0.3(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-id': 1.0.0(react@18.2.0) + '@radix-ui/react-popper': 1.1.1(@types/react@18.0.27)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-portal': 1.0.2(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-presence': 1.0.0(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.2(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-slot': 1.0.1(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.0.0(react@18.2.0) + '@radix-ui/react-visually-hidden': 1.0.2(react-dom@18.2.0)(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + transitivePeerDependencies: + - '@types/react' + dev: false + /@radix-ui/react-tooltip@1.0.5(@types/react@18.0.38)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-cDKVcfzyO6PpckZekODJZDe5ZxZ2fCZlzKzTmPhe4mX9qTHRfLcKgqb0OKf22xLwDequ2tVleim+ZYx3rabD5w==} peerDependencies: @@ -5537,6 +5592,38 @@ packages: - '@types/react' dev: false + /@saleor/macaw-ui@0.8.0-pre.80(@types/react@18.0.27)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-ecP65upR3P8NtE/ZaznWP4Lk/BVTiHr33K/g0YH3535hdfevaE8WibWeQ7wS6g41pia1OfDYGiW0rTxXgmsS4g==} + engines: {node: '>=16 <19', pnpm: '>=8'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@dessert-box/react': 0.4.0(react@18.2.0) + '@floating-ui/react-dom-interactions': 0.5.0(@types/react@18.0.27)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-accordion': 1.1.1(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-checkbox': 1.0.3(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-dialog': 1.0.3(@types/react@18.0.27)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-dropdown-menu': 2.0.4(@types/react@18.0.27)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-popover': 1.0.5(@types/react@18.0.27)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-portal': 1.0.2(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-radio-group': 1.1.2(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-select': 1.2.1(@types/react@18.0.27)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-toggle': 1.0.2(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-tooltip': 1.0.5(@types/react@18.0.27)(react-dom@18.2.0)(react@18.2.0) + '@vanilla-extract/css-utils': 0.1.3 + clsx: 1.2.1 + downshift: 6.1.12(react@18.2.0) + downshift7: /downshift@7.6.0(react@18.2.0) + lodash: 4.17.21 + lodash-es: 4.17.21 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-inlinesvg: 3.0.1(react@18.2.0) + transitivePeerDependencies: + - '@types/react' + dev: false + /@selderee/plugin-htmlparser2@0.10.0: resolution: {integrity: sha512-gW69MEamZ4wk1OsOq1nG1jcyhXIQcnrsX5JwixVw/9xaiav8TCyjESAruu1Rz9yyInhgBXxkNwMeygKnN2uxNA==} dependencies: