Invoices redesign to Macaw Next (#445)
* Removed old macaw and material * Add trpc router that fetches shop address info * Config page layout with header and address * display default addres * Draft channels list * add v2 config model * Render address overrides * Render address overrides ui * connect address form * reset address form * implement removing conifg * connect dashboard sites * update webhook * Add ConfigV1 to ConfigV2 transformer * Cleanup v1 router, abstract v2 * Implement runtime migrations * Implement migration service in controllers * test for configuration service * test for app cofnig * draft test for router * refactor webhook * Unify Address schema to single one * Extractr data fetching from form
This commit is contained in:
parent
a3d87be3f4
commit
1b3680465f
61 changed files with 1553 additions and 792 deletions
|
@ -17,12 +17,9 @@
|
||||||
"schemaVersion": "3.10"
|
"schemaVersion": "3.10"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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/app-sdk": "0.37.3",
|
||||||
"@saleor/apps-shared": "workspace:*",
|
"@saleor/apps-shared": "workspace:*",
|
||||||
"@saleor/macaw-ui": "^0.7.2",
|
"@saleor/macaw-ui": "^0.8.0-pre.80",
|
||||||
"@sentry/nextjs": "^7.36.0",
|
"@sentry/nextjs": "^7.36.0",
|
||||||
"@tanstack/react-query": "^4.24.4",
|
"@tanstack/react-query": "^4.24.4",
|
||||||
"@trpc/client": "^10.10.0",
|
"@trpc/client": "^10.10.0",
|
||||||
|
@ -44,7 +41,8 @@
|
||||||
"tiny-invariant": "^1.3.1",
|
"tiny-invariant": "^1.3.1",
|
||||||
"urql": "^3.0.3",
|
"urql": "^3.0.3",
|
||||||
"usehooks-ts": "^2.9.1",
|
"usehooks-ts": "^2.9.1",
|
||||||
"zod": "^3.20.2"
|
"zod": "^3.20.2",
|
||||||
|
"@hookform/resolvers": "^3.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@graphql-codegen/cli": "3.2.2",
|
"@graphql-codegen/cli": "3.2.2",
|
||||||
|
|
|
@ -1,13 +0,0 @@
|
||||||
<svg width="19" height="19" viewBox="0 0 19 19" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M16 0.375C16.8975 0.375 17.625 1.10254 17.625 2L17.625 3.89063C17.625 4.2358 17.3452 4.51563 17 4.51562C16.6548 4.51562 16.375 4.2358 16.375 3.89062L16.375 2C16.375 1.79289 16.2071 1.625 16 1.625H2C1.79289 1.625 1.625 1.79289 1.625 2V13C1.625 13.2071 1.79289 13.375 2 13.375H5C5.34518 13.375 5.625 13.6548 5.625 14C5.625 14.3452 5.34518 14.625 5 14.625H2C1.10254 14.625 0.375 13.8975 0.375 13V2C0.375 1.10254 1.10254 0.375 2 0.375H16Z"
|
|
||||||
fill="#fff"/>
|
|
||||||
<path d="M4 5C4.55228 5 5 4.55228 5 4C5 3.44772 4.55228 3 4 3C3.44771 3 3 3.44772 3 4C3 4.55228 3.44771 5 4 5Z"
|
|
||||||
fill="#fff"/>
|
|
||||||
<path d="M8 4C8 4.55228 7.55228 5 7 5C6.44771 5 6 4.55228 6 4C6 3.44772 6.44771 3 7 3C7.55228 3 8 3.44772 8 4Z"
|
|
||||||
fill="#fff"/>
|
|
||||||
<path d="M13.625 10.375V10C13.625 9.65482 13.3452 9.375 13 9.375C12.6548 9.375 12.375 9.65482 12.375 10V10.3797C11.5359 10.4436 10.875 11.1446 10.875 12C10.875 12.8975 11.6025 13.625 12.5 13.625H13.5C13.7071 13.625 13.875 13.7929 13.875 14C13.875 14.2071 13.7071 14.375 13.5 14.375H11.5C11.1548 14.375 10.875 14.6548 10.875 15C10.875 15.3452 11.1548 15.625 11.5 15.625H12.375V16C12.375 16.3452 12.6548 16.625 13 16.625C13.3452 16.625 13.625 16.3452 13.625 16V15.6203C14.4641 15.5564 15.125 14.8554 15.125 14C15.125 13.1025 14.3975 12.375 13.5 12.375H12.5C12.2929 12.375 12.125 12.2071 12.125 12C12.125 11.7929 12.2929 11.625 12.5 11.625H14.5C14.8452 11.625 15.125 11.3452 15.125 11C15.125 10.6548 14.8452 10.375 14.5 10.375H13.625Z"
|
|
||||||
fill="#fff"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
|
||||||
d="M18.625 8.96838V17C18.625 17.8975 17.8975 18.625 17 18.625H9C8.10254 18.625 7.375 17.8975 7.375 17V7C7.375 6.10254 8.10254 5.375 9 5.375H14.6379C15.0181 5.375 15.3862 5.50828 15.6782 5.75164L18.0403 7.72002C18.4108 8.02876 18.625 8.48611 18.625 8.96838ZM8.625 7C8.625 6.79289 8.79289 6.625 9 6.625H13.875V8C13.875 8.89746 14.6025 9.625 15.5 9.625H17.375V17C17.375 17.2071 17.2071 17.375 17 17.375H9C8.79289 17.375 8.625 17.2071 8.625 17V7ZM16.8737 8.375H15.5C15.2929 8.375 15.125 8.20711 15.125 8V6.91773L16.8737 8.375Z"
|
|
||||||
fill="#fff"/>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 2.2 KiB |
|
@ -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"] => {
|
export const getMockAddress = (): SellerShopConfig["address"] => {
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -1,8 +0,0 @@
|
||||||
import React, { PropsWithChildren } from "react";
|
|
||||||
import dynamic from "next/dynamic";
|
|
||||||
|
|
||||||
const Wrapper = (props: PropsWithChildren<{}>) => <React.Fragment>{props.children}</React.Fragment>;
|
|
||||||
|
|
||||||
export const NoSSRWrapper = dynamic(() => Promise.resolve(Wrapper), {
|
|
||||||
ssr: false,
|
|
||||||
});
|
|
|
@ -1,33 +1,25 @@
|
||||||
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
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";
|
import { memo, useEffect } from "react";
|
||||||
|
|
||||||
/**
|
// todo move to shared
|
||||||
* Macaw-ui stores its theme mode in memory and local storage. To synchronize App with Dashboard,
|
export function ThemeSynchronizer() {
|
||||||
* Macaw must be informed about this change from AppBridge.
|
|
||||||
*
|
|
||||||
* If you are not using Macaw, you can remove this.
|
|
||||||
*/
|
|
||||||
function _ThemeSynchronizer() {
|
|
||||||
const { appBridgeState } = useAppBridge();
|
const { appBridgeState } = useAppBridge();
|
||||||
const { setTheme, themeType } = useTheme();
|
const { setTheme } = useTheme();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!setTheme || !appBridgeState?.theme) {
|
if (!setTheme || !appBridgeState?.theme) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (themeType !== appBridgeState?.theme) {
|
if (appBridgeState.theme === "light") {
|
||||||
setTheme(appBridgeState.theme);
|
setTheme("defaultLight");
|
||||||
/**
|
|
||||||
* 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);
|
|
||||||
}
|
}
|
||||||
}, [appBridgeState?.theme, setTheme, themeType]);
|
|
||||||
|
if (appBridgeState.theme === "dark") {
|
||||||
|
setTheme("defaultDark");
|
||||||
|
}
|
||||||
|
}, [appBridgeState?.theme, setTheme]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ThemeSynchronizer = memo(_ThemeSynchronizer);
|
|
||||||
|
|
|
@ -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;
|
||||||
|
};
|
|
@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -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,
|
|
||||||
};
|
|
|
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -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),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
),
|
|
||||||
});
|
|
|
@ -1,11 +0,0 @@
|
||||||
import { SellerAddress } from "./address";
|
|
||||||
|
|
||||||
export interface SellerShopConfig {
|
|
||||||
address: SellerAddress;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ShopConfigPerChannelSlug = Record<string, SellerShopConfig>;
|
|
||||||
|
|
||||||
export type AppConfig = {
|
|
||||||
shopConfigPerChannel: ShopConfigPerChannelSlug;
|
|
||||||
};
|
|
|
@ -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),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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());
|
||||||
|
}),
|
||||||
|
});
|
|
@ -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;
|
|
||||||
}),
|
|
||||||
});
|
|
|
@ -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<AppConfig>(
|
|
||||||
(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: {} }
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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 { Client, gql } from "urql";
|
||||||
import {
|
import {
|
||||||
FetchAppDetailsDocument,
|
FetchAppDetailsDocument,
|
||||||
|
@ -31,7 +35,9 @@ gql`
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export async function fetchAllMetadata(client: Client): Promise<MetadataEntry[]> {
|
export type SimpleGraphqlClient = Pick<Client, "mutation" | "query">;
|
||||||
|
|
||||||
|
export async function fetchAllMetadata(client: SimpleGraphqlClient): Promise<MetadataEntry[]> {
|
||||||
const { error, data } = await client
|
const { error, data } = await client
|
||||||
.query<FetchAppDetailsQuery>(FetchAppDetailsDocument, {})
|
.query<FetchAppDetailsQuery>(FetchAppDetailsDocument, {})
|
||||||
.toPromise();
|
.toPromise();
|
||||||
|
@ -43,7 +49,7 @@ export async function fetchAllMetadata(client: Client): Promise<MetadataEntry[]>
|
||||||
return data?.app?.privateMetadata.map((md) => ({ key: md.key, value: md.value })) || [];
|
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
|
// to update the metadata, ID is required
|
||||||
const { error: idQueryError, data: idQueryData } = await client
|
const { error: idQueryError, data: idQueryData } = await client
|
||||||
.query(FetchAppDetailsDocument, {})
|
.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.
|
* EncryptedMetadataManager gives you interface to manipulate metadata and cache values in memory.
|
||||||
* We recommend it for production, because all values are encrypted.
|
* We recommend it for production, because all values are encrypted.
|
||||||
|
|
|
@ -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<string, SellerShopConfig>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
* Remove when SchemaV1 is migrated to SchemaV2
|
||||||
|
*/
|
||||||
|
export type AppConfigV1 = {
|
||||||
|
shopConfigPerChannel: ShopConfigPerChannelSlug;
|
||||||
|
};
|
|
@ -1,17 +1,25 @@
|
||||||
import { AppConfig } from "./app-config";
|
import { AppConfigV1 } from "./app-config-v1";
|
||||||
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
|
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
* Remove when SchemaV1 is migrated to SchemaV2
|
||||||
|
*/
|
||||||
export interface AppConfigurator {
|
export interface AppConfigurator {
|
||||||
setConfig(config: AppConfig): Promise<void>;
|
setConfig(config: AppConfigV1): Promise<void>;
|
||||||
getConfig(): Promise<AppConfig | undefined>;
|
getConfig(): Promise<AppConfigV1 | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PrivateMetadataAppConfigurator implements AppConfigurator {
|
/**
|
||||||
|
* @deprecated
|
||||||
|
* Remove when SchemaV1 is migrated to SchemaV2
|
||||||
|
*/
|
||||||
|
export class PrivateMetadataAppConfiguratorV1 implements AppConfigurator {
|
||||||
private metadataKey = "app-config";
|
private metadataKey = "app-config";
|
||||||
|
|
||||||
constructor(private metadataManager: SettingsManager, private saleorApiUrl: string) {}
|
constructor(private metadataManager: SettingsManager, private saleorApiUrl: string) {}
|
||||||
|
|
||||||
getConfig(): Promise<AppConfig | undefined> {
|
getConfig(): Promise<AppConfigV1 | undefined> {
|
||||||
return this.metadataManager.get(this.metadataKey, this.saleorApiUrl).then((data) => {
|
return this.metadataManager.get(this.metadataKey, this.saleorApiUrl).then((data) => {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return data;
|
return data;
|
||||||
|
@ -25,7 +33,7 @@ export class PrivateMetadataAppConfigurator implements AppConfigurator {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setConfig(config: AppConfig): Promise<void> {
|
setConfig(config: AppConfigV1): Promise<void> {
|
||||||
return this.metadataManager.set({
|
return this.metadataManager.set({
|
||||||
key: this.metadataKey,
|
key: this.metadataKey,
|
||||||
value: JSON.stringify(config),
|
value: JSON.stringify(config),
|
|
@ -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<typeof AppConfigV2Schema>;
|
||||||
|
export type AddressV2Shape = z.infer<typeof AddressV2Schema>;
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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<typeof AddressV2Schema>) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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<AppConfigV2> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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: "",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,79 +1,196 @@
|
||||||
import { SellerShopConfig } from "../app-config";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
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((theme) => ({
|
import React, { useCallback, useEffect } from "react";
|
||||||
field: {
|
import { Box, Button, Input, Text } from "@saleor/macaw-ui/next";
|
||||||
marginBottom: 20,
|
import { SellerAddress } from "../address";
|
||||||
},
|
import { trpcClient } from "../../trpc/trpc-client";
|
||||||
form: {
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
padding: 20,
|
import { z } from "zod";
|
||||||
paddingTop: 0,
|
import { useDashboardNotification } from "@saleor/apps-shared";
|
||||||
},
|
import { useRouter } from "next/router";
|
||||||
channelName: {
|
import { AddressV2Schema, AddressV2Shape } from "../schema-v2/app-config-schema.v2";
|
||||||
cursor: "pointer",
|
|
||||||
borderBottom: `2px solid ${theme.palette.secondary.main}`,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
channelSlug: string;
|
channelSlug: string;
|
||||||
channelName: string;
|
|
||||||
channelID: string;
|
|
||||||
onSubmit(data: SellerShopConfig["address"]): Promise<void>;
|
|
||||||
initialData?: SellerShopConfig["address"] | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AddressForm = (props: Props) => {
|
type InnerFormProps = {
|
||||||
const { register, handleSubmit } = useForm<SellerShopConfig["address"]>({
|
address: AddressV2Shape;
|
||||||
defaultValues: props.initialData ?? undefined,
|
onSubmit(fields: AddressV2Shape): Promise<void>;
|
||||||
|
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<typeof FormSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Divide fields into blocks to make it easier to create a form layout
|
||||||
|
*/
|
||||||
|
const fieldsBlock1: Array<keyof FormSchemaType> = [
|
||||||
|
"companyName",
|
||||||
|
"streetAddress1",
|
||||||
|
"streetAddress2",
|
||||||
|
];
|
||||||
|
const fieldsBlock2: Array<keyof FormSchemaType> = ["postalCode", "city"];
|
||||||
|
const fieldsBlock3: Array<keyof FormSchemaType> = ["cityArea", "country", "countryArea"];
|
||||||
|
|
||||||
|
const fieldLabels: Record<keyof FormSchemaType, string> = {
|
||||||
|
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<SellerAddress>({
|
||||||
|
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 (
|
return (
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit((data, event) => {
|
onSubmit={handleSubmit((data, event) => {
|
||||||
props.onSubmit(data);
|
return props.onSubmit(data);
|
||||||
})}
|
})}
|
||||||
className={styles.form}
|
|
||||||
>
|
>
|
||||||
<Typography component="h3" variant="h3" paragraph>
|
<Box display={"grid"} gap={6} marginBottom={12}>
|
||||||
Configure
|
{fieldsBlock1.map((fieldName) => (
|
||||||
<span onClick={handleChannelNameClick} className={styles.channelName}>
|
<Controller
|
||||||
{` ${props.channelName} `}
|
key={fieldName}
|
||||||
</span>
|
control={control}
|
||||||
channel:
|
render={({ field: { onChange, onBlur, value, name, ref } }) => {
|
||||||
</Typography>
|
return (
|
||||||
<TextField label="Company Name" {...CommonFieldProps} {...register("companyName")} />
|
<Input
|
||||||
<TextField label="Street Address 1" {...CommonFieldProps} {...register("streetAddress1")} />
|
onChange={onChange}
|
||||||
<TextField {...CommonFieldProps} label="Street Address 2" {...register("streetAddress2")} />
|
value={value}
|
||||||
<div style={{ display: "grid", gap: 20, gridTemplateColumns: "1fr 2fr" }}>
|
error={!!formState.errors[fieldName]}
|
||||||
<TextField {...CommonFieldProps} label="Postal Code" {...register("postalCode")} />
|
label={fieldLabels[fieldName]}
|
||||||
<TextField {...CommonFieldProps} label="City" {...register("city")} />
|
onBlur={onBlur}
|
||||||
</div>
|
name={name}
|
||||||
<TextField {...CommonFieldProps} label="City Area" {...register("cityArea")} />
|
ref={ref}
|
||||||
<TextField {...CommonFieldProps} label="Country" {...register("country")} />
|
/>
|
||||||
<TextField label="Country Area" {...CommonFieldProps} {...register("countryArea")} />
|
);
|
||||||
<Button type="submit" fullWidth variant="primary">
|
}}
|
||||||
Save channel configuration
|
name={fieldName}
|
||||||
</Button>
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Box display={"grid"} gridTemplateColumns={2} gap={6}>
|
||||||
|
{fieldsBlock2.map((fieldName) => (
|
||||||
|
<Controller
|
||||||
|
key={fieldName}
|
||||||
|
control={control}
|
||||||
|
render={({ field: { onChange, onBlur, value, name, ref } }) => {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
onChange={onChange}
|
||||||
|
value={value}
|
||||||
|
error={!!formState.errors[fieldName]}
|
||||||
|
label={fieldLabels[fieldName]}
|
||||||
|
onBlur={onBlur}
|
||||||
|
name={name}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
name={fieldName}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{fieldsBlock3.map((fieldName) => (
|
||||||
|
<Controller
|
||||||
|
key={fieldName}
|
||||||
|
control={control}
|
||||||
|
render={({ field: { onChange, onBlur, value, name, ref } }) => {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
onChange={onChange}
|
||||||
|
value={value}
|
||||||
|
error={!!formState.errors[fieldName]}
|
||||||
|
label={fieldLabels[fieldName]}
|
||||||
|
onBlur={onBlur}
|
||||||
|
name={name}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
name={fieldName}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
<Box display={"grid"} justifyContent={"flex-end"} gap={4} gridAutoFlow={"column"}>
|
||||||
|
<Button
|
||||||
|
variant="tertiary"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
props.onCancel();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text color={"textNeutralSubdued"}>Cancel</Text>
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="primary">
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 <Text color={"textNeutralSubdued"}>Loading</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AddressForm
|
||||||
|
onCancel={onCancelHandler}
|
||||||
|
onSubmit={submitHandler}
|
||||||
|
address={channelOverrideConfigQuery.data[props.channelSlug]}
|
||||||
|
channelSlug={props.channelSlug}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
@ -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<string | null>(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 <div>Error. No channel available</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppColumnsLayout>
|
|
||||||
<ChannelsList
|
|
||||||
channels={channels.data}
|
|
||||||
activeChannelSlug={activeChannel.slug}
|
|
||||||
onChannelClick={(slug) => {
|
|
||||||
setActiveChannelSlug(slug);
|
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{activeChannel ? (
|
|
||||||
<Paper elevation={0} className={styles.formContainer}>
|
|
||||||
<AddressForm
|
|
||||||
channelID={activeChannel.id}
|
|
||||||
key={activeChannelSlug}
|
|
||||||
channelSlug={activeChannel.slug}
|
|
||||||
onSubmit={async (data) => {
|
|
||||||
const newConfig = AppConfigContainer.setChannelAddress(configurationData)(
|
|
||||||
activeChannel.slug
|
|
||||||
)(data);
|
|
||||||
|
|
||||||
mutate(newConfig);
|
|
||||||
}}
|
|
||||||
initialData={AppConfigContainer.getChannelAddress(configurationData)(
|
|
||||||
activeChannel.slug
|
|
||||||
)}
|
|
||||||
channelName={activeChannel?.name ?? activeChannelSlug}
|
|
||||||
/>
|
|
||||||
{saveError && <span>{saveError.message}</span>}
|
|
||||||
</Paper>
|
|
||||||
) : null}
|
|
||||||
<Paper elevation={0} className={styles.instructionsContainer}>
|
|
||||||
<Typography paragraph variant="h4">
|
|
||||||
Generate invoices for orders in your shop
|
|
||||||
</Typography>
|
|
||||||
<Typography paragraph>
|
|
||||||
Shop data on the invoice an be configured per channel. If not set it will use shop data
|
|
||||||
from{" "}
|
|
||||||
<Link
|
|
||||||
onClick={() => {
|
|
||||||
appBridge?.dispatch(
|
|
||||||
actions.Redirect({
|
|
||||||
to: "/site-settings",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
the configuration
|
|
||||||
</Link>
|
|
||||||
</Typography>
|
|
||||||
<Typography>
|
|
||||||
Go to{" "}
|
|
||||||
<Link
|
|
||||||
onClick={() => {
|
|
||||||
appBridge?.dispatch(
|
|
||||||
actions.Redirect({
|
|
||||||
to: "/orders",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Orders
|
|
||||||
</Link>{" "}
|
|
||||||
and open any Order. Then click <strong>Invoices -{">"} Generate</strong>. Invoice will be
|
|
||||||
added to the order page
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
</AppColumnsLayout>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -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 (
|
|
||||||
<OffsettedList gridTemplate={["1fr"]}>
|
|
||||||
<OffsettedListHeader>
|
|
||||||
<Typography variant="h3" paragraph>
|
|
||||||
Available channels
|
|
||||||
</Typography>
|
|
||||||
</OffsettedListHeader>
|
|
||||||
<OffsettedListBody>
|
|
||||||
{channels.map((c) => {
|
|
||||||
return (
|
|
||||||
<OffsettedListItem
|
|
||||||
className={clsx(styles.listItem, {
|
|
||||||
[styles.listItemActive]: c.slug === activeChannelSlug,
|
|
||||||
})}
|
|
||||||
key={c.slug}
|
|
||||||
onClick={() => {
|
|
||||||
onChannelClick(c.slug);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<OffsettedListItemCell>
|
|
||||||
{c.name}
|
|
||||||
<Typography variant="caption" className={styles.channelSlug}>
|
|
||||||
{c.slug}
|
|
||||||
</Typography>
|
|
||||||
</OffsettedListItemCell>
|
|
||||||
</OffsettedListItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</OffsettedListBody>
|
|
||||||
</OffsettedList>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -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 (
|
||||||
|
<Box>
|
||||||
|
<Box
|
||||||
|
display={"grid"}
|
||||||
|
justifyContent={"space-between"}
|
||||||
|
__gridTemplateColumns={"400px 400px"}
|
||||||
|
gap={13}
|
||||||
|
__marginBottom={"200px"}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Text as={"h1"} variant={"hero"} marginBottom={8}>
|
||||||
|
Configuration
|
||||||
|
</Text>
|
||||||
|
<Text as={"p"} marginBottom={4}>
|
||||||
|
The Invoices App will generate invoices for each order, for which{" "}
|
||||||
|
<code>INVOICE_REQUESTED</code> event will be triggered
|
||||||
|
</Text>
|
||||||
|
<Text as={"p"} marginBottom={4}>
|
||||||
|
By default it will use{" "}
|
||||||
|
<a
|
||||||
|
href={"#"}
|
||||||
|
onClick={() => {
|
||||||
|
appBridge?.dispatch(
|
||||||
|
actions.Redirect({
|
||||||
|
to: "/site-settings",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
site settings
|
||||||
|
</a>{" "}
|
||||||
|
address, but each channel can be configured separately
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<DefaultShopAddress />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<AppSection
|
||||||
|
includePadding={true}
|
||||||
|
heading={"Shop address per channel"}
|
||||||
|
mainContent={<PerChannelConfigList />}
|
||||||
|
sideContent={
|
||||||
|
<Text>
|
||||||
|
Configure custom billing address for each channel. If not set, default shop address will
|
||||||
|
be used
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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 (
|
||||||
|
<Box>
|
||||||
|
<Box __marginBottom={"100px"}>
|
||||||
|
<Box marginBottom={8} display={"flex"} alignItems={"center"}>
|
||||||
|
<Text color={"textNeutralSubdued"}>Configuration</Text>
|
||||||
|
<ChevronRightIcon color={"textNeutralSubdued"} />
|
||||||
|
<Text color={"textNeutralSubdued"}>Edit channel</Text>
|
||||||
|
<ChevronRightIcon color={"textNeutralSubdued"} />
|
||||||
|
<Text>{channel}</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<AppSection
|
||||||
|
includePadding={true}
|
||||||
|
heading={"Shop address per channel"}
|
||||||
|
mainContent={<ConnectedAddressForm channelSlug={channel as string} />}
|
||||||
|
sideContent={
|
||||||
|
<Box>
|
||||||
|
<Text marginBottom={8} as={"p"}>
|
||||||
|
Set custom billing address for <Text variant={"bodyStrong"}>{channel}</Text> channel.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
variant={"secondary"}
|
||||||
|
onClick={() => {
|
||||||
|
mutateAsync({ channelSlug: channel as string }).then(() => {
|
||||||
|
notifySuccess("Success", "Custom address configuration removed");
|
||||||
|
push("/configuration");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove and set to default
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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 = (
|
||||||
|
<Chip __display={"inline-block"} size={"large"}>
|
||||||
|
<Text size={"small"} color={"textNeutralSubdued"}>
|
||||||
|
Default
|
||||||
|
</Text>
|
||||||
|
</Chip>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const PerChannelConfigList = () => {
|
||||||
|
const shopChannelsQuery = trpcClient.channels.fetch.useQuery();
|
||||||
|
const channelsOverridesQuery = trpcClient.appConfiguration.fetchChannelsOverrides.useQuery();
|
||||||
|
|
||||||
|
const { push } = useRouter();
|
||||||
|
|
||||||
|
if (shopChannelsQuery.isLoading || channelsOverridesQuery.isLoading) {
|
||||||
|
return <Text color={"textNeutralSubdued"}>Loading...</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Box>
|
||||||
|
<Text size={"small"} as={"p"}>
|
||||||
|
{address.companyName}
|
||||||
|
</Text>
|
||||||
|
<Text size={"small"} as={"p"}>
|
||||||
|
{address.streetAddress1}
|
||||||
|
</Text>
|
||||||
|
<Text size={"small"} as={"p"}>
|
||||||
|
{address.streetAddress2}
|
||||||
|
</Text>
|
||||||
|
<Text size={"small"}>
|
||||||
|
{address.postalCode} {address.city}
|
||||||
|
</Text>
|
||||||
|
<Text size={"small"} as={"p"}>
|
||||||
|
{address.country}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return defaultAddressChip;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderActionButtonAddress = (slug: string) => {
|
||||||
|
const overridesDataRecord = channelsOverridesQuery.data;
|
||||||
|
|
||||||
|
if (!overridesDataRecord) {
|
||||||
|
return null; // todo should throw
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant={"tertiary"}
|
||||||
|
onClick={() => {
|
||||||
|
push(`/configuration/${slug}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text color={"textNeutralSubdued"} size={"small"}>
|
||||||
|
{overridesDataRecord[slug] ? "Edit" : "Set custom"}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box display={"grid"} gridTemplateColumns={3} marginBottom={8}>
|
||||||
|
<Text color={"textNeutralSubdued"} variant={"caption"} size={"small"}>
|
||||||
|
Channel
|
||||||
|
</Text>
|
||||||
|
<Text color={"textNeutralSubdued"} variant={"caption"} size={"small"}>
|
||||||
|
Address
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
{shopChannelsQuery.data?.map((channel) => (
|
||||||
|
<Box
|
||||||
|
key={channel.id}
|
||||||
|
display={"grid"}
|
||||||
|
gridTemplateColumns={3}
|
||||||
|
paddingY={4}
|
||||||
|
borderBottomStyle={"solid"}
|
||||||
|
borderBottomWidth={1}
|
||||||
|
borderColor={"neutralHighlight"}
|
||||||
|
>
|
||||||
|
<Text variant={"bodyStrong"}>{channel.name}</Text>
|
||||||
|
<Box>{renderChannelAddress(channel.slug)}</Box>
|
||||||
|
<Box marginLeft={"auto"}> {renderActionButtonAddress(channel.slug)}</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,5 +1,5 @@
|
||||||
import { Client, gql } from "urql";
|
import { Client, gql } from "urql";
|
||||||
import { InvoiceCreateDocument } from "../../../generated/graphql";
|
import { InvoiceCreateDocument } from "../../../../generated/graphql";
|
||||||
import { logger } from "@saleor/apps-shared";
|
import { logger } from "@saleor/apps-shared";
|
||||||
|
|
||||||
gql`
|
gql`
|
|
@ -1,5 +1,5 @@
|
||||||
import { OrderPayloadFragment } from "../../../generated/graphql";
|
import { OrderPayloadFragment } from "../../../../generated/graphql";
|
||||||
import { SellerShopConfig } from "../app-configuration/app-config";
|
import { SellerShopConfig } from "../../app-configuration/schema-v1/app-config-v1";
|
||||||
|
|
||||||
export interface InvoiceGenerator {
|
export interface InvoiceGenerator {
|
||||||
generate(input: {
|
generate(input: {
|
|
@ -3,8 +3,8 @@ import { MicroinvoiceInvoiceGenerator } from "./microinvoice-invoice-generator";
|
||||||
import { readFile } from "fs/promises";
|
import { readFile } from "fs/promises";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import rimraf from "rimraf";
|
import rimraf from "rimraf";
|
||||||
import { mockOrder } from "../../../fixtures/mock-order";
|
import { mockOrder } from "../../../../fixtures/mock-order";
|
||||||
import { getMockAddress } from "../../../fixtures/mock-address";
|
import { getMockAddress } from "../../../../fixtures/mock-address";
|
||||||
|
|
||||||
const dirToSet = process.env.TEMP_PDF_STORAGE_DIR as string;
|
const dirToSet = process.env.TEMP_PDF_STORAGE_DIR as string;
|
||||||
const filePath = join(dirToSet, "test-invoice.pdf");
|
const filePath = join(dirToSet, "test-invoice.pdf");
|
|
@ -1,6 +1,6 @@
|
||||||
import { InvoiceGenerator } from "../invoice-generator";
|
import { InvoiceGenerator } from "../invoice-generator";
|
||||||
import { Order, OrderPayloadFragment } from "../../../../generated/graphql";
|
import { Order, OrderPayloadFragment } from "../../../../../generated/graphql";
|
||||||
import { SellerShopConfig } from "../../app-configuration/app-config";
|
import { SellerShopConfig } from "../../../app-configuration/schema-v1/app-config-v1";
|
||||||
const Microinvoice = require("microinvoice");
|
const Microinvoice = require("microinvoice");
|
||||||
|
|
||||||
export class MicroinvoiceInvoiceGenerator implements InvoiceGenerator {
|
export class MicroinvoiceInvoiceGenerator implements InvoiceGenerator {
|
|
@ -1,4 +1,4 @@
|
||||||
import { OrderPayloadFragment } from "../../../generated/graphql";
|
import { OrderPayloadFragment } from "../../../../generated/graphql";
|
||||||
|
|
||||||
interface IInvoiceNumberGenerationStrategy {
|
interface IInvoiceNumberGenerationStrategy {
|
||||||
(order: OrderPayloadFragment): string;
|
(order: OrderPayloadFragment): string;
|
|
@ -1,7 +1,7 @@
|
||||||
import { InvoiceUploader } from "./invoice-uploader";
|
import { InvoiceUploader } from "./invoice-uploader";
|
||||||
import { Client, gql } from "urql";
|
import { Client, gql } from "urql";
|
||||||
import { readFile } from "fs/promises";
|
import { readFile } from "fs/promises";
|
||||||
import { FileUploadMutation } from "../../../generated/graphql";
|
import { FileUploadMutation } from "../../../../generated/graphql";
|
||||||
/**
|
/**
|
||||||
* Polyfill file because Node doesn't have it yet
|
* Polyfill file because Node doesn't have it yet
|
||||||
* https://github.com/nodejs/node/commit/916af4ef2d63fe936a369bcf87ee4f69ec7c67ce
|
* https://github.com/nodejs/node/commit/916af4ef2d63fe936a369bcf87ee4f69ec7c67ce
|
|
@ -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,
|
||||||
|
};
|
||||||
|
};
|
14
apps/invoices/src/modules/shop-info/shop-info.router.ts
Normal file
14
apps/invoices/src/modules/shop-info/shop-info.router.ts
Normal file
|
@ -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();
|
||||||
|
}),
|
||||||
|
});
|
|
@ -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 (
|
||||||
|
<Box>
|
||||||
|
<Box display={"flex"} justifyContent={"space-between"} marginBottom={8}>
|
||||||
|
<Text variant={"heading"}>Default address of the shop</Text>
|
||||||
|
<Button
|
||||||
|
size={"small"}
|
||||||
|
variant={"tertiary"}
|
||||||
|
onClick={() => {
|
||||||
|
appBridge?.dispatch(
|
||||||
|
actions.Redirect({
|
||||||
|
to: "/site-settings",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text color={"textNeutralSubdued"}>Edit</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
<Box>{children}</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DefaultShopAddress = () => {
|
||||||
|
const { data, isLoading, error, refetch } = trpcClient.shopInfo.fetchShopAddress.useQuery();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Wrapper>
|
||||||
|
<Text marginBottom={4} color={"textCriticalDefault"}>
|
||||||
|
Error while fetching shop address
|
||||||
|
</Text>
|
||||||
|
<Button onClick={() => refetch()}>Fetch again</Button>
|
||||||
|
</Wrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Wrapper>
|
||||||
|
<Text color={"textNeutralSubdued"}>Loading...</Text>
|
||||||
|
</Wrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data && data.companyAddress === null) {
|
||||||
|
return (
|
||||||
|
<Wrapper>
|
||||||
|
<Text as={"p"} variant={"bodyStrong"}>
|
||||||
|
No default address set
|
||||||
|
</Text>
|
||||||
|
<Text as={"p"} size={"small"} marginBottom={4}>
|
||||||
|
Set default shop address or channel overrides
|
||||||
|
</Text>
|
||||||
|
<Text as={"p"} color={"textCriticalDefault"}>
|
||||||
|
If no address is set, invoices will not be generated
|
||||||
|
</Text>
|
||||||
|
</Wrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data && data.companyAddress) {
|
||||||
|
return (
|
||||||
|
<Wrapper>
|
||||||
|
<Text size={"small"} as={"p"}>
|
||||||
|
{data.companyAddress.companyName}
|
||||||
|
</Text>
|
||||||
|
<Text size={"small"} as={"p"}>
|
||||||
|
{data.companyAddress.streetAddress1}
|
||||||
|
</Text>
|
||||||
|
<Text size={"small"} as={"p"}>
|
||||||
|
{data.companyAddress.streetAddress2}
|
||||||
|
</Text>
|
||||||
|
<Text size={"small"}>
|
||||||
|
{data.companyAddress.postalCode} {data.companyAddress.city}
|
||||||
|
</Text>
|
||||||
|
<Text size={"small"} as={"p"}>
|
||||||
|
{data.companyAddress.country.country}
|
||||||
|
</Text>
|
||||||
|
</Wrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
|
@ -1,10 +1,12 @@
|
||||||
import { channelsRouter } from "../channels/channels.router";
|
import { channelsRouter } from "../channels/channels.router";
|
||||||
import { router } from "./trpc-server";
|
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({
|
export const appRouter = router({
|
||||||
channels: channelsRouter,
|
channels: channelsRouter,
|
||||||
appConfiguration: appConfigurationRouter,
|
appConfiguration: appConfigurationRouter,
|
||||||
|
shopInfo: shopInfoRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
|
|
36
apps/invoices/src/modules/ui/AppSection.tsx
Normal file
36
apps/invoices/src/modules/ui/AppSection.tsx
Normal file
|
@ -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 (
|
||||||
|
<Box as="section" __gridTemplateColumns={"400px auto"} display={"grid"} gap={13} {...props}>
|
||||||
|
<Box>
|
||||||
|
<Text as="h2" variant={"heading"} size={"large"} marginBottom={4}>
|
||||||
|
{heading}
|
||||||
|
</Text>
|
||||||
|
{sideContent}
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
borderStyle={"solid"}
|
||||||
|
borderColor={"neutralPlain"}
|
||||||
|
borderWidth={1}
|
||||||
|
padding={includePadding ? 8 : 0}
|
||||||
|
borderRadius={4}
|
||||||
|
>
|
||||||
|
{mainContent}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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 <div className={styles.root}>{children}</div>;
|
|
||||||
};
|
|
|
@ -1,41 +1,58 @@
|
||||||
import "@saleor/apps-shared/src/globals.css";
|
import "@saleor/macaw-ui/next/style";
|
||||||
import "../styles/globals.css";
|
import "../styles/globals.css";
|
||||||
|
|
||||||
import { AppBridge, AppBridgeProvider } from "@saleor/app-sdk/app-bridge";
|
import { AppBridge, AppBridgeProvider } from "@saleor/app-sdk/app-bridge";
|
||||||
import { RoutePropagator } from "@saleor/app-sdk/app-bridge/next";
|
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 { 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 { 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.
|
* Ensure instance is a singleton.
|
||||||
* TODO: This is React 18 issue, consider hiding this workaround inside app-sdk
|
* TODO: This is React 18 issue, consider hiding this workaround inside app-sdk
|
||||||
*/
|
*/
|
||||||
export const appBridgeInstance =
|
export let appBridgeInstance: AppBridge | undefined;
|
||||||
typeof window !== "undefined" ? new AppBridge({ autoNotifyReady: false }) : undefined;
|
|
||||||
|
|
||||||
function NextApp({ Component, pageProps }: AppProps) {
|
if (typeof window !== "undefined" && !appBridgeInstance) {
|
||||||
/**
|
appBridgeInstance = new AppBridge();
|
||||||
* 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);
|
* 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<P = {}, IP = P> = NextPage<P, IP> & {
|
||||||
|
overwriteLayout?: (page: ReactElement) => ReactElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AppPropsWithLayout = AppProps & {
|
||||||
|
Component: NextPageWithLayoutOverwrite;
|
||||||
|
};
|
||||||
|
|
||||||
|
function NextApp({ Component, pageProps: { session, ...pageProps } }: AppPropsWithLayout) {
|
||||||
|
if (Component.overwriteLayout) {
|
||||||
|
return Component.overwriteLayout(<Component {...pageProps} />);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NoSSRWrapper>
|
<NoSSRWrapper>
|
||||||
<AppBridgeProvider appBridgeInstance={appBridgeInstance}>
|
<AppBridgeProvider appBridgeInstance={appBridgeInstance}>
|
||||||
<MacawThemeProvider>
|
<ThemeProvider defaultTheme="defaultLight">
|
||||||
<ThemeSynchronizer />
|
<ThemeSynchronizer />
|
||||||
<RoutePropagator />
|
<RoutePropagator />
|
||||||
<Component {...pageProps} />
|
<Box padding={8} __maxWidth={1440}>
|
||||||
</MacawThemeProvider>
|
<Component {...pageProps} />
|
||||||
|
</Box>
|
||||||
|
</ThemeProvider>
|
||||||
</AppBridgeProvider>
|
</AppBridgeProvider>
|
||||||
</NoSSRWrapper>
|
</NoSSRWrapper>
|
||||||
);
|
);
|
||||||
|
|
|
@ -6,18 +6,26 @@ import {
|
||||||
OrderPayloadFragment,
|
OrderPayloadFragment,
|
||||||
} from "../../../../generated/graphql";
|
} from "../../../../generated/graphql";
|
||||||
import { createClient } from "../../../lib/graphql";
|
import { createClient } from "../../../lib/graphql";
|
||||||
import { SaleorInvoiceUploader } from "../../../modules/invoice-uploader/saleor-invoice-uploader";
|
import { SaleorInvoiceUploader } from "../../../modules/invoices/invoice-uploader/saleor-invoice-uploader";
|
||||||
import { InvoiceCreateNotifier } from "../../../modules/invoice-create-notifier/invoice-create-notifier";
|
import { InvoiceCreateNotifier } from "../../../modules/invoices/invoice-create-notifier/invoice-create-notifier";
|
||||||
import {
|
import {
|
||||||
InvoiceNumberGenerationStrategy,
|
InvoiceNumberGenerationStrategy,
|
||||||
InvoiceNumberGenerator,
|
InvoiceNumberGenerator,
|
||||||
} from "../../../modules/invoice-number-generator/invoice-number-generator";
|
} from "../../../modules/invoices/invoice-number-generator/invoice-number-generator";
|
||||||
import { MicroinvoiceInvoiceGenerator } from "../../../modules/invoice-generator/microinvoice/microinvoice-invoice-generator";
|
import { MicroinvoiceInvoiceGenerator } from "../../../modules/invoices/invoice-generator/microinvoice/microinvoice-invoice-generator";
|
||||||
import { hashInvoiceFilename } from "../../../modules/invoice-file-name/hash-invoice-filename";
|
import { hashInvoiceFilename } from "../../../modules/invoices/invoice-file-name/hash-invoice-filename";
|
||||||
import { resolveTempPdfFileLocation } from "../../../modules/invoice-file-name/resolve-temp-pdf-file-location";
|
import { resolveTempPdfFileLocation } from "../../../modules/invoices/invoice-file-name/resolve-temp-pdf-file-location";
|
||||||
import { createLogger } from "@saleor/apps-shared";
|
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 { 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`
|
const OrderPayload = gql`
|
||||||
fragment Address on Address {
|
fragment Address on Address {
|
||||||
|
@ -136,6 +144,13 @@ export const invoiceRequestedWebhook = new SaleorAsyncWebhook<InvoiceRequestedPa
|
||||||
|
|
||||||
const invoiceNumberGenerator = new InvoiceNumberGenerator();
|
const invoiceNumberGenerator = new InvoiceNumberGenerator();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO
|
||||||
|
* Refactor - extract smaller pieces
|
||||||
|
* Test
|
||||||
|
* More logs
|
||||||
|
* Extract service
|
||||||
|
*/
|
||||||
export const handler: NextWebhookApiHandler<InvoiceRequestedPayloadFragment> = async (
|
export const handler: NextWebhookApiHandler<InvoiceRequestedPayloadFragment> = async (
|
||||||
req,
|
req,
|
||||||
res,
|
res,
|
||||||
|
@ -160,14 +175,6 @@ export const handler: NextWebhookApiHandler<InvoiceRequestedPayloadFragment> = a
|
||||||
|
|
||||||
logger.debug({ invoiceName }, "Generated invoice name");
|
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 {
|
try {
|
||||||
const client = createClient(authData.saleorApiUrl, async () =>
|
const client = createClient(authData.saleorApiUrl, async () =>
|
||||||
Promise.resolve({ token: authData.token })
|
Promise.resolve({ token: authData.token })
|
||||||
|
@ -182,17 +189,39 @@ export const handler: NextWebhookApiHandler<InvoiceRequestedPayloadFragment> = a
|
||||||
|
|
||||||
logger.debug({ tempPdfLocation }, "Resolved PDF location for temporary files");
|
logger.debug({ tempPdfLocation }, "Resolved PDF location for temporary files");
|
||||||
|
|
||||||
const appConfig = await new GetAppConfigurationService({
|
let appConfigV2 = await new GetAppConfigurationV2Service({
|
||||||
saleorApiUrl: authData.saleorApiUrl,
|
saleorApiUrl: authData.saleorApiUrl,
|
||||||
apiClient: client,
|
apiClient: client,
|
||||||
}).getConfiguration();
|
}).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()
|
await new MicroinvoiceInvoiceGenerator()
|
||||||
.generate({
|
.generate({
|
||||||
order,
|
order,
|
||||||
invoiceNumber: invoiceName,
|
invoiceNumber: invoiceName,
|
||||||
filename: tempPdfLocation,
|
filename: tempPdfLocation,
|
||||||
companyAddressData: appConfig.shopConfigPerChannel[order.channel.slug]?.address,
|
companyAddressData: address,
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
logger.error(err, "Error generating invoice");
|
logger.error(err, "Error generating invoice");
|
||||||
|
|
|
@ -1,31 +1,9 @@
|
||||||
import { NextPage } from "next";
|
import { NextPage } from "next";
|
||||||
import React, { useEffect } from "react";
|
import React from "react";
|
||||||
import { ChannelsConfiguration } from "../modules/app-configuration/ui/channels-configuration";
|
import { AppConfigView } from "../modules/app-configuration/views/app-config.view";
|
||||||
import { trpcClient } from "../modules/trpc/trpc-client";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
|
||||||
|
|
||||||
const ConfigurationPage: NextPage = () => {
|
const ConfigurationPage: NextPage = () => {
|
||||||
const channels = trpcClient.channels.fetch.useQuery();
|
return <AppConfigView />;
|
||||||
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 <ChannelsConfiguration />;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ConfigurationPage;
|
export default ConfigurationPage;
|
||||||
|
|
8
apps/invoices/src/pages/configuration/[channel].tsx
Normal file
8
apps/invoices/src/pages/configuration/[channel].tsx
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import { NextPage } from "next";
|
||||||
|
import { ChannelConfigView } from "../../modules/app-configuration/views/channel-config.view";
|
||||||
|
|
||||||
|
const ChannelConfigPage: NextPage = () => {
|
||||||
|
return <ChannelConfigView />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChannelConfigPage;
|
|
@ -4,6 +4,7 @@ import { useEffect } from "react";
|
||||||
import { useIsMounted } from "usehooks-ts";
|
import { useIsMounted } from "usehooks-ts";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { isInIframe } from "@saleor/apps-shared";
|
import { isInIframe } from "@saleor/apps-shared";
|
||||||
|
import { Box, Text } from "@saleor/macaw-ui/next";
|
||||||
|
|
||||||
const IndexPage: NextPage = () => {
|
const IndexPage: NextPage = () => {
|
||||||
const { appBridgeState } = useAppBridge();
|
const { appBridgeState } = useAppBridge();
|
||||||
|
@ -21,11 +22,16 @@ const IndexPage: NextPage = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<Box>
|
||||||
<h1>Saleor Invoices</h1>
|
<Text as={"h1"} variant={"hero"}>
|
||||||
<p>This is Saleor App that allows invoices generation</p>
|
Saleor Invoices
|
||||||
<p>Install app in your Saleor instance and open in with Dashboard</p>
|
</Text>
|
||||||
</div>
|
<Text as={"p"}>This is Saleor App that allows invoices generation</Text>
|
||||||
|
<Text as={"p"}>
|
||||||
|
Install app in your Saleor instance and open in with Dashboard{" "}
|
||||||
|
<a href={"https://github.com/saleor/apps"}>or check it on Github</a>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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 (
|
|
||||||
<div>
|
|
||||||
<h1>Saleor Invoices App</h1>
|
|
||||||
<AlertBase variant="error">
|
|
||||||
<Typography variant="h3" paragraph>
|
|
||||||
App can not be used
|
|
||||||
</Typography>
|
|
||||||
<Typography paragraph>
|
|
||||||
To configure Invoices App you need to create at least 1 channel
|
|
||||||
</Typography>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={() => {
|
|
||||||
appBridge?.dispatch(
|
|
||||||
actions.Redirect({
|
|
||||||
to: `/channels/add`,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Set up channel
|
|
||||||
</Button>
|
|
||||||
</AlertBase>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NotReadyPage;
|
|
|
@ -1,10 +1,9 @@
|
||||||
body {
|
body {
|
||||||
font-family: Inter, -apple-system, "system-ui", "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell,
|
font-family: Inter, -apple-system, "system-ui", "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell,
|
||||||
"Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
|
"Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
|
||||||
color: #111;
|
|
||||||
padding-top: 32px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
109
pnpm-lock.yaml
109
pnpm-lock.yaml
|
@ -626,15 +626,9 @@ importers:
|
||||||
|
|
||||||
apps/invoices:
|
apps/invoices:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@material-ui/core':
|
'@hookform/resolvers':
|
||||||
specifier: ^4.12.4
|
specifier: ^3.1.0
|
||||||
version: 4.12.4(@types/react@18.0.27)(react-dom@18.2.0)(react@18.2.0)
|
version: 3.1.0(react-hook-form@7.43.1)
|
||||||
'@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)
|
|
||||||
'@saleor/app-sdk':
|
'@saleor/app-sdk':
|
||||||
specifier: 0.37.3
|
specifier: 0.37.3
|
||||||
version: 0.37.3(next@13.3.0)(react-dom@18.2.0)(react@18.2.0)
|
version: 0.37.3(next@13.3.0)(react-dom@18.2.0)(react@18.2.0)
|
||||||
|
@ -642,8 +636,8 @@ importers:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/shared
|
version: link:../../packages/shared
|
||||||
'@saleor/macaw-ui':
|
'@saleor/macaw-ui':
|
||||||
specifier: ^0.7.2
|
specifier: ^0.8.0-pre.80
|
||||||
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)
|
version: 0.8.0-pre.80(@types/react@18.0.27)(react-dom@18.2.0)(react@18.2.0)
|
||||||
'@sentry/nextjs':
|
'@sentry/nextjs':
|
||||||
specifier: ^7.36.0
|
specifier: ^7.36.0
|
||||||
version: 7.36.0(next@13.3.0)(react@18.2.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)
|
react-hook-form: 7.43.1(react@18.2.0)
|
||||||
dev: false
|
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):
|
/@hookform/resolvers@3.1.0(react-hook-form@7.43.9):
|
||||||
resolution: {integrity: sha512-z0A8K+Nxq+f83Whm/ajlwE6VtQlp/yPHZnXw7XWVPIGm1Vx0QV8KThU3BpbBRfAZ7/dYqCKKBNnQh85BkmBKkA==}
|
resolution: {integrity: sha512-z0A8K+Nxq+f83Whm/ajlwE6VtQlp/yPHZnXw7XWVPIGm1Vx0QV8KThU3BpbBRfAZ7/dYqCKKBNnQh85BkmBKkA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
@ -5004,6 +5006,34 @@ packages:
|
||||||
- '@types/react'
|
- '@types/react'
|
||||||
dev: false
|
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):
|
/@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==}
|
resolution: {integrity: sha512-GRHZ8yD12MrN2NLobHPE8Rb5uHTxd9x372DE9PPNnBjpczAQHcZ5ne0KXG4xpf+RDdXSzdLv9ym6mYJCDTaUZg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
@ -5248,6 +5278,31 @@ packages:
|
||||||
react-dom: 18.2.0(react@18.2.0)
|
react-dom: 18.2.0(react@18.2.0)
|
||||||
dev: false
|
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):
|
/@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==}
|
resolution: {integrity: sha512-cDKVcfzyO6PpckZekODJZDe5ZxZ2fCZlzKzTmPhe4mX9qTHRfLcKgqb0OKf22xLwDequ2tVleim+ZYx3rabD5w==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
@ -5537,6 +5592,38 @@ packages:
|
||||||
- '@types/react'
|
- '@types/react'
|
||||||
dev: false
|
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:
|
/@selderee/plugin-htmlparser2@0.10.0:
|
||||||
resolution: {integrity: sha512-gW69MEamZ4wk1OsOq1nG1jcyhXIQcnrsX5JwixVw/9xaiav8TCyjESAruu1Rz9yyInhgBXxkNwMeygKnN2uxNA==}
|
resolution: {integrity: sha512-gW69MEamZ4wk1OsOq1nG1jcyhXIQcnrsX5JwixVw/9xaiav8TCyjESAruu1Rz9yyInhgBXxkNwMeygKnN2uxNA==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
Loading…
Reference in a new issue