Compare commits
7 commits
main
...
eam-use-ap
Author | SHA1 | Date | |
---|---|---|---|
![]() |
f0a62fe580 | ||
![]() |
767d7fb4d3 | ||
![]() |
40eda0045a | ||
![]() |
cb4a9e8cad | ||
![]() |
44741aa74e | ||
![]() |
e04ee04b3d | ||
![]() |
55b998a75f |
112 changed files with 3801 additions and 4080 deletions
|
@ -1,11 +1,14 @@
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
transpilePackages: ["@saleor/apps-shared"],
|
transpilePackages: ["@saleor/apps-shared", "@saleor/apps-ui", "@saleor/react-hook-form-macaw"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSentryEnvAvailable =
|
const isSentryEnvAvailable =
|
||||||
process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_PROJECT && process.env.SENTRY_ORG && process.env.SENTRY_AUTH_TOKEN;
|
process.env.SENTRY_AUTH_TOKEN &&
|
||||||
|
process.env.SENTRY_PROJECT &&
|
||||||
|
process.env.SENTRY_ORG &&
|
||||||
|
process.env.SENTRY_AUTH_TOKEN;
|
||||||
|
|
||||||
const { withSentryConfig } = require("@sentry/nextjs");
|
const { withSentryConfig } = require("@sentry/nextjs");
|
||||||
|
|
||||||
|
|
|
@ -16,13 +16,13 @@
|
||||||
"schemaVersion": "3.11.7"
|
"schemaVersion": "3.11.7"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@material-ui/core": "^4.12.4",
|
"@hookform/resolvers": "^3.1.0",
|
||||||
"@material-ui/icons": "^4.11.3",
|
|
||||||
"@material-ui/lab": "4.0.0-alpha.61",
|
|
||||||
"@monaco-editor/react": "^4.4.6",
|
"@monaco-editor/react": "^4.4.6",
|
||||||
"@saleor/app-sdk": "0.38.0",
|
"@saleor/app-sdk": "0.38.0",
|
||||||
"@saleor/apps-shared": "workspace:*",
|
"@saleor/apps-shared": "workspace:*",
|
||||||
"@saleor/macaw-ui": "^0.7.2",
|
"@saleor/apps-ui": "workspace:*",
|
||||||
|
"@saleor/react-hook-form-macaw": "workspace:*",
|
||||||
|
"@saleor/macaw-ui": "0.8.0-pre.84",
|
||||||
"@sendgrid/client": "^7.7.0",
|
"@sendgrid/client": "^7.7.0",
|
||||||
"@sendgrid/mail": "^7.7.0",
|
"@sendgrid/mail": "^7.7.0",
|
||||||
"@sentry/nextjs": "^7.52.1",
|
"@sentry/nextjs": "^7.52.1",
|
||||||
|
@ -32,7 +32,7 @@
|
||||||
"@trpc/react-query": "^10.13.0",
|
"@trpc/react-query": "^10.13.0",
|
||||||
"@trpc/server": "^10.13.0",
|
"@trpc/server": "^10.13.0",
|
||||||
"@urql/exchange-auth": "^1.0.0",
|
"@urql/exchange-auth": "^1.0.0",
|
||||||
"@vitejs/plugin-react": "^3.0.1",
|
"@vitejs/plugin-react": "^4.0.0",
|
||||||
"clsx": "^1.2.1",
|
"clsx": "^1.2.1",
|
||||||
"graphql": "^16.6.0",
|
"graphql": "^16.6.0",
|
||||||
"graphql-tag": "^2.12.6",
|
"graphql-tag": "^2.12.6",
|
||||||
|
@ -47,13 +47,13 @@
|
||||||
"pino-pretty": "^10.0.0",
|
"pino-pretty": "^10.0.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-hook-form": "^7.43.0",
|
"react-hook-form": "^7.43.9",
|
||||||
"react-is": "^18.2.0",
|
"react-is": "^18.2.0",
|
||||||
"react-query": "^3.39.3",
|
"react-query": "^3.39.3",
|
||||||
"urql": "^3.0.3",
|
"urql": "^3.0.3",
|
||||||
"usehooks-ts": "^2.9.1",
|
"usehooks-ts": "^2.9.1",
|
||||||
"vite": "^4.3.1",
|
"vite": "^4.3.8",
|
||||||
"vitest": "^0.30.1",
|
"vitest": "^0.31.1",
|
||||||
"zod": "^3.20.2"
|
"zod": "^3.20.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
23
apps/emails-and-messages/src/components/basic-layout.tsx
Normal file
23
apps/emails-and-messages/src/components/basic-layout.tsx
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { Breadcrumbs } from "@saleor/apps-ui";
|
||||||
|
import { Box } from "@saleor/macaw-ui/next";
|
||||||
|
|
||||||
|
interface BasicLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
isLoading?: boolean;
|
||||||
|
breadcrumbs?: { name: string; href?: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BasicLayout = ({ children, breadcrumbs, isLoading = false }: BasicLayoutProps) => {
|
||||||
|
return (
|
||||||
|
<Box padding={10} display="grid" gap={13}>
|
||||||
|
{breadcrumbs?.length && (
|
||||||
|
<Breadcrumbs>
|
||||||
|
{breadcrumbs.map((breadcrumb) => (
|
||||||
|
<Breadcrumbs.Item href={breadcrumb.href}>{breadcrumb.name}</Breadcrumbs.Item>
|
||||||
|
))}
|
||||||
|
</Breadcrumbs>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
19
apps/emails-and-messages/src/components/box-footer.tsx
Normal file
19
apps/emails-and-messages/src/components/box-footer.tsx
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import { Box, BoxProps } from "@saleor/macaw-ui/next";
|
||||||
|
import { defaultPadding } from "./ui-defaults";
|
||||||
|
|
||||||
|
export const BoxFooter = (props: BoxProps) => {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
borderTopStyle="solid"
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor="neutralDefault"
|
||||||
|
padding={defaultPadding}
|
||||||
|
display="flex"
|
||||||
|
flexDirection="row"
|
||||||
|
justifyContent="flex-end"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
15
apps/emails-and-messages/src/components/box-with-border.tsx
Normal file
15
apps/emails-and-messages/src/components/box-with-border.tsx
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import { Box, BoxProps } from "@saleor/macaw-ui/next";
|
||||||
|
|
||||||
|
export const BoxWithBorder = (props: BoxProps) => {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
borderWidth={1}
|
||||||
|
borderStyle="solid"
|
||||||
|
borderColor="neutralDefault"
|
||||||
|
borderRadius={5}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { Box, Text } from "@saleor/macaw-ui/next";
|
||||||
|
|
||||||
|
interface SectionWithDescriptionProps {
|
||||||
|
title: string;
|
||||||
|
description?: React.ReactNode;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
export const SectionWithDescription = (props: SectionWithDescriptionProps) => {
|
||||||
|
return (
|
||||||
|
<Box display="grid" gridTemplateColumns={{ desktop: 3, mobile: 1 }} gap={6}>
|
||||||
|
<Box>
|
||||||
|
<Text variant="heading" display="block">
|
||||||
|
{props.title}
|
||||||
|
</Text>
|
||||||
|
{props.description}
|
||||||
|
</Box>
|
||||||
|
{!!props.children && (
|
||||||
|
<Box
|
||||||
|
gridColumnStart={{ desktop: "2", mobile: "1" }}
|
||||||
|
gridColumnEnd={{ desktop: "4", mobile: "1" }}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
1
apps/emails-and-messages/src/components/ui-defaults.ts
Normal file
1
apps/emails-and-messages/src/components/ui-defaults.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export const defaultPadding = 6;
|
|
@ -1,4 +1,12 @@
|
||||||
export const getBaseUrl = (headers: { [name: string]: string | string[] | undefined }): string => {
|
export const getBaseUrl = (headers: { [name: string]: string | string[] | undefined }): string => {
|
||||||
const { host, "x-forwarded-proto": protocol = "http" } = headers;
|
const { host, "x-forwarded-proto": xForwardedProto = "http" } = headers;
|
||||||
|
|
||||||
|
const xForwardedProtos = Array.isArray(xForwardedProto)
|
||||||
|
? xForwardedProto.join(",")
|
||||||
|
: xForwardedProto;
|
||||||
|
const protocols = xForwardedProtos.split(",");
|
||||||
|
// prefer https over other protocols
|
||||||
|
const protocol = protocols.find((el) => el === "https") || protocols[0];
|
||||||
|
|
||||||
return `${protocol}://${host}`;
|
return `${protocol}://${host}`;
|
||||||
};
|
};
|
||||||
|
|
38
apps/emails-and-messages/src/lib/set-backend-errors.ts
Normal file
38
apps/emails-and-messages/src/lib/set-backend-errors.ts
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
import { TRPCClientErrorLike } from "@trpc/client";
|
||||||
|
import { AppRouter } from "../modules/trpc/trpc-app-router";
|
||||||
|
import { FieldValues, UseFormSetError } from "react-hook-form";
|
||||||
|
|
||||||
|
type SetBackendErrorsProps<T extends FieldValues = FieldValues> = {
|
||||||
|
error: TRPCClientErrorLike<AppRouter>;
|
||||||
|
setError?: UseFormSetError<T>;
|
||||||
|
notifyError: (title: string, text?: string, apiMessage?: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function setBackendErrors<T extends FieldValues = FieldValues>({
|
||||||
|
error,
|
||||||
|
setError,
|
||||||
|
notifyError,
|
||||||
|
}: SetBackendErrorsProps<T>) {
|
||||||
|
let isFieldErrorSet = false;
|
||||||
|
const fieldErrors = error.data?.zodError?.fieldErrors || {};
|
||||||
|
|
||||||
|
for (const fieldName in fieldErrors) {
|
||||||
|
for (const message of fieldErrors[fieldName] || []) {
|
||||||
|
isFieldErrorSet = true;
|
||||||
|
if (!!setError) {
|
||||||
|
setError(fieldName as keyof UseFormSetError<T>, {
|
||||||
|
type: "manual",
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const formErrors = error.data?.zodError?.formErrors || [];
|
||||||
|
const formErrorMessage = formErrors.length ? formErrors.join("\n") : undefined;
|
||||||
|
|
||||||
|
notifyError(
|
||||||
|
"Could not save the configuration",
|
||||||
|
isFieldErrorSet ? "Submitted form contain errors" : "Error saving configuration",
|
||||||
|
formErrorMessage
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,48 +0,0 @@
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import { AppBridgeState } from "@saleor/app-sdk/app-bridge";
|
|
||||||
import { render, waitFor } from "@testing-library/react";
|
|
||||||
import { ThemeSynchronizer } from "./theme-synchronizer";
|
|
||||||
|
|
||||||
const appBridgeState: AppBridgeState = {
|
|
||||||
ready: true,
|
|
||||||
token: "token",
|
|
||||||
domain: "some-domain.saleor.cloud",
|
|
||||||
theme: "dark",
|
|
||||||
path: "/",
|
|
||||||
locale: "en",
|
|
||||||
id: "app-id",
|
|
||||||
saleorApiUrl: "https://some-domain.saleor.cloud/graphql/",
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockThemeChange = vi.fn();
|
|
||||||
|
|
||||||
vi.mock("@saleor/app-sdk/app-bridge", () => {
|
|
||||||
return {
|
|
||||||
useAppBridge() {
|
|
||||||
return {
|
|
||||||
appBridgeState: appBridgeState,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("@saleor/macaw-ui", () => {
|
|
||||||
return {
|
|
||||||
useTheme() {
|
|
||||||
return {
|
|
||||||
setTheme: mockThemeChange,
|
|
||||||
themeType: "light",
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ThemeSynchronizer", () => {
|
|
||||||
it("Updates MacawUI theme when AppBridgeState theme changes", () => {
|
|
||||||
render(<ThemeSynchronizer />);
|
|
||||||
|
|
||||||
return waitFor(() => {
|
|
||||||
expect(mockThemeChange).toHaveBeenCalledWith("dark");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,33 +1,24 @@
|
||||||
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 { useEffect } from "react";
|
||||||
|
|
||||||
/**
|
export function ThemeSynchronizer() {
|
||||||
* Macaw-ui stores its theme mode in memory and local storage. To synchronize App with Dashboard,
|
|
||||||
* Macaw must be informed about this change from AppBridge.
|
|
||||||
*
|
|
||||||
* If you are not using Macaw, you can remove this.
|
|
||||||
*/
|
|
||||||
function _ThemeSynchronizer() {
|
|
||||||
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);
|
|
||||||
|
|
|
@ -1,33 +0,0 @@
|
||||||
import { AppConfig, AppConfigurationPerChannel } from "./app-config";
|
|
||||||
|
|
||||||
export const getDefaultEmptyAppConfiguration = (): AppConfigurationPerChannel => ({
|
|
||||||
active: false,
|
|
||||||
mjmlConfigurationId: undefined,
|
|
||||||
sendgridConfigurationId: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const getChannelAppConfiguration =
|
|
||||||
(appConfig: AppConfig | null | undefined) => (channelSlug: string) => {
|
|
||||||
try {
|
|
||||||
return appConfig?.configurationsPerChannel[channelSlug] ?? null;
|
|
||||||
} catch (e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const setChannelAppConfiguration =
|
|
||||||
(appConfig: AppConfig | null | undefined) =>
|
|
||||||
(channelSlug: string) =>
|
|
||||||
(appConfiguration: AppConfigurationPerChannel) => {
|
|
||||||
const appConfigNormalized = structuredClone(appConfig) ?? { configurationsPerChannel: {} };
|
|
||||||
|
|
||||||
appConfigNormalized.configurationsPerChannel[channelSlug] ??= getDefaultEmptyAppConfiguration();
|
|
||||||
appConfigNormalized.configurationsPerChannel[channelSlug] = appConfiguration;
|
|
||||||
|
|
||||||
return appConfigNormalized;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AppConfigContainer = {
|
|
||||||
getChannelAppConfiguration,
|
|
||||||
setChannelAppConfiguration,
|
|
||||||
};
|
|
|
@ -1,20 +0,0 @@
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
export const appConfigInputSchema = z.object({
|
|
||||||
configurationsPerChannel: z.record(
|
|
||||||
z.object({
|
|
||||||
active: z.boolean(),
|
|
||||||
mjmlConfigurationId: z.string().optional(),
|
|
||||||
sendgridConfigurationId: z.string().optional(),
|
|
||||||
})
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const appChannelConfigurationInputSchema = z.object({
|
|
||||||
channel: z.string(),
|
|
||||||
configuration: z.object({
|
|
||||||
active: z.boolean(),
|
|
||||||
mjmlConfigurationId: z.string().optional(),
|
|
||||||
sendgridConfigurationId: z.string().optional(),
|
|
||||||
}),
|
|
||||||
});
|
|
|
@ -1,11 +0,0 @@
|
||||||
export interface AppConfigurationPerChannel {
|
|
||||||
active: boolean;
|
|
||||||
mjmlConfigurationId?: string;
|
|
||||||
sendgridConfigurationId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AppConfigurationsChannelMap = Record<string, AppConfigurationPerChannel>;
|
|
||||||
|
|
||||||
export type AppConfig = {
|
|
||||||
configurationsPerChannel: AppConfigurationsChannelMap;
|
|
||||||
};
|
|
|
@ -1,70 +0,0 @@
|
||||||
import { createLogger } from "@saleor/apps-shared";
|
|
||||||
import {
|
|
||||||
appChannelConfigurationInputSchema,
|
|
||||||
appConfigInputSchema,
|
|
||||||
} from "./app-config-input-schema";
|
|
||||||
import { AppConfigurationService } from "./get-app-configuration.service";
|
|
||||||
import { router } from "../trpc/trpc-server";
|
|
||||||
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Allow access only for the dashboard users and attaches the
|
|
||||||
* configuration service to the context
|
|
||||||
*/
|
|
||||||
const protectedWithConfigurationService = protectedClientProcedure.use(({ next, ctx }) =>
|
|
||||||
next({
|
|
||||||
ctx: {
|
|
||||||
...ctx,
|
|
||||||
configurationService: new AppConfigurationService({
|
|
||||||
apiClient: ctx.apiClient,
|
|
||||||
saleorApiUrl: ctx.saleorApiUrl,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
export const appConfigurationRouter = router({
|
|
||||||
getChannelConfiguration: protectedWithConfigurationService
|
|
||||||
.input(z.object({ channelSlug: z.string() }))
|
|
||||||
.query(async ({ ctx, input }) => {
|
|
||||||
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
|
|
||||||
|
|
||||||
logger.debug("Get Channel Configuration called");
|
|
||||||
|
|
||||||
return await ctx.configurationService.getChannelConfiguration(input.channelSlug);
|
|
||||||
}),
|
|
||||||
|
|
||||||
setChannelConfiguration: protectedWithConfigurationService
|
|
||||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
|
||||||
.input(appChannelConfigurationInputSchema)
|
|
||||||
.mutation(async ({ ctx, input }) => {
|
|
||||||
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
|
|
||||||
|
|
||||||
logger.debug("Set channel configuration called");
|
|
||||||
|
|
||||||
await ctx.configurationService.setChannelConfiguration(input);
|
|
||||||
}),
|
|
||||||
fetch: protectedWithConfigurationService.query(async ({ ctx, input }) => {
|
|
||||||
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
|
|
||||||
|
|
||||||
logger.debug("appConfigurationRouter.fetch called");
|
|
||||||
|
|
||||||
return new AppConfigurationService({
|
|
||||||
apiClient: ctx.apiClient,
|
|
||||||
saleorApiUrl: ctx.saleorApiUrl,
|
|
||||||
}).getConfiguration();
|
|
||||||
}),
|
|
||||||
setAndReplace: protectedWithConfigurationService
|
|
||||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
|
||||||
.input(appConfigInputSchema)
|
|
||||||
.mutation(async ({ ctx, input }) => {
|
|
||||||
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
|
|
||||||
|
|
||||||
logger.debug(input, "appConfigurationRouter.setAndReplace called with input");
|
|
||||||
|
|
||||||
await ctx.configurationService.setConfigurationRoot(input);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}),
|
|
||||||
});
|
|
|
@ -1,35 +0,0 @@
|
||||||
import { AppConfig } from "./app-config";
|
|
||||||
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
|
|
||||||
|
|
||||||
export interface AppConfigurator {
|
|
||||||
setConfig(config: AppConfig): Promise<void>;
|
|
||||||
getConfig(): Promise<AppConfig | undefined>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PrivateMetadataAppConfigurator implements AppConfigurator {
|
|
||||||
private metadataKey = "app-config";
|
|
||||||
|
|
||||||
constructor(private metadataManager: SettingsManager, private saleorApiUrl: string) {}
|
|
||||||
|
|
||||||
getConfig(): Promise<AppConfig | undefined> {
|
|
||||||
return this.metadataManager.get(this.metadataKey, this.saleorApiUrl).then((data) => {
|
|
||||||
if (!data) {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return JSON.parse(data);
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error("Invalid metadata value, cant be parsed");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setConfig(config: AppConfig): Promise<void> {
|
|
||||||
return this.metadataManager.set({
|
|
||||||
key: this.metadataKey,
|
|
||||||
value: JSON.stringify(config),
|
|
||||||
domain: this.saleorApiUrl,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,22 +0,0 @@
|
||||||
import { AppConfig } from "./app-config";
|
|
||||||
import { AppConfigContainer, getDefaultEmptyAppConfiguration } from "./app-config-container";
|
|
||||||
import { ChannelFragment, ShopInfoFragment } from "../../../generated/graphql";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TODO Test
|
|
||||||
*/
|
|
||||||
export const FallbackAppConfig = {
|
|
||||||
createFallbackConfigFromExistingShopAndChannels(
|
|
||||||
channels: ChannelFragment[],
|
|
||||||
shopAppConfiguration: ShopInfoFragment | null
|
|
||||||
) {
|
|
||||||
return (channels ?? []).reduce<AppConfig>(
|
|
||||||
(state, channel) => {
|
|
||||||
return AppConfigContainer.setChannelAppConfiguration(state)(channel.slug)(
|
|
||||||
getDefaultEmptyAppConfiguration()
|
|
||||||
);
|
|
||||||
},
|
|
||||||
{ configurationsPerChannel: {} }
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
|
@ -1,95 +0,0 @@
|
||||||
import { PrivateMetadataAppConfigurator } from "./app-configurator";
|
|
||||||
import { Client } from "urql";
|
|
||||||
import { createLogger } from "@saleor/apps-shared";
|
|
||||||
import { AppConfig, AppConfigurationPerChannel } from "./app-config";
|
|
||||||
import { getDefaultEmptyAppConfiguration } from "./app-config-container";
|
|
||||||
import { createSettingsManager } from "../../lib/metadata-manager";
|
|
||||||
|
|
||||||
const logger = createLogger({
|
|
||||||
service: "AppConfigurationService",
|
|
||||||
});
|
|
||||||
|
|
||||||
export class AppConfigurationService {
|
|
||||||
private configurationData?: AppConfig;
|
|
||||||
private metadataConfigurator: PrivateMetadataAppConfigurator;
|
|
||||||
|
|
||||||
constructor(args: { apiClient: Client; saleorApiUrl: string; initialData?: AppConfig }) {
|
|
||||||
this.metadataConfigurator = new PrivateMetadataAppConfigurator(
|
|
||||||
createSettingsManager(args.apiClient),
|
|
||||||
args.saleorApiUrl
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch configuration from Saleor API and cache it
|
|
||||||
private async pullConfiguration() {
|
|
||||||
logger.debug("Fetch configuration from Saleor API");
|
|
||||||
|
|
||||||
const config = await this.metadataConfigurator.getConfig();
|
|
||||||
|
|
||||||
this.configurationData = config;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Push configuration to Saleor API
|
|
||||||
private async pushConfiguration() {
|
|
||||||
logger.debug("Push configuration to Saleor API");
|
|
||||||
|
|
||||||
await this.metadataConfigurator.setConfig(this.configurationData!);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getConfiguration() {
|
|
||||||
logger.debug("Get configuration");
|
|
||||||
|
|
||||||
if (!this.configurationData) {
|
|
||||||
logger.debug("No configuration found in cache. Will fetch it from Saleor API");
|
|
||||||
await this.pullConfiguration();
|
|
||||||
}
|
|
||||||
|
|
||||||
const savedAppConfig = this.configurationData ?? null;
|
|
||||||
|
|
||||||
logger.debug(savedAppConfig, "Retrieved app config from Metadata. Will return it");
|
|
||||||
|
|
||||||
if (savedAppConfig) {
|
|
||||||
return savedAppConfig;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Saves configuration to Saleor API and cache it
|
|
||||||
async setConfigurationRoot(config: AppConfig) {
|
|
||||||
logger.debug("Set configuration");
|
|
||||||
|
|
||||||
this.configurationData = config;
|
|
||||||
await this.pushConfiguration();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns channel configuration if existing. Otherwise returns default empty one
|
|
||||||
async getChannelConfiguration(channel: string) {
|
|
||||||
logger.debug("Get channel configuration");
|
|
||||||
const configurations = await this.getConfiguration();
|
|
||||||
|
|
||||||
if (!configurations) {
|
|
||||||
return getDefaultEmptyAppConfiguration();
|
|
||||||
}
|
|
||||||
|
|
||||||
const channelConfiguration = configurations.configurationsPerChannel[channel];
|
|
||||||
|
|
||||||
return channelConfiguration || getDefaultEmptyAppConfiguration();
|
|
||||||
}
|
|
||||||
|
|
||||||
async setChannelConfiguration({
|
|
||||||
channel,
|
|
||||||
configuration,
|
|
||||||
}: {
|
|
||||||
channel: string;
|
|
||||||
configuration: AppConfigurationPerChannel;
|
|
||||||
}) {
|
|
||||||
logger.debug("Set channel configuration");
|
|
||||||
let configurations = await this.getConfiguration();
|
|
||||||
|
|
||||||
if (!configurations) {
|
|
||||||
configurations = { configurationsPerChannel: {} };
|
|
||||||
}
|
|
||||||
|
|
||||||
configurations.configurationsPerChannel[channel] = configuration;
|
|
||||||
await this.setConfigurationRoot(configurations);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,181 +0,0 @@
|
||||||
import { AppConfigurationPerChannel } from "../app-config";
|
|
||||||
import { Controller, useForm } from "react-hook-form";
|
|
||||||
import { FormControl, InputLabel, Link, MenuItem, Select, Typography } from "@material-ui/core";
|
|
||||||
import { Button, makeStyles, SwitchSelector, SwitchSelectorButton } from "@saleor/macaw-ui";
|
|
||||||
import React, { useEffect } from "react";
|
|
||||||
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { mjmlUrls } from "../../mjml/urls";
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => ({
|
|
||||||
field: {
|
|
||||||
marginBottom: 20,
|
|
||||||
},
|
|
||||||
channelName: {
|
|
||||||
cursor: "pointer",
|
|
||||||
borderBottom: `2px solid ${theme.palette.secondary.main}`,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
type AppConfigurationFormProps = {
|
|
||||||
channelSlug: string;
|
|
||||||
channelName: string;
|
|
||||||
channelID: string;
|
|
||||||
mjmlConfigurationChoices: { label: string; value: string }[];
|
|
||||||
sendgridConfigurationChoices: { label: string; value: string }[];
|
|
||||||
onSubmit(data: AppConfigurationPerChannel): Promise<void>;
|
|
||||||
initialData?: AppConfigurationPerChannel | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AppConfigurationForm = (props: AppConfigurationFormProps) => {
|
|
||||||
const styles = useStyles();
|
|
||||||
const { appBridge } = useAppBridge();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { handleSubmit, getValues, setValue, control, reset } = useForm<AppConfigurationPerChannel>(
|
|
||||||
{
|
|
||||||
defaultValues: props.initialData ?? undefined,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
reset(props.initialData || undefined);
|
|
||||||
}, [props.initialData, reset]);
|
|
||||||
|
|
||||||
const handleChannelNameClick = () => {
|
|
||||||
appBridge?.dispatch(
|
|
||||||
actions.Redirect({
|
|
||||||
to: `/channels/${props.channelID}`,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isNoSendgridConfigurations = !props.sendgridConfigurationChoices.length;
|
|
||||||
const isNoMjmlConfigurations = !props.mjmlConfigurationChoices.length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit((data, event) => {
|
|
||||||
props.onSubmit(data);
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Typography variant="h2" paragraph>
|
|
||||||
Configure
|
|
||||||
<span onClick={handleChannelNameClick} className={styles.channelName}>
|
|
||||||
{` ${props.channelName} `}
|
|
||||||
</span>
|
|
||||||
channel:
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="active"
|
|
||||||
render={({ field: { value, name, onChange } }) => (
|
|
||||||
<div className={styles.field}>
|
|
||||||
{/* TODO: fix types in the MacawUI */}
|
|
||||||
{/* @ts-ignore: MacawUI use wrong type for */}
|
|
||||||
<SwitchSelector key={name} className={styles.field}>
|
|
||||||
{[
|
|
||||||
{ label: "Active", value: true },
|
|
||||||
{ label: "Disabled", value: false },
|
|
||||||
].map((button) => (
|
|
||||||
// @ts-ignore: MacawUI use wrong type for SwitchSelectorButton
|
|
||||||
<SwitchSelectorButton
|
|
||||||
value={button.value.toString()}
|
|
||||||
onClick={() => onChange(button.value)}
|
|
||||||
activeTab={value?.toString() || "false"}
|
|
||||||
key={button.label}
|
|
||||||
>
|
|
||||||
{button.label}
|
|
||||||
</SwitchSelectorButton>
|
|
||||||
))}
|
|
||||||
</SwitchSelector>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="mjmlConfigurationId"
|
|
||||||
render={({ field: { value, onChange } }) => {
|
|
||||||
return (
|
|
||||||
<FormControl disabled={isNoMjmlConfigurations} className={styles.field} fullWidth>
|
|
||||||
<InputLabel>MJML Configuration</InputLabel>
|
|
||||||
<Select
|
|
||||||
variant="outlined"
|
|
||||||
value={value}
|
|
||||||
onChange={(event, val) => {
|
|
||||||
onChange(event.target.value);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MenuItem key="none" value={undefined}>
|
|
||||||
No configuration
|
|
||||||
</MenuItem>
|
|
||||||
{props.mjmlConfigurationChoices.map((choice) => (
|
|
||||||
<MenuItem key={choice.value} value={choice.value}>
|
|
||||||
{choice.label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
{isNoMjmlConfigurations && (
|
|
||||||
<Link
|
|
||||||
href="#"
|
|
||||||
onClick={() => {
|
|
||||||
router.push(mjmlUrls.configuration());
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="caption" color="textSecondary">
|
|
||||||
Currently theres no MJML configuration available. Click here to create one.
|
|
||||||
</Typography>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</FormControl>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="sendgridConfigurationId"
|
|
||||||
render={({ field: { value, onChange } }) => {
|
|
||||||
return (
|
|
||||||
<FormControl disabled={isNoSendgridConfigurations} className={styles.field} fullWidth>
|
|
||||||
<InputLabel>Sendgrid Configuration</InputLabel>
|
|
||||||
<Select
|
|
||||||
variant="outlined"
|
|
||||||
value={value}
|
|
||||||
onChange={(event, val) => {
|
|
||||||
onChange(event.target.value);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MenuItem key="none" value={undefined}>
|
|
||||||
No configuration
|
|
||||||
</MenuItem>
|
|
||||||
{props.sendgridConfigurationChoices.map((choice) => (
|
|
||||||
<MenuItem key={choice.value} value={choice.value}>
|
|
||||||
{choice.label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
{isNoSendgridConfigurations && (
|
|
||||||
<Link
|
|
||||||
href="#"
|
|
||||||
onClick={() => {
|
|
||||||
router.push("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="caption" color="textSecondary">
|
|
||||||
Currently theres no Sendgrid configuration available. Click here to create one.
|
|
||||||
</Typography>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</FormControl>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button type="submit" fullWidth variant="primary">
|
|
||||||
Save configuration
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,146 +0,0 @@
|
||||||
import React, { useMemo, useState } from "react";
|
|
||||||
import { EditIcon, IconButton, makeStyles } from "@saleor/macaw-ui";
|
|
||||||
import { AppConfigurationForm } from "./app-configuration-form";
|
|
||||||
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
|
||||||
import { AppColumnsLayout } from "../../ui/app-columns-layout";
|
|
||||||
import { trpcClient } from "../../trpc/trpc-client";
|
|
||||||
import { SideMenu } from "./side-menu";
|
|
||||||
import { LoadingIndicator } from "../../ui/loading-indicator";
|
|
||||||
import { Instructions } from "./instructions";
|
|
||||||
import { useDashboardNotification } from "@saleor/apps-shared";
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => {
|
|
||||||
return {
|
|
||||||
formContainer: {
|
|
||||||
top: 0,
|
|
||||||
},
|
|
||||||
configurationColumn: {
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 20,
|
|
||||||
maxWidth: 700,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export const ChannelsConfigurationTab = () => {
|
|
||||||
const styles = useStyles();
|
|
||||||
const { appBridge } = useAppBridge();
|
|
||||||
const [activeChannelSlug, setActiveChannelSlug] = useState<string | null>(null);
|
|
||||||
const { notifySuccess } = useDashboardNotification();
|
|
||||||
|
|
||||||
const { data: channelsData, isLoading: isChannelsDataLoading } =
|
|
||||||
trpcClient.channels.fetch.useQuery(undefined, {
|
|
||||||
onSuccess: (data) => {
|
|
||||||
if (data?.length) {
|
|
||||||
setActiveChannelSlug(data[0].slug);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: configurationData,
|
|
||||||
refetch: refetchConfig,
|
|
||||||
isLoading: isConfigurationDataLoading,
|
|
||||||
} = trpcClient.appConfiguration.getChannelConfiguration.useQuery(
|
|
||||||
{
|
|
||||||
channelSlug: activeChannelSlug!,
|
|
||||||
},
|
|
||||||
{ enabled: !!activeChannelSlug }
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: mjmlConfigurations, isLoading: isMjmlQueryLoading } =
|
|
||||||
trpcClient.mjmlConfiguration.getConfigurations.useQuery({});
|
|
||||||
|
|
||||||
const mjmlConfigurationsListData = useMemo(() => {
|
|
||||||
return (
|
|
||||||
mjmlConfigurations?.map((configuration) => ({
|
|
||||||
value: configuration.id,
|
|
||||||
label: configuration.configurationName,
|
|
||||||
})) ?? []
|
|
||||||
);
|
|
||||||
}, [mjmlConfigurations]);
|
|
||||||
|
|
||||||
const { data: sendgridConfigurations, isLoading: isSendgridQueryLoading } =
|
|
||||||
trpcClient.sendgridConfiguration.getConfigurations.useQuery({});
|
|
||||||
|
|
||||||
const sendgridConfigurationsListData = useMemo(() => {
|
|
||||||
return (
|
|
||||||
sendgridConfigurations?.map((configuration) => ({
|
|
||||||
value: configuration.id,
|
|
||||||
label: configuration.configurationName,
|
|
||||||
})) ?? []
|
|
||||||
);
|
|
||||||
}, [sendgridConfigurations]);
|
|
||||||
|
|
||||||
const { mutate: mutateAppChannelConfiguration, error: saveError } =
|
|
||||||
trpcClient.appConfiguration.setChannelConfiguration.useMutation({
|
|
||||||
onSuccess() {
|
|
||||||
refetchConfig();
|
|
||||||
|
|
||||||
notifySuccess("Success", "Saved app configuration");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const activeChannel = channelsData?.find((c) => c.slug === activeChannelSlug);
|
|
||||||
|
|
||||||
if (isChannelsDataLoading) {
|
|
||||||
return <LoadingIndicator />;
|
|
||||||
}
|
|
||||||
if (!channelsData?.length) {
|
|
||||||
return <div>NO CHANNELS</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isFormDataLoading =
|
|
||||||
isConfigurationDataLoading || isMjmlQueryLoading || isSendgridQueryLoading;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppColumnsLayout>
|
|
||||||
<SideMenu
|
|
||||||
title="Channels"
|
|
||||||
selectedItemId={activeChannel?.slug}
|
|
||||||
headerToolbar={
|
|
||||||
<IconButton
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => {
|
|
||||||
appBridge?.dispatch(
|
|
||||||
actions.Redirect({
|
|
||||||
to: `/channels/`,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<EditIcon />
|
|
||||||
</IconButton>
|
|
||||||
}
|
|
||||||
onClick={(id) => setActiveChannelSlug(id)}
|
|
||||||
items={channelsData.map((c) => ({ label: c.name, id: c.slug })) || []}
|
|
||||||
/>
|
|
||||||
<div className={styles.configurationColumn}>
|
|
||||||
{!activeChannel || isFormDataLoading ? (
|
|
||||||
<LoadingIndicator />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<AppConfigurationForm
|
|
||||||
channelID={activeChannel.id}
|
|
||||||
key={activeChannelSlug}
|
|
||||||
channelSlug={activeChannel.slug}
|
|
||||||
mjmlConfigurationChoices={mjmlConfigurationsListData}
|
|
||||||
sendgridConfigurationChoices={sendgridConfigurationsListData}
|
|
||||||
onSubmit={async (data) => {
|
|
||||||
mutateAppChannelConfiguration({
|
|
||||||
channel: activeChannel.slug,
|
|
||||||
configuration: data,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
initialData={configurationData}
|
|
||||||
channelName={activeChannel?.name ?? activeChannelSlug}
|
|
||||||
/>
|
|
||||||
{saveError && <span>{saveError.message}</span>}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Instructions />
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,87 +0,0 @@
|
||||||
import {
|
|
||||||
DeleteIcon,
|
|
||||||
IconButton,
|
|
||||||
makeStyles,
|
|
||||||
OffsettedList,
|
|
||||||
OffsettedListBody,
|
|
||||||
OffsettedListItem,
|
|
||||||
OffsettedListItemCell,
|
|
||||||
} from "@saleor/macaw-ui";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
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 ListItem = {
|
|
||||||
label: string;
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
listItems: ListItem[];
|
|
||||||
activeItemId?: string;
|
|
||||||
onItemClick(itemId?: string): void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ConfigurationsList = ({ listItems, activeItemId, onItemClick }: Props) => {
|
|
||||||
const styles = useStyles();
|
|
||||||
return (
|
|
||||||
<OffsettedList gridTemplate={["1fr"]}>
|
|
||||||
<OffsettedListBody>
|
|
||||||
{listItems.map((c) => {
|
|
||||||
return (
|
|
||||||
<OffsettedListItem
|
|
||||||
className={clsx(styles.listItem, {
|
|
||||||
[styles.listItemActive]: c.id === activeItemId,
|
|
||||||
})}
|
|
||||||
key={c.id}
|
|
||||||
onClick={() => {
|
|
||||||
onItemClick(c.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<OffsettedListItemCell>
|
|
||||||
{c.label}
|
|
||||||
<IconButton
|
|
||||||
variant="secondary"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DeleteIcon />
|
|
||||||
</IconButton>
|
|
||||||
</OffsettedListItemCell>
|
|
||||||
</OffsettedListItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<OffsettedListItem
|
|
||||||
className={clsx(styles.listItem, {
|
|
||||||
[styles.listItemActive]: activeItemId === undefined,
|
|
||||||
})}
|
|
||||||
key="new"
|
|
||||||
onClick={() => {
|
|
||||||
onItemClick();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<OffsettedListItemCell>Create new</OffsettedListItemCell>
|
|
||||||
</OffsettedListItem>
|
|
||||||
</OffsettedListBody>
|
|
||||||
</OffsettedList>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,35 +0,0 @@
|
||||||
import { Paper, Typography } from "@material-ui/core";
|
|
||||||
import { makeStyles } from "@saleor/macaw-ui";
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => {
|
|
||||||
return {
|
|
||||||
instructionsContainer: {
|
|
||||||
padding: 15,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export const Instructions = () => {
|
|
||||||
const styles = useStyles();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper elevation={0} className={styles.instructionsContainer}>
|
|
||||||
<Typography paragraph variant="h4">
|
|
||||||
Welcome to Emails and Messages App!
|
|
||||||
</Typography>
|
|
||||||
<Typography paragraph>
|
|
||||||
The application will allow you to send emails and messages to your customers using different
|
|
||||||
services.
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Typography paragraph variant="h4">
|
|
||||||
How to configure the app
|
|
||||||
</Typography>
|
|
||||||
<Typography paragraph>
|
|
||||||
Start by creating a new configuration for provider of your choice. You can create multiple
|
|
||||||
configurations and then assign them to channels. Navigate to the relevant tab to configure
|
|
||||||
the provider.
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -0,0 +1,134 @@
|
||||||
|
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
||||||
|
import { SemanticChip } from "@saleor/apps-ui";
|
||||||
|
import { BoxWithBorder } from "../../../components/box-with-border";
|
||||||
|
import { BoxFooter } from "../../../components/box-footer";
|
||||||
|
import { defaultPadding } from "../../../components/ui-defaults";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import Image from "next/image";
|
||||||
|
import sendgrid from "../../../public/sendgrid.png";
|
||||||
|
import smtp from "../../../public/smtp.svg";
|
||||||
|
import { smtpUrls } from "../../smtp/urls";
|
||||||
|
import { sendgridUrls } from "../../sendgrid/urls";
|
||||||
|
import { appUrls } from "../urls";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const NoExistingConfigurations = () => {
|
||||||
|
const { replace } = useRouter();
|
||||||
|
|
||||||
|
const redirectToProvidersSelection = () => {
|
||||||
|
replace("/configuration/choose-provider");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BoxWithBorder padding={10} display="grid" alignItems="center" justifyContent="center">
|
||||||
|
<Text>No providers configured yet</Text>
|
||||||
|
<Button onClick={redirectToProvidersSelection}>Add first provider</Button>
|
||||||
|
</BoxWithBorder>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProviderType = "sendgrid" | "smtp";
|
||||||
|
|
||||||
|
const providerLabels: Record<ProviderType, string> = {
|
||||||
|
sendgrid: "Sendgrid",
|
||||||
|
smtp: "SMTP",
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConfigurationListItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
active: boolean;
|
||||||
|
provider: ProviderType;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MessagingProvidersSectionProps {
|
||||||
|
configurations: ConfigurationListItem[];
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MessagingProvidersBox = ({
|
||||||
|
configurations,
|
||||||
|
isLoading: loading,
|
||||||
|
}: MessagingProvidersSectionProps) => {
|
||||||
|
const { push } = useRouter();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<BoxWithBorder padding={10} display="grid" alignItems="center" justifyContent="center">
|
||||||
|
<Text>Loading</Text>
|
||||||
|
</BoxWithBorder>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configurations.length === 0) {
|
||||||
|
return <NoExistingConfigurations />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectToProvidersSelection = () => {
|
||||||
|
push(appUrls.chooseProvider());
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEditLink = (configuration: ConfigurationListItem) => {
|
||||||
|
switch (configuration.provider) {
|
||||||
|
case "smtp":
|
||||||
|
return smtpUrls.configuration(configuration.id);
|
||||||
|
case "sendgrid":
|
||||||
|
return sendgridUrls.configuration(configuration.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProviderLogo = (configuration: ConfigurationListItem) => {
|
||||||
|
switch (configuration.provider) {
|
||||||
|
case "smtp":
|
||||||
|
return <Image alt="SMTP logo" src={smtp} height={20} width={20} />;
|
||||||
|
case "sendgrid":
|
||||||
|
return <Image alt="Sendgrid logo" src={sendgrid} height={20} width={20} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BoxWithBorder>
|
||||||
|
<Box padding={defaultPadding} display="grid" gridTemplateColumns={4} gap={defaultPadding}>
|
||||||
|
<Text variant="caption" color="textNeutralSubdued">
|
||||||
|
Provider
|
||||||
|
</Text>
|
||||||
|
<Text variant="caption" color="textNeutralSubdued">
|
||||||
|
Configuration name
|
||||||
|
</Text>
|
||||||
|
<Text variant="caption" color="textNeutralSubdued">
|
||||||
|
Status
|
||||||
|
</Text>
|
||||||
|
<Box />
|
||||||
|
{configurations.map((configuration) => (
|
||||||
|
<React.Fragment key={configuration.id}>
|
||||||
|
<Box display="flex" gap={defaultPadding}>
|
||||||
|
{getProviderLogo(configuration)}
|
||||||
|
<Text>{providerLabels[configuration.provider]}</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Text>{configuration.name}</Text>
|
||||||
|
<Box __maxWidth="fit-content">
|
||||||
|
<SemanticChip variant={configuration.active ? "success" : "error"}>
|
||||||
|
{configuration.active ? "Active" : "Inactive"}
|
||||||
|
</SemanticChip>
|
||||||
|
</Box>
|
||||||
|
<Box display="flex" justifyContent="flex-end">
|
||||||
|
<Button
|
||||||
|
variant="tertiary"
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
push(getEditLink(configuration));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
<BoxFooter>
|
||||||
|
<Button onClick={redirectToProvidersSelection}>Add provider</Button>
|
||||||
|
</BoxFooter>
|
||||||
|
</BoxWithBorder>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
||||||
|
import { BoxWithBorder } from "../../../components/box-with-border";
|
||||||
|
import { defaultPadding } from "../../../components/ui-defaults";
|
||||||
|
import { BoxFooter } from "../../../components/box-footer";
|
||||||
|
|
||||||
|
interface ProviderSelectionBoxProps {
|
||||||
|
providerName: string;
|
||||||
|
providerDescription: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProviderSelectionBox = (props: ProviderSelectionBoxProps) => {
|
||||||
|
return (
|
||||||
|
<BoxWithBorder display="grid" alignItems="center" justifyContent="center">
|
||||||
|
<Box padding={defaultPadding} display="grid" alignItems="center" justifyContent="center">
|
||||||
|
<Text variant="heading">{props.providerName}</Text>
|
||||||
|
</Box>
|
||||||
|
<Box padding={defaultPadding} display="grid" alignItems="center" justifyContent="center">
|
||||||
|
<Text>{props.providerDescription}</Text>
|
||||||
|
</Box>
|
||||||
|
<BoxFooter justifyContent="center" alignItems="center">
|
||||||
|
<Button onClick={props.onClick}>Choose</Button>
|
||||||
|
</BoxFooter>
|
||||||
|
</BoxWithBorder>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,109 +0,0 @@
|
||||||
import { Card, CardContent, CardHeader, Divider } from "@material-ui/core";
|
|
||||||
("@material-ui/icons");
|
|
||||||
import { DeleteIcon, IconButton, List, ListItem, ListItemCell } from "@saleor/macaw-ui";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
import { makeStyles } from "@saleor/macaw-ui";
|
|
||||||
import { Skeleton } from "@material-ui/lab";
|
|
||||||
|
|
||||||
export const useStyles = makeStyles((theme) => ({
|
|
||||||
menu: {
|
|
||||||
height: "fit-content",
|
|
||||||
},
|
|
||||||
clickable: {
|
|
||||||
cursor: "pointer",
|
|
||||||
},
|
|
||||||
selected: {
|
|
||||||
"&&&&::before": {
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
width: "4px",
|
|
||||||
height: "100%",
|
|
||||||
backgroundColor: theme.palette.saleor.active[1],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
spaceBetween: {
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
tableRow: {
|
|
||||||
minHeight: "48px",
|
|
||||||
"&::after": {
|
|
||||||
display: "none",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
greyText: {
|
|
||||||
color: theme.palette.text.hint,
|
|
||||||
},
|
|
||||||
link: {
|
|
||||||
all: "inherit",
|
|
||||||
display: "contents",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
interface SideMenuProps {
|
|
||||||
title: string;
|
|
||||||
noItemsText?: string;
|
|
||||||
items: { id: string; label: string }[];
|
|
||||||
selectedItemId?: string;
|
|
||||||
headerToolbar?: React.ReactNode;
|
|
||||||
onDelete?: (itemId: string) => void;
|
|
||||||
onClick: (itemId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SideMenu: React.FC<SideMenuProps> = ({
|
|
||||||
title,
|
|
||||||
items,
|
|
||||||
headerToolbar,
|
|
||||||
selectedItemId,
|
|
||||||
noItemsText,
|
|
||||||
onDelete,
|
|
||||||
onClick,
|
|
||||||
}) => {
|
|
||||||
const classes = useStyles();
|
|
||||||
|
|
||||||
const isNoItems = !items || !items.length;
|
|
||||||
return (
|
|
||||||
<Card className={classes.menu}>
|
|
||||||
<CardHeader title={title} action={headerToolbar} />
|
|
||||||
{isNoItems ? (
|
|
||||||
!!noItemsText && <CardContent className={classes.greyText}>{noItemsText}</CardContent>
|
|
||||||
) : (
|
|
||||||
<List gridTemplate={["1fr"]}>
|
|
||||||
{items.map((item) => (
|
|
||||||
<React.Fragment key={item.id}>
|
|
||||||
<Divider />
|
|
||||||
<ListItem
|
|
||||||
className={clsx(classes.clickable, classes.tableRow, {
|
|
||||||
[classes.selected]: item.id === selectedItemId,
|
|
||||||
})}
|
|
||||||
onClick={() => onClick(item.id)}
|
|
||||||
>
|
|
||||||
<ListItemCell>
|
|
||||||
<div className={classes.spaceBetween}>
|
|
||||||
{item.label}
|
|
||||||
{!!onDelete && (
|
|
||||||
<IconButton
|
|
||||||
variant="secondary"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
onDelete(item.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DeleteIcon />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ListItemCell>
|
|
||||||
</ListItem>
|
|
||||||
</React.Fragment>
|
|
||||||
)) ?? <Skeleton />}
|
|
||||||
<Divider />
|
|
||||||
</List>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,4 +1,4 @@
|
||||||
export const channelUrls = {
|
export const appUrls = {
|
||||||
configuration: (id?: string) =>
|
configuration: () => "/configuration/",
|
||||||
id ? `/configuration/channels/${id}` : "/configuration/channels",
|
chooseProvider: () => "/configuration/choose-provider",
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const channelMode = ["exclude", "restrict"] as const;
|
||||||
|
|
||||||
|
export const channelConfigurationSchema = z.object({
|
||||||
|
override: z.boolean().default(false),
|
||||||
|
channels: z.array(z.string()).default([]),
|
||||||
|
mode: z.enum(channelMode).default("restrict"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ChannelConfiguration = z.infer<typeof channelConfigurationSchema>;
|
||||||
|
|
||||||
|
export const updateChannelsInputSchema = channelConfigurationSchema.merge(
|
||||||
|
z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export type UpdateChannelsInput = z.infer<typeof updateChannelsInputSchema>;
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { vi, expect, describe, it } from "vitest";
|
||||||
|
import { isAvailableInChannel } from "./is-available-in-channel";
|
||||||
|
|
||||||
|
describe("isAvailableInChannel", function () {
|
||||||
|
it("Passes when no overrides are set", () => {
|
||||||
|
expect(
|
||||||
|
isAvailableInChannel({
|
||||||
|
channel: "default-channel",
|
||||||
|
channelConfiguration: {
|
||||||
|
channels: [],
|
||||||
|
mode: "restrict",
|
||||||
|
override: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Restrict mode", () => {
|
||||||
|
it("Fails if no channel is specified", () => {
|
||||||
|
expect(
|
||||||
|
isAvailableInChannel({
|
||||||
|
channel: "default-channel",
|
||||||
|
channelConfiguration: {
|
||||||
|
channels: [],
|
||||||
|
mode: "restrict",
|
||||||
|
override: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).toEqual(false);
|
||||||
|
});
|
||||||
|
it("Fails if tested channel is not on the list", () => {
|
||||||
|
expect(
|
||||||
|
isAvailableInChannel({
|
||||||
|
channel: "default-channel",
|
||||||
|
channelConfiguration: {
|
||||||
|
channels: ["another-channel"],
|
||||||
|
mode: "restrict",
|
||||||
|
override: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).toEqual(false);
|
||||||
|
});
|
||||||
|
it("Passes if tested channel is on the list", () => {
|
||||||
|
expect(
|
||||||
|
isAvailableInChannel({
|
||||||
|
channel: "default-channel",
|
||||||
|
channelConfiguration: {
|
||||||
|
channels: ["default-channel"],
|
||||||
|
mode: "restrict",
|
||||||
|
override: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).toEqual(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Exclude mode", () => {
|
||||||
|
it("Passes if no channel is specified", () => {
|
||||||
|
expect(
|
||||||
|
isAvailableInChannel({
|
||||||
|
channel: "default-channel",
|
||||||
|
channelConfiguration: {
|
||||||
|
channels: [],
|
||||||
|
mode: "exclude",
|
||||||
|
override: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).toEqual(true);
|
||||||
|
});
|
||||||
|
it("Passes if other channels are specified", () => {
|
||||||
|
expect(
|
||||||
|
isAvailableInChannel({
|
||||||
|
channel: "default-channel",
|
||||||
|
channelConfiguration: {
|
||||||
|
channels: ["other-channel", "different-channel"],
|
||||||
|
mode: "exclude",
|
||||||
|
override: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).toEqual(true);
|
||||||
|
});
|
||||||
|
it("Fails if channel is on the list", () => {
|
||||||
|
expect(
|
||||||
|
isAvailableInChannel({
|
||||||
|
channel: "default-channel",
|
||||||
|
channelConfiguration: {
|
||||||
|
channels: ["default-channel", "different-channel"],
|
||||||
|
mode: "exclude",
|
||||||
|
override: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).toEqual(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { ChannelConfiguration } from "./channel-configuration-schema";
|
||||||
|
|
||||||
|
interface IsAvailableInChannelArgs {
|
||||||
|
channel: string;
|
||||||
|
channelConfiguration: ChannelConfiguration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the channel is available for the configuration.
|
||||||
|
*
|
||||||
|
* Is available if:
|
||||||
|
* - it's not in the excluded list
|
||||||
|
* - if assigned list is not empty, it's in the assigned list
|
||||||
|
* - assigned list is empty
|
||||||
|
*/
|
||||||
|
export const isAvailableInChannel = ({
|
||||||
|
channel,
|
||||||
|
channelConfiguration,
|
||||||
|
}: IsAvailableInChannelArgs): boolean => {
|
||||||
|
if (!channelConfiguration.override) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (channelConfiguration.mode === "restrict") {
|
||||||
|
return channelConfiguration.channels.includes(channel);
|
||||||
|
}
|
||||||
|
return !channelConfiguration.channels.includes(channel);
|
||||||
|
};
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { ChannelConfiguration } from "../channel-configuration-schema";
|
||||||
|
import { Text } from "@saleor/macaw-ui/next";
|
||||||
|
|
||||||
|
interface OverrideMessageArgs {
|
||||||
|
availableChannels: string[];
|
||||||
|
channelConfiguration: ChannelConfiguration;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AssignedChannelsMessage = ({
|
||||||
|
availableChannels,
|
||||||
|
channelConfiguration: { channels, mode, override },
|
||||||
|
}: OverrideMessageArgs) => {
|
||||||
|
if (!override) {
|
||||||
|
return (
|
||||||
|
<Text>
|
||||||
|
Configuration will be used with <Text variant="bodyStrong"> all</Text> channels.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "exclude") {
|
||||||
|
const leftChannels = availableChannels.filter((channel) => !channels.includes(channel));
|
||||||
|
|
||||||
|
if (!leftChannels.length) {
|
||||||
|
return (
|
||||||
|
<Text variant="bodyStrong">
|
||||||
|
Theres no channel which will be used with this configuration.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Text>
|
||||||
|
Configuration will be used with channels:{" "}
|
||||||
|
<Text variant="bodyStrong">{leftChannels.join(", ")}</Text>.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channels.length === 0) {
|
||||||
|
return (
|
||||||
|
<Text>
|
||||||
|
<Text variant="bodyStrong">No channels assigned. The configuration will not be used!</Text>
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Text>
|
||||||
|
Configuration will be used with channels:{" "}
|
||||||
|
<Text variant="bodyStrong">{channels.join(", ")}</Text>.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,118 @@
|
||||||
|
import { BoxWithBorder } from "../../../components/box-with-border";
|
||||||
|
import { Box, Button, ProductsIcons, Switch, TableEditIcon, Text } from "@saleor/macaw-ui/next";
|
||||||
|
import { Multiselect } from "@saleor/react-hook-form-macaw";
|
||||||
|
import { defaultPadding } from "../../../components/ui-defaults";
|
||||||
|
import { trpcClient } from "../../trpc/trpc-client";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { BoxFooter } from "../../../components/box-footer";
|
||||||
|
import { SectionWithDescription } from "../../../components/section-with-description";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { AssignedChannelsMessage } from "./assigned-channels-message";
|
||||||
|
import {
|
||||||
|
ChannelConfiguration,
|
||||||
|
UpdateChannelsInput,
|
||||||
|
updateChannelsInputSchema,
|
||||||
|
} from "../channel-configuration-schema";
|
||||||
|
|
||||||
|
interface UniversalChannelsSectionProps {
|
||||||
|
configurationId: string;
|
||||||
|
channelConfiguration: ChannelConfiguration;
|
||||||
|
onSubmit: (formData: UpdateChannelsInput) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UniversalChannelsSection = ({
|
||||||
|
configurationId,
|
||||||
|
channelConfiguration,
|
||||||
|
onSubmit,
|
||||||
|
}: UniversalChannelsSectionProps) => {
|
||||||
|
const { handleSubmit, control, register } = useForm<UpdateChannelsInput>({
|
||||||
|
defaultValues: {
|
||||||
|
id: configurationId,
|
||||||
|
...channelConfiguration,
|
||||||
|
},
|
||||||
|
resolver: zodResolver(updateChannelsInputSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: channels } = trpcClient.channels.fetch.useQuery();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionWithDescription
|
||||||
|
title="Channels"
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
<Text display="block">
|
||||||
|
By default, provider will work for every channel. You can change this behavior with
|
||||||
|
excluding or including strategy.
|
||||||
|
</Text>
|
||||||
|
<Text display="block">
|
||||||
|
<Text variant="bodyStrong">Excluding</Text> - all current channels and new created
|
||||||
|
channels will work, excluding selected
|
||||||
|
</Text>
|
||||||
|
<Text display="block">
|
||||||
|
<Text variant="bodyStrong">Including</Text> - only selected channels will work, new
|
||||||
|
created channels will not work
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit((data, event) => {
|
||||||
|
onSubmit(data);
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<BoxWithBorder>
|
||||||
|
<Box padding={defaultPadding} display="flex" flexDirection="column" gap={defaultPadding}>
|
||||||
|
<Box display="flex" flexDirection="column" gap={defaultPadding}>
|
||||||
|
<Text variant="heading">Current behaviour</Text>
|
||||||
|
<AssignedChannelsMessage
|
||||||
|
availableChannels={channels?.map((channel) => channel.slug) || []}
|
||||||
|
channelConfiguration={channelConfiguration}
|
||||||
|
/>
|
||||||
|
<Text variant="heading">Settings</Text>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" {...register("override")} />
|
||||||
|
<Text paddingLeft={defaultPadding}>Override channels</Text>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="mode"
|
||||||
|
control={control}
|
||||||
|
render={({ field: { onChange } }) => (
|
||||||
|
<Switch
|
||||||
|
defaultValue={channelConfiguration.mode}
|
||||||
|
__maxWidth="max-content"
|
||||||
|
onValueChange={onChange}
|
||||||
|
>
|
||||||
|
<Switch.Item id="1" value="restrict">
|
||||||
|
<TableEditIcon size="medium" />
|
||||||
|
<Text>Include</Text>
|
||||||
|
</Switch.Item>
|
||||||
|
<Switch.Item id="2" value="exclude">
|
||||||
|
<ProductsIcons size="medium" />
|
||||||
|
<Text>Exclude</Text>
|
||||||
|
</Switch.Item>
|
||||||
|
</Switch>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Multiselect
|
||||||
|
control={control}
|
||||||
|
label="Channels"
|
||||||
|
size="large"
|
||||||
|
name="channels"
|
||||||
|
options={
|
||||||
|
channels?.map((channel) => ({
|
||||||
|
label: channel.name,
|
||||||
|
value: channel.slug,
|
||||||
|
})) || []
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<BoxFooter>
|
||||||
|
<Button type="submit">Save provider</Button>
|
||||||
|
</BoxFooter>
|
||||||
|
</BoxWithBorder>
|
||||||
|
</form>
|
||||||
|
</SectionWithDescription>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,9 +1,8 @@
|
||||||
import { AuthData } from "@saleor/app-sdk/APL";
|
import { AuthData } from "@saleor/app-sdk/APL";
|
||||||
import { Client } from "urql";
|
import { Client } from "urql";
|
||||||
import { createLogger } from "@saleor/apps-shared";
|
import { createLogger } from "@saleor/apps-shared";
|
||||||
import { AppConfigurationService } from "../app-configuration/get-app-configuration.service";
|
import { SmtpConfigurationService } from "../smtp/configuration/get-smtp-configuration.service";
|
||||||
import { MjmlConfigurationService } from "../mjml/configuration/get-mjml-configuration.service";
|
import { sendSmtp } from "../smtp/send-smtp";
|
||||||
import { sendMjml } from "../mjml/send-mjml";
|
|
||||||
import { SendgridConfigurationService } from "../sendgrid/configuration/get-sendgrid-configuration.service";
|
import { SendgridConfigurationService } from "../sendgrid/configuration/get-sendgrid-configuration.service";
|
||||||
import { sendSendgrid } from "../sendgrid/send-sendgrid";
|
import { sendSendgrid } from "../sendgrid/send-sendgrid";
|
||||||
import { MessageEventTypes } from "./message-event-types";
|
import { MessageEventTypes } from "./message-event-types";
|
||||||
|
@ -31,52 +30,30 @@ export const sendEventMessages = async ({
|
||||||
|
|
||||||
logger.debug("Function called");
|
logger.debug("Function called");
|
||||||
|
|
||||||
const appConfigurationService = new AppConfigurationService({
|
const smtpConfigurationService = new SmtpConfigurationService({
|
||||||
apiClient: client,
|
apiClient: client,
|
||||||
saleorApiUrl: authData.saleorApiUrl,
|
saleorApiUrl: authData.saleorApiUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
const channelAppConfiguration = await appConfigurationService.getChannelConfiguration(channel);
|
const availableSmtpConfigurations = await smtpConfigurationService.getConfigurations({
|
||||||
|
active: true,
|
||||||
if (!channelAppConfiguration) {
|
availableInChannel: channel,
|
||||||
logger.warn("App has no configuration for this channel");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
logger.debug("Channel has assigned app configuration");
|
|
||||||
|
|
||||||
if (!channelAppConfiguration.active) {
|
|
||||||
logger.warn("App configuration is not active for this channel");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (channelAppConfiguration.mjmlConfigurationId) {
|
|
||||||
logger.debug("Channel has assigned MJML configuration");
|
|
||||||
|
|
||||||
const mjmlConfigurationService = new MjmlConfigurationService({
|
|
||||||
apiClient: client,
|
|
||||||
saleorApiUrl: authData.saleorApiUrl,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const mjmlConfiguration = await mjmlConfigurationService.getConfiguration({
|
for (const smtpConfiguration of availableSmtpConfigurations) {
|
||||||
id: channelAppConfiguration.mjmlConfigurationId,
|
const smtpStatus = await sendSmtp({
|
||||||
});
|
|
||||||
|
|
||||||
if (mjmlConfiguration) {
|
|
||||||
const mjmlStatus = await sendMjml({
|
|
||||||
event,
|
event,
|
||||||
payload,
|
payload,
|
||||||
recipientEmail,
|
recipientEmail,
|
||||||
mjmlConfiguration,
|
smtpConfiguration,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (mjmlStatus?.errors.length) {
|
if (smtpStatus?.errors.length) {
|
||||||
logger.error("MJML errors");
|
logger.error("SMTP errors");
|
||||||
logger.error(mjmlStatus?.errors);
|
logger.error(smtpStatus?.errors);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (channelAppConfiguration.sendgridConfigurationId) {
|
|
||||||
logger.debug("Channel has assigned Sendgrid configuration");
|
logger.debug("Channel has assigned Sendgrid configuration");
|
||||||
|
|
||||||
const sendgridConfigurationService = new SendgridConfigurationService({
|
const sendgridConfigurationService = new SendgridConfigurationService({
|
||||||
|
@ -84,11 +61,12 @@ export const sendEventMessages = async ({
|
||||||
saleorApiUrl: authData.saleorApiUrl,
|
saleorApiUrl: authData.saleorApiUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
const sendgridConfiguration = await sendgridConfigurationService.getConfiguration({
|
const availableSendgridConfigurations = await sendgridConfigurationService.getConfigurations({
|
||||||
id: channelAppConfiguration.sendgridConfigurationId,
|
active: true,
|
||||||
|
availableInChannel: channel,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (sendgridConfiguration) {
|
for (const sendgridConfiguration of availableSendgridConfigurations) {
|
||||||
const sendgridStatus = await sendSendgrid({
|
const sendgridStatus = await sendSendgrid({
|
||||||
event,
|
event,
|
||||||
payload,
|
payload,
|
||||||
|
@ -101,5 +79,4 @@ export const sendEventMessages = async ({
|
||||||
logger.error(sendgridStatus?.errors);
|
logger.error(sendgridStatus?.errors);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,121 +0,0 @@
|
||||||
import { messageEventTypes } from "../../event-handlers/message-event-types";
|
|
||||||
import { MjmlConfig as MjmlConfigurationRoot, MjmlConfiguration } from "./mjml-config";
|
|
||||||
import { defaultMjmlTemplates, defaultMjmlSubjectTemplates } from "../default-templates";
|
|
||||||
import { generateRandomId } from "../../../lib/generate-random-id";
|
|
||||||
|
|
||||||
export const getDefaultEventsConfiguration = (): MjmlConfiguration["events"] =>
|
|
||||||
messageEventTypes.map((eventType) => ({
|
|
||||||
active: true,
|
|
||||||
eventType: eventType,
|
|
||||||
template: defaultMjmlTemplates[eventType],
|
|
||||||
subject: defaultMjmlSubjectTemplates[eventType],
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const getDefaultEmptyConfiguration = (): MjmlConfiguration => {
|
|
||||||
const defaultConfig: MjmlConfiguration = {
|
|
||||||
id: "",
|
|
||||||
active: true,
|
|
||||||
configurationName: "",
|
|
||||||
senderName: "",
|
|
||||||
senderEmail: "",
|
|
||||||
smtpHost: "",
|
|
||||||
smtpPort: "",
|
|
||||||
smtpUser: "",
|
|
||||||
smtpPassword: "",
|
|
||||||
encryption: "NONE",
|
|
||||||
events: getDefaultEventsConfiguration(),
|
|
||||||
};
|
|
||||||
|
|
||||||
return defaultConfig;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface GetConfigurationArgs {
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getConfiguration =
|
|
||||||
(mjmlConfigRoot: MjmlConfigurationRoot | null | undefined) =>
|
|
||||||
({ id }: GetConfigurationArgs) => {
|
|
||||||
if (!mjmlConfigRoot || !mjmlConfigRoot.configurations) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return mjmlConfigRoot.configurations.find((c) => c.id === id);
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface FilterConfigurationsArgs {
|
|
||||||
ids?: string[];
|
|
||||||
active?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getConfigurations =
|
|
||||||
(mjmlConfigRoot: MjmlConfigurationRoot | null | undefined) =>
|
|
||||||
(filter: FilterConfigurationsArgs | undefined): MjmlConfiguration[] => {
|
|
||||||
if (!mjmlConfigRoot || !mjmlConfigRoot.configurations) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
let filtered = mjmlConfigRoot.configurations;
|
|
||||||
|
|
||||||
if (filter?.ids?.length) {
|
|
||||||
filtered = filtered.filter((c) => filter?.ids?.includes(c.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter?.active !== undefined) {
|
|
||||||
filtered = filtered.filter((c) => c.active === filter.active);
|
|
||||||
}
|
|
||||||
|
|
||||||
return filtered;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createConfiguration =
|
|
||||||
(mjmlConfigRoot: MjmlConfigurationRoot | null | undefined) =>
|
|
||||||
(mjmlConfiguration: Omit<MjmlConfiguration, "id" | "events">) => {
|
|
||||||
const mjmlConfigNormalized = structuredClone(mjmlConfigRoot) ?? { configurations: [] };
|
|
||||||
|
|
||||||
// for creating a new configurations, the ID has to be generated
|
|
||||||
const newConfiguration = {
|
|
||||||
...mjmlConfiguration,
|
|
||||||
id: generateRandomId(),
|
|
||||||
events: getDefaultEventsConfiguration(),
|
|
||||||
};
|
|
||||||
mjmlConfigNormalized.configurations.push(newConfiguration);
|
|
||||||
return mjmlConfigNormalized;
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateConfiguration =
|
|
||||||
(mjmlConfig: MjmlConfigurationRoot | null | undefined) =>
|
|
||||||
(mjmlConfiguration: MjmlConfiguration) => {
|
|
||||||
const mjmlConfigNormalized = structuredClone(mjmlConfig) ?? { configurations: [] };
|
|
||||||
|
|
||||||
const configurationIndex = mjmlConfigNormalized.configurations.findIndex(
|
|
||||||
(configuration) => configuration.id === mjmlConfiguration.id
|
|
||||||
);
|
|
||||||
|
|
||||||
mjmlConfigNormalized.configurations[configurationIndex] = mjmlConfiguration;
|
|
||||||
return mjmlConfigNormalized;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface DeleteConfigurationArgs {
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteConfiguration =
|
|
||||||
(mjmlConfig: MjmlConfigurationRoot | null | undefined) =>
|
|
||||||
({ id }: DeleteConfigurationArgs) => {
|
|
||||||
const mjmlConfigNormalized = structuredClone(mjmlConfig) ?? { configurations: [] };
|
|
||||||
|
|
||||||
mjmlConfigNormalized.configurations = mjmlConfigNormalized.configurations.filter(
|
|
||||||
(configuration) => configuration.id !== id
|
|
||||||
);
|
|
||||||
|
|
||||||
return mjmlConfigNormalized;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MjmlConfigContainer = {
|
|
||||||
createConfiguration,
|
|
||||||
getConfiguration,
|
|
||||||
updateConfiguration,
|
|
||||||
deleteConfiguration,
|
|
||||||
getConfigurations,
|
|
||||||
};
|
|
|
@ -1,52 +0,0 @@
|
||||||
import { z } from "zod";
|
|
||||||
import { messageEventTypes } from "../../event-handlers/message-event-types";
|
|
||||||
import { smtpEncryptionTypes } from "./mjml-config";
|
|
||||||
|
|
||||||
export const mjmlConfigurationEventObjectSchema = z.object({
|
|
||||||
active: z.boolean(),
|
|
||||||
eventType: z.enum(messageEventTypes),
|
|
||||||
template: z.string().min(1),
|
|
||||||
subject: z.string().min(1),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const mjmlConfigurationBaseObjectSchema = z.object({
|
|
||||||
active: z.boolean(),
|
|
||||||
configurationName: z.string().min(1),
|
|
||||||
senderName: z.string().min(1),
|
|
||||||
senderEmail: z.string().email().min(5),
|
|
||||||
smtpHost: z.string().min(1),
|
|
||||||
smtpPort: z.string(),
|
|
||||||
smtpUser: z.string(),
|
|
||||||
smtpPassword: z.string(),
|
|
||||||
encryption: z.enum(smtpEncryptionTypes),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const mjmlCreateConfigurationSchema = mjmlConfigurationBaseObjectSchema;
|
|
||||||
export const mjmlUpdateOrCreateConfigurationSchema = mjmlConfigurationBaseObjectSchema.merge(
|
|
||||||
z.object({
|
|
||||||
id: z.string().optional(),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
export const mjmlGetConfigurationInputSchema = z.object({
|
|
||||||
id: z.string(),
|
|
||||||
});
|
|
||||||
export const mjmlDeleteConfigurationInputSchema = z.object({
|
|
||||||
id: z.string(),
|
|
||||||
});
|
|
||||||
export const mjmlGetConfigurationsInputSchema = z
|
|
||||||
.object({
|
|
||||||
ids: z.array(z.string()).optional(),
|
|
||||||
active: z.boolean().optional(),
|
|
||||||
})
|
|
||||||
.optional();
|
|
||||||
|
|
||||||
export const mjmlUpdateEventConfigurationInputSchema = z
|
|
||||||
.object({
|
|
||||||
configurationId: z.string(),
|
|
||||||
})
|
|
||||||
.merge(mjmlConfigurationEventObjectSchema);
|
|
||||||
|
|
||||||
export const mjmlGetEventConfigurationInputSchema = z.object({
|
|
||||||
configurationId: z.string(),
|
|
||||||
eventType: z.enum(messageEventTypes),
|
|
||||||
});
|
|
|
@ -1,30 +0,0 @@
|
||||||
import { MessageEventTypes } from "../../event-handlers/message-event-types";
|
|
||||||
|
|
||||||
export interface MjmlEventConfiguration {
|
|
||||||
active: boolean;
|
|
||||||
eventType: MessageEventTypes;
|
|
||||||
template: string;
|
|
||||||
subject: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const smtpEncryptionTypes = ["NONE", "TLS", "SSL"] as const;
|
|
||||||
|
|
||||||
export type SmtpEncryptionType = (typeof smtpEncryptionTypes)[number];
|
|
||||||
|
|
||||||
export interface MjmlConfiguration {
|
|
||||||
id: string;
|
|
||||||
active: boolean;
|
|
||||||
configurationName: string;
|
|
||||||
senderName: string;
|
|
||||||
senderEmail: string;
|
|
||||||
smtpHost: string;
|
|
||||||
smtpPort: string;
|
|
||||||
smtpUser: string;
|
|
||||||
smtpPassword: string;
|
|
||||||
encryption: SmtpEncryptionType;
|
|
||||||
events: MjmlEventConfiguration[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MjmlConfig = {
|
|
||||||
configurations: MjmlConfiguration[];
|
|
||||||
};
|
|
|
@ -1,303 +0,0 @@
|
||||||
import { Controller, useForm } from "react-hook-form";
|
|
||||||
import { Divider, TextField, TextFieldProps, Typography } from "@material-ui/core";
|
|
||||||
import { Button, makeStyles, SwitchSelector, SwitchSelectorButton } from "@saleor/macaw-ui";
|
|
||||||
import React, { useEffect } from "react";
|
|
||||||
import { MjmlConfiguration, smtpEncryptionTypes } from "../mjml-config";
|
|
||||||
import { trpcClient } from "../../../trpc/trpc-client";
|
|
||||||
import { useAppBridge, actions } from "@saleor/app-sdk/app-bridge";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useDashboardNotification } from "@saleor/apps-shared";
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => ({
|
|
||||||
field: {
|
|
||||||
marginBottom: 20,
|
|
||||||
},
|
|
||||||
editor: {
|
|
||||||
marginBottom: 20,
|
|
||||||
},
|
|
||||||
preview: {
|
|
||||||
marginBottom: 20,
|
|
||||||
},
|
|
||||||
sectionHeader: {
|
|
||||||
marginTop: 20,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
onConfigurationSaved: () => void;
|
|
||||||
initialData: MjmlConfiguration;
|
|
||||||
configurationId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MjmlConfigurationForm = (props: Props) => {
|
|
||||||
const styles = useStyles();
|
|
||||||
const { notifySuccess, notifyError } = useDashboardNotification();
|
|
||||||
|
|
||||||
const { handleSubmit, control, reset, setError } = useForm<MjmlConfiguration>({
|
|
||||||
defaultValues: props.initialData,
|
|
||||||
});
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const { mutate: createOrUpdateConfiguration } =
|
|
||||||
trpcClient.mjmlConfiguration.updateOrCreateConfiguration.useMutation({
|
|
||||||
onSuccess: async (data, variables) => {
|
|
||||||
await queryClient.cancelQueries({ queryKey: ["mjmlConfiguration", "getConfigurations"] });
|
|
||||||
|
|
||||||
// Optimistically update to the new value
|
|
||||||
queryClient.setQueryData<Array<MjmlConfiguration>>(
|
|
||||||
["mjmlConfiguration", "getConfigurations", undefined],
|
|
||||||
(old) => {
|
|
||||||
if (old) {
|
|
||||||
const index = old.findIndex((c) => c.id === data.id);
|
|
||||||
// If thats an update, replace the old one
|
|
||||||
if (index !== -1) {
|
|
||||||
old[index] = data;
|
|
||||||
return [...old];
|
|
||||||
} else {
|
|
||||||
return [...old, data];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return [data];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Trigger refetch to make sure we have a fresh data
|
|
||||||
props.onConfigurationSaved();
|
|
||||||
notifySuccess("Configuration saved");
|
|
||||||
},
|
|
||||||
onError(error) {
|
|
||||||
let isFieldErrorSet = false;
|
|
||||||
const fieldErrors = error.data?.zodError?.fieldErrors || {};
|
|
||||||
for (const fieldName in fieldErrors) {
|
|
||||||
for (const message of fieldErrors[fieldName] || []) {
|
|
||||||
isFieldErrorSet = true;
|
|
||||||
setError(fieldName as keyof MjmlConfiguration, {
|
|
||||||
type: "manual",
|
|
||||||
message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const formErrors = error.data?.zodError?.formErrors || [];
|
|
||||||
const formErrorMessage = formErrors.length ? formErrors.join("\n") : undefined;
|
|
||||||
notifyError(
|
|
||||||
"Could not save the configuration",
|
|
||||||
isFieldErrorSet ? "Submitted form contain errors" : "Error saving configuration",
|
|
||||||
formErrorMessage
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// when the configuration tab is changed, initialData change and form has to be updated
|
|
||||||
useEffect(() => {
|
|
||||||
reset(props.initialData);
|
|
||||||
}, [props.initialData, props.configurationId, reset]);
|
|
||||||
|
|
||||||
const CommonFieldProps: TextFieldProps = {
|
|
||||||
className: styles.field,
|
|
||||||
fullWidth: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const isNewConfiguration = !props.configurationId;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit((data, event) => {
|
|
||||||
createOrUpdateConfiguration({
|
|
||||||
...data,
|
|
||||||
});
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{isNewConfiguration ? (
|
|
||||||
<Typography variant="h2" paragraph>
|
|
||||||
Create a new configuration
|
|
||||||
</Typography>
|
|
||||||
) : (
|
|
||||||
<Typography variant="h2" paragraph>
|
|
||||||
Configuration
|
|
||||||
<strong>{` ${props.initialData.configurationName} `}</strong>
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
name="configurationName"
|
|
||||||
control={control}
|
|
||||||
render={({ field: { onChange, value }, fieldState: { error }, formState: { errors } }) => (
|
|
||||||
<TextField
|
|
||||||
label="Configuration name"
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
error={!!error}
|
|
||||||
helperText={
|
|
||||||
error?.message || "Name of the configuration, for example 'Production' or 'Test'"
|
|
||||||
}
|
|
||||||
{...CommonFieldProps}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="active"
|
|
||||||
render={({ field: { value, name, onChange } }) => (
|
|
||||||
<div className={styles.field}>
|
|
||||||
{/* TODO: fix types in the MacawUI */}
|
|
||||||
{/* @ts-ignore: MacawUI use wrong type for */}
|
|
||||||
<SwitchSelector key={name} className={styles.field}>
|
|
||||||
{[
|
|
||||||
{ label: "Active", value: true },
|
|
||||||
{ label: "Disabled", value: false },
|
|
||||||
].map((button) => (
|
|
||||||
// @ts-ignore: MacawUI use wrong type for SwitchSelectorButton
|
|
||||||
<SwitchSelectorButton
|
|
||||||
value={button.value.toString()}
|
|
||||||
onClick={() => onChange(button.value)}
|
|
||||||
activeTab={value.toString()}
|
|
||||||
key={button.label}
|
|
||||||
>
|
|
||||||
{button.label}
|
|
||||||
</SwitchSelectorButton>
|
|
||||||
))}
|
|
||||||
</SwitchSelector>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<Typography variant="h3" paragraph className={styles.sectionHeader}>
|
|
||||||
Sender details
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
name="senderName"
|
|
||||||
control={control}
|
|
||||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
|
||||||
<TextField
|
|
||||||
label="Sender name"
|
|
||||||
error={!!error}
|
|
||||||
helperText={error?.message || "Name which will be presented as author of the email"}
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
{...CommonFieldProps}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
name="senderEmail"
|
|
||||||
control={control}
|
|
||||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
|
||||||
<>
|
|
||||||
<TextField
|
|
||||||
label="Sender email"
|
|
||||||
value={value}
|
|
||||||
helperText={error?.message || "Email which will be presented as author of the email"}
|
|
||||||
error={!!error}
|
|
||||||
onChange={onChange}
|
|
||||||
{...CommonFieldProps}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<Typography variant="h3" paragraph className={styles.sectionHeader}>
|
|
||||||
SMTP server configuration
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
name="smtpHost"
|
|
||||||
control={control}
|
|
||||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
|
||||||
<TextField
|
|
||||||
label="SMTP server host"
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
helperText={
|
|
||||||
error?.message ||
|
|
||||||
"Address of the SMTP server, without the protocol. For example 'smtp.example.com'"
|
|
||||||
}
|
|
||||||
error={!!error}
|
|
||||||
{...CommonFieldProps}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
name="smtpPort"
|
|
||||||
control={control}
|
|
||||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
|
||||||
<TextField
|
|
||||||
label="SMTP server port"
|
|
||||||
helperText={error?.message}
|
|
||||||
error={!!error}
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
{...CommonFieldProps}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
name="smtpUser"
|
|
||||||
control={control}
|
|
||||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
|
||||||
<TextField
|
|
||||||
label="SMTP server user"
|
|
||||||
value={value}
|
|
||||||
helperText={error?.message}
|
|
||||||
error={!!error}
|
|
||||||
onChange={onChange}
|
|
||||||
{...CommonFieldProps}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
name="smtpPassword"
|
|
||||||
control={control}
|
|
||||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
|
||||||
<TextField
|
|
||||||
label="SMTP server password"
|
|
||||||
value={value}
|
|
||||||
helperText={error?.message}
|
|
||||||
error={!!error}
|
|
||||||
onChange={onChange}
|
|
||||||
{...CommonFieldProps}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="encryption"
|
|
||||||
render={({ field: { value, name, onChange } }) => (
|
|
||||||
<div className={styles.field}>
|
|
||||||
{/* TODO: fix types in the MacawUI */}
|
|
||||||
{/* @ts-ignore: MacawUI use wrong type for SwitchSelector */}
|
|
||||||
<SwitchSelector key={name}>
|
|
||||||
{smtpEncryptionTypes.map((encryptionType) => (
|
|
||||||
// @ts-ignore: MacawUI use wrong type for SwitchSelectorButton
|
|
||||||
<SwitchSelectorButton
|
|
||||||
value={encryptionType}
|
|
||||||
onClick={() => onChange(encryptionType)}
|
|
||||||
activeTab={value}
|
|
||||||
key={encryptionType}
|
|
||||||
>
|
|
||||||
{encryptionType === "NONE" ? "No encryption" : encryptionType}
|
|
||||||
</SwitchSelectorButton>
|
|
||||||
))}
|
|
||||||
</SwitchSelector>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button type="submit" fullWidth variant="primary">
|
|
||||||
Save configuration
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,160 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
import { IconButton, makeStyles } from "@saleor/macaw-ui";
|
|
||||||
import { AppColumnsLayout } from "../../../ui/app-columns-layout";
|
|
||||||
import { trpcClient } from "../../../trpc/trpc-client";
|
|
||||||
import { MjmlConfigurationForm } from "./mjml-configuration-form";
|
|
||||||
import { getDefaultEmptyConfiguration } from "../mjml-config-container";
|
|
||||||
import { NextRouter, useRouter } from "next/router";
|
|
||||||
import { mjmlUrls } from "../../urls";
|
|
||||||
import { MjmlTemplatesCard } from "./mjml-templates-card";
|
|
||||||
import { SideMenu } from "../../../app-configuration/ui/side-menu";
|
|
||||||
import { MjmlConfiguration } from "../mjml-config";
|
|
||||||
import { LoadingIndicator } from "../../../ui/loading-indicator";
|
|
||||||
import { Add } from "@material-ui/icons";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { MjmlInstructions } from "./mjml-instructions";
|
|
||||||
import { useDashboardNotification } from "@saleor/apps-shared";
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => {
|
|
||||||
return {
|
|
||||||
formContainer: {
|
|
||||||
top: 0,
|
|
||||||
},
|
|
||||||
instructionsContainer: {
|
|
||||||
padding: 15,
|
|
||||||
},
|
|
||||||
configurationColumn: {
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 20,
|
|
||||||
maxWidth: 600,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
interface MjmlConfigurationTabProps {
|
|
||||||
configurationId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const navigateToFirstConfiguration = (router: NextRouter, configurations?: MjmlConfiguration[]) => {
|
|
||||||
if (!configurations || !configurations?.length) {
|
|
||||||
router.replace(mjmlUrls.configuration());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const firstConfigurationId = configurations[0]?.id;
|
|
||||||
if (firstConfigurationId) {
|
|
||||||
router.replace(mjmlUrls.configuration(firstConfigurationId));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MjmlConfigurationTab = ({ configurationId }: MjmlConfigurationTabProps) => {
|
|
||||||
const styles = useStyles();
|
|
||||||
const { notifyError, notifySuccess } = useDashboardNotification();
|
|
||||||
const router = useRouter();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: configurations,
|
|
||||||
refetch: refetchConfigurations,
|
|
||||||
isLoading: configurationsIsLoading,
|
|
||||||
isFetching: configurationsIsFetching,
|
|
||||||
isRefetching: configurationsIsRefetching,
|
|
||||||
} = trpcClient.mjmlConfiguration.getConfigurations.useQuery(undefined, {
|
|
||||||
onSuccess(data) {
|
|
||||||
if (!configurationId) {
|
|
||||||
console.log("no conf id! navigate to first");
|
|
||||||
navigateToFirstConfiguration(router, data);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutate: deleteConfiguration } =
|
|
||||||
trpcClient.mjmlConfiguration.deleteConfiguration.useMutation({
|
|
||||||
onError: (error) => {
|
|
||||||
notifyError("Could not remove the configuration", error.message);
|
|
||||||
},
|
|
||||||
onSuccess: async (_data, variables) => {
|
|
||||||
await queryClient.cancelQueries({ queryKey: ["mjmlConfiguration", "getConfigurations"] });
|
|
||||||
// remove value from the cache after the success
|
|
||||||
queryClient.setQueryData<Array<MjmlConfiguration>>(
|
|
||||||
["mjmlConfiguration", "getConfigurations"],
|
|
||||||
(old) => {
|
|
||||||
if (old) {
|
|
||||||
const index = old.findIndex((c) => c.id === variables.id);
|
|
||||||
if (index !== -1) {
|
|
||||||
delete old[index];
|
|
||||||
return [...old];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// if we just deleted the configuration that was selected
|
|
||||||
// we have to update the URL
|
|
||||||
if (variables.id === configurationId) {
|
|
||||||
router.replace(mjmlUrls.configuration());
|
|
||||||
}
|
|
||||||
|
|
||||||
refetchConfigurations();
|
|
||||||
notifySuccess("Success", "Removed successfully");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (configurationsIsLoading || configurationsIsFetching) {
|
|
||||||
return <LoadingIndicator />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const configuration = configurations?.find((c) => c.id === configurationId?.toString());
|
|
||||||
|
|
||||||
if (configurationId && !configuration) {
|
|
||||||
return <div>Configuration not found</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppColumnsLayout>
|
|
||||||
<SideMenu
|
|
||||||
title="Configurations"
|
|
||||||
selectedItemId={configurationId}
|
|
||||||
headerToolbar={
|
|
||||||
<IconButton
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => {
|
|
||||||
router.replace(mjmlUrls.configuration());
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Add />
|
|
||||||
</IconButton>
|
|
||||||
}
|
|
||||||
onClick={(id) => router.replace(mjmlUrls.configuration(id))}
|
|
||||||
onDelete={(id) => {
|
|
||||||
deleteConfiguration({ id });
|
|
||||||
}}
|
|
||||||
items={configurations?.map((c) => ({ label: c.configurationName, id: c.id })) || []}
|
|
||||||
/>
|
|
||||||
<div className={styles.configurationColumn}>
|
|
||||||
{configurationsIsLoading || configurationsIsFetching ? (
|
|
||||||
<LoadingIndicator />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<MjmlConfigurationForm
|
|
||||||
onConfigurationSaved={() => refetchConfigurations()}
|
|
||||||
initialData={configuration || getDefaultEmptyConfiguration()}
|
|
||||||
configurationId={configurationId}
|
|
||||||
/>
|
|
||||||
{!!configurationId && !!configuration && (
|
|
||||||
<MjmlTemplatesCard
|
|
||||||
configurationId={configurationId}
|
|
||||||
configuration={configuration}
|
|
||||||
onEventChanged={() => {
|
|
||||||
refetchConfigurations();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<MjmlInstructions />
|
|
||||||
</AppColumnsLayout>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,271 +0,0 @@
|
||||||
import { Controller, useForm } from "react-hook-form";
|
|
||||||
import { CircularProgress, Grid, TextField, TextFieldProps, Typography } from "@material-ui/core";
|
|
||||||
import {
|
|
||||||
BackSmallIcon,
|
|
||||||
Button,
|
|
||||||
IconButton,
|
|
||||||
makeStyles,
|
|
||||||
SwitchSelector,
|
|
||||||
SwitchSelectorButton,
|
|
||||||
} from "@saleor/macaw-ui";
|
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import { MjmlEventConfiguration } from "../mjml-config";
|
|
||||||
import { CodeEditor } from "../../../ui/code-editor";
|
|
||||||
import { MjmlPreview } from "./mjml-preview";
|
|
||||||
import {
|
|
||||||
MessageEventTypes,
|
|
||||||
messageEventTypesLabels,
|
|
||||||
} from "../../../event-handlers/message-event-types";
|
|
||||||
import { trpcClient } from "../../../trpc/trpc-client";
|
|
||||||
import { useDebounce } from "usehooks-ts";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { mjmlUrls } from "../../urls";
|
|
||||||
import { useAppBridge, actions } from "@saleor/app-sdk/app-bridge";
|
|
||||||
import { examplePayloads } from "../../../event-handlers/default-payloads";
|
|
||||||
import { useDashboardNotification } from "@saleor/apps-shared";
|
|
||||||
|
|
||||||
const PREVIEW_DEBOUNCE_DELAY = 500;
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => ({
|
|
||||||
viewContainer: {
|
|
||||||
padding: theme.spacing(2),
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "flex-start",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing(2),
|
|
||||||
marginBottom: theme.spacing(2),
|
|
||||||
margin: "0 auto",
|
|
||||||
},
|
|
||||||
previewHeader: {
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing(1),
|
|
||||||
marginTop: theme.spacing(2),
|
|
||||||
marginBottom: theme.spacing(2),
|
|
||||||
},
|
|
||||||
|
|
||||||
field: {
|
|
||||||
marginBottom: theme.spacing(3),
|
|
||||||
},
|
|
||||||
editor: {
|
|
||||||
marginBottom: theme.spacing(3),
|
|
||||||
},
|
|
||||||
preview: {
|
|
||||||
marginBottom: theme.spacing(3),
|
|
||||||
},
|
|
||||||
form: {
|
|
||||||
maxWidth: 800,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
type EventConfigurationFormProps = {
|
|
||||||
initialData: MjmlEventConfiguration;
|
|
||||||
configurationId: string;
|
|
||||||
eventType: MessageEventTypes;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const EventConfigurationForm = ({
|
|
||||||
initialData,
|
|
||||||
configurationId,
|
|
||||||
eventType,
|
|
||||||
}: EventConfigurationFormProps) => {
|
|
||||||
const { notifySuccess, notifyError } = useDashboardNotification();
|
|
||||||
const router = useRouter();
|
|
||||||
const { appBridge } = useAppBridge();
|
|
||||||
const { handleSubmit, control, getValues, setError } = useForm<MjmlEventConfiguration>({
|
|
||||||
defaultValues: initialData,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [lastValidRenderedTemplate, setLastValidRenderedTemplate] = useState("");
|
|
||||||
|
|
||||||
const [lastValidRenderedSubject, setLastValidRenderedSubject] = useState("");
|
|
||||||
|
|
||||||
const [payload, setPayload] = useState<string>(
|
|
||||||
JSON.stringify(examplePayloads[eventType], undefined, 2)
|
|
||||||
);
|
|
||||||
|
|
||||||
const { template, subject } = getValues();
|
|
||||||
const debouncedMutationVariables = useDebounce(
|
|
||||||
{ template, subject, payload },
|
|
||||||
PREVIEW_DEBOUNCE_DELAY
|
|
||||||
);
|
|
||||||
|
|
||||||
const styles = useStyles();
|
|
||||||
|
|
||||||
const CommonFieldProps: TextFieldProps = {
|
|
||||||
className: styles.field,
|
|
||||||
fullWidth: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { mutate: fetchTemplatePreview, isLoading: isFetchingTemplatePreview } =
|
|
||||||
trpcClient.mjmlConfiguration.renderTemplate.useMutation({
|
|
||||||
onSuccess: (data) => {
|
|
||||||
if (data.renderedEmailBody) {
|
|
||||||
setLastValidRenderedTemplate(data.renderedEmailBody);
|
|
||||||
}
|
|
||||||
if (data.renderedSubject) {
|
|
||||||
setLastValidRenderedSubject(data.renderedSubject);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutate: updateEventConfiguration } =
|
|
||||||
trpcClient.mjmlConfiguration.updateEventConfiguration.useMutation({
|
|
||||||
onSuccess: (data) => {
|
|
||||||
notifySuccess("Success", "Configuration saved");
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
let isFieldErrorSet = false;
|
|
||||||
const fieldErrors = error.data?.zodError?.fieldErrors || {};
|
|
||||||
for (const fieldName in fieldErrors) {
|
|
||||||
for (const message of fieldErrors[fieldName] || []) {
|
|
||||||
isFieldErrorSet = true;
|
|
||||||
setError(fieldName as keyof MjmlEventConfiguration, {
|
|
||||||
type: "manual",
|
|
||||||
message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const formErrors = error.data?.zodError?.formErrors || [];
|
|
||||||
const formErrorMessage = formErrors.length ? formErrors.join("\n") : undefined;
|
|
||||||
|
|
||||||
notifyError(
|
|
||||||
"Could not save the configuration",
|
|
||||||
isFieldErrorSet ? "Submitted form contain errors" : "Error saving configuration",
|
|
||||||
formErrorMessage
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
template: debouncedTemplate,
|
|
||||||
subject: debouncedSubject,
|
|
||||||
payload: debouncedPayload,
|
|
||||||
} = debouncedMutationVariables;
|
|
||||||
useEffect(() => {
|
|
||||||
fetchTemplatePreview({
|
|
||||||
template: debouncedTemplate,
|
|
||||||
subject: debouncedSubject,
|
|
||||||
payload: debouncedPayload,
|
|
||||||
});
|
|
||||||
}, [debouncedPayload, debouncedSubject, debouncedTemplate, fetchTemplatePreview]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.viewContainer}>
|
|
||||||
<div className={styles.header}>
|
|
||||||
<IconButton
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => {
|
|
||||||
router.push(mjmlUrls.configuration(configurationId));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<BackSmallIcon />
|
|
||||||
</IconButton>
|
|
||||||
<Typography variant="h2">
|
|
||||||
{messageEventTypesLabels[eventType]} event configuration
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
<Grid container spacing={2}>
|
|
||||||
<Grid item xs={12} lg={7}>
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit((data, event) => {
|
|
||||||
updateEventConfiguration({ ...data, configurationId });
|
|
||||||
})}
|
|
||||||
className={styles.form}
|
|
||||||
>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="active"
|
|
||||||
render={({ field: { value, name, onChange } }) => (
|
|
||||||
<div className={styles.field}>
|
|
||||||
{/* TODO: fix types in the MacawUI */}
|
|
||||||
{/* @ts-ignore: MacawUI use wrong type for */}
|
|
||||||
<SwitchSelector key={name} className={styles.field}>
|
|
||||||
{[
|
|
||||||
{ label: "Active", value: true },
|
|
||||||
{ label: "Disabled", value: false },
|
|
||||||
].map((button) => (
|
|
||||||
// @ts-ignore: MacawUI use wrong type for SwitchSelectorButton
|
|
||||||
<SwitchSelectorButton
|
|
||||||
value={button.value.toString()}
|
|
||||||
onClick={() => onChange(button.value)}
|
|
||||||
activeTab={value.toString()}
|
|
||||||
key={button.label}
|
|
||||||
>
|
|
||||||
{button.label}
|
|
||||||
</SwitchSelectorButton>
|
|
||||||
))}
|
|
||||||
</SwitchSelector>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
name="subject"
|
|
||||||
control={control}
|
|
||||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
|
||||||
<TextField
|
|
||||||
label="Email subject"
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
error={!!error}
|
|
||||||
helperText={
|
|
||||||
!error
|
|
||||||
? "You can use variables like {{ order.number }} or {{ order.userEmail }}"
|
|
||||||
: error.message
|
|
||||||
}
|
|
||||||
{...CommonFieldProps}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="template"
|
|
||||||
render={({ field: { value, onChange } }) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={styles.editor}>
|
|
||||||
<CodeEditor
|
|
||||||
initialTemplate={value}
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
language="xml"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button type="submit" fullWidth variant="primary">
|
|
||||||
Save configuration
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={12} lg={5}>
|
|
||||||
<div>
|
|
||||||
<div className={styles.previewHeader}>
|
|
||||||
<Typography variant="h2">Preview</Typography>
|
|
||||||
{isFetchingTemplatePreview && <CircularProgress size="3rem" color="primary" />}
|
|
||||||
</div>
|
|
||||||
<Typography variant="h3" paragraph>
|
|
||||||
Subject: {lastValidRenderedSubject}
|
|
||||||
</Typography>
|
|
||||||
<div className={styles.preview}>
|
|
||||||
<MjmlPreview value={lastValidRenderedTemplate} />
|
|
||||||
</div>
|
|
||||||
<CodeEditor
|
|
||||||
initialTemplate={payload}
|
|
||||||
value={payload}
|
|
||||||
onChange={setPayload}
|
|
||||||
language="json"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,53 +0,0 @@
|
||||||
import { Link, Paper, Typography } from "@material-ui/core";
|
|
||||||
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
|
||||||
import { makeStyles } from "@saleor/macaw-ui";
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => {
|
|
||||||
return {
|
|
||||||
instructionsContainer: {
|
|
||||||
padding: 15,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export const MjmlInstructions = () => {
|
|
||||||
const styles = useStyles();
|
|
||||||
|
|
||||||
const { appBridge } = useAppBridge();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper elevation={0} className={styles.instructionsContainer}>
|
|
||||||
<Typography paragraph variant="h4">
|
|
||||||
MJML Provider
|
|
||||||
</Typography>
|
|
||||||
<Typography paragraph>
|
|
||||||
You can use this provider to send emails using MJML as a template language. The emails are
|
|
||||||
then sent using the SMTP.
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Typography paragraph>
|
|
||||||
<Link
|
|
||||||
href="https://mjml.io/"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
appBridge?.dispatch(
|
|
||||||
actions.Redirect({
|
|
||||||
to: "https://mjml.io/",
|
|
||||||
newContext: true,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Visit the MJML Homepage
|
|
||||||
</Link>
|
|
||||||
</Typography>
|
|
||||||
<Typography paragraph variant="h4">
|
|
||||||
How to configure
|
|
||||||
</Typography>
|
|
||||||
<Typography paragraph>
|
|
||||||
Create a new configuration and fill in the required fields. After the configuration is
|
|
||||||
saved, you will be able to modify the email templates.
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,128 +0,0 @@
|
||||||
import { Divider, Paper, Typography } from "@material-ui/core";
|
|
||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
EditIcon,
|
|
||||||
IconButton,
|
|
||||||
List,
|
|
||||||
ListHeader,
|
|
||||||
ListItem,
|
|
||||||
ListItemCell,
|
|
||||||
makeStyles,
|
|
||||||
SwitchSelector,
|
|
||||||
SwitchSelectorButton,
|
|
||||||
} from "@saleor/macaw-ui";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { mjmlUrls } from "../../urls";
|
|
||||||
import { messageEventTypesLabels } from "../../../event-handlers/message-event-types";
|
|
||||||
import { MjmlConfiguration } from "../mjml-config";
|
|
||||||
import { trpcClient } from "../../../trpc/trpc-client";
|
|
||||||
import { useDashboardNotification } from "@saleor/apps-shared";
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => {
|
|
||||||
return {
|
|
||||||
spaceBetween: {
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
rowActions: {
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "flex-end",
|
|
||||||
gap: theme.spacing(1),
|
|
||||||
},
|
|
||||||
tableRow: {
|
|
||||||
minHeight: "48px",
|
|
||||||
"&::after": {
|
|
||||||
display: "none",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
interface MjmlTemplatesCardProps {
|
|
||||||
configurationId: string;
|
|
||||||
configuration: MjmlConfiguration;
|
|
||||||
onEventChanged: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MjmlTemplatesCard = ({
|
|
||||||
configurationId,
|
|
||||||
configuration,
|
|
||||||
onEventChanged,
|
|
||||||
}: MjmlTemplatesCardProps) => {
|
|
||||||
const classes = useStyles();
|
|
||||||
const router = useRouter();
|
|
||||||
const { notifySuccess } = useDashboardNotification();
|
|
||||||
|
|
||||||
const { mutate: updateEventConfiguration } =
|
|
||||||
trpcClient.mjmlConfiguration.updateEventConfiguration.useMutation({
|
|
||||||
onSuccess(data, variables) {
|
|
||||||
onEventChanged();
|
|
||||||
|
|
||||||
notifySuccess(variables.active ? "Event enabled" : "Event disabled");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper elevation={0}>
|
|
||||||
<ListHeader>
|
|
||||||
<ListItem className={classes.tableRow}>
|
|
||||||
<ListItemCell>Supported events and templates</ListItemCell>
|
|
||||||
</ListItem>
|
|
||||||
</ListHeader>
|
|
||||||
<List gridTemplate={["1fr"]}>
|
|
||||||
<Divider />
|
|
||||||
{configuration.events.map((eventConfiguration) => (
|
|
||||||
<React.Fragment key={eventConfiguration.eventType}>
|
|
||||||
<ListItem className={classes.tableRow}>
|
|
||||||
<ListItemCell>
|
|
||||||
<div className={classes.spaceBetween}>
|
|
||||||
<Typography>{messageEventTypesLabels[eventConfiguration.eventType]}</Typography>
|
|
||||||
<div className={classes.rowActions}>
|
|
||||||
{/* TODO: fix types in the MacawUI */}
|
|
||||||
{/* @ts-ignore: MacawUI use wrong type for */}
|
|
||||||
<SwitchSelector key={eventConfiguration.eventType}>
|
|
||||||
{[
|
|
||||||
{ label: "Active", value: true },
|
|
||||||
{ label: "Disabled", value: false },
|
|
||||||
].map((button) => (
|
|
||||||
// @ts-ignore: MacawUI use wrong type for SwitchSelectorButton
|
|
||||||
<SwitchSelectorButton
|
|
||||||
value={button.value.toString()}
|
|
||||||
onClick={() => {
|
|
||||||
updateEventConfiguration({
|
|
||||||
configurationId,
|
|
||||||
...eventConfiguration,
|
|
||||||
active: button.value,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
activeTab={eventConfiguration.active.toString()}
|
|
||||||
key={button.label}
|
|
||||||
>
|
|
||||||
{button.label}
|
|
||||||
</SwitchSelectorButton>
|
|
||||||
))}
|
|
||||||
</SwitchSelector>
|
|
||||||
<IconButton
|
|
||||||
variant="secondary"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
router.push(
|
|
||||||
mjmlUrls.eventConfiguration(configurationId, eventConfiguration.eventType)
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<EditIcon />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ListItemCell>
|
|
||||||
</ListItem>
|
|
||||||
<Divider />
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,7 +0,0 @@
|
||||||
import { MessageEventTypes } from "../event-handlers/message-event-types";
|
|
||||||
|
|
||||||
export const mjmlUrls = {
|
|
||||||
configuration: (id?: string) => (!id ? "/configuration/mjml" : `/configuration/mjml/${id}`),
|
|
||||||
eventConfiguration: (id: string, event: MessageEventTypes) =>
|
|
||||||
`/configuration/mjml/${id}/event/${event}`,
|
|
||||||
};
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { SendgridConfigurator, PrivateMetadataSendgridConfigurator } from "./sendgrid-configurator";
|
import { SendgridConfigurator, PrivateMetadataSendgridConfigurator } from "./sendgrid-configurator";
|
||||||
import { Client } from "urql";
|
import { Client } from "urql";
|
||||||
import { createLogger } from "@saleor/apps-shared";
|
import { createLogger } from "@saleor/apps-shared";
|
||||||
import { SendgridConfig, SendgridConfiguration } from "./sendgrid-config";
|
import { SendgridConfig, SendgridConfiguration } from "./sendgrid-config-schema";
|
||||||
import { FilterConfigurationsArgs, SendgridConfigContainer } from "./sendgrid-config-container";
|
import { FilterConfigurationsArgs, SendgridConfigContainer } from "./sendgrid-config-container";
|
||||||
import { createSettingsManager } from "../../../lib/metadata-manager";
|
import { createSettingsManager } from "../../../lib/metadata-manager";
|
||||||
|
|
||||||
|
|
|
@ -1,28 +1,27 @@
|
||||||
import { generateRandomId } from "../../../lib/generate-random-id";
|
import { generateRandomId } from "../../../lib/generate-random-id";
|
||||||
|
import { isAvailableInChannel } from "../../channels/is-available-in-channel";
|
||||||
import { messageEventTypes } from "../../event-handlers/message-event-types";
|
import { messageEventTypes } from "../../event-handlers/message-event-types";
|
||||||
import {
|
import {
|
||||||
SendgridConfig as SendgridConfigurationRoot,
|
SendgridConfig as SendgridConfigurationRoot,
|
||||||
SendgridConfiguration,
|
SendgridConfiguration,
|
||||||
} from "./sendgrid-config";
|
sendgridConfigurationEventSchema,
|
||||||
|
sendgridConfigurationSchema,
|
||||||
|
} from "./sendgrid-config-schema";
|
||||||
|
|
||||||
export const getDefaultEventsConfiguration = (): SendgridConfiguration["events"] =>
|
export const getDefaultEventsConfiguration = (): SendgridConfiguration["events"] =>
|
||||||
messageEventTypes.map((eventType) => ({
|
messageEventTypes.map((eventType) => sendgridConfigurationEventSchema.parse({ eventType }));
|
||||||
active: true,
|
|
||||||
eventType: eventType,
|
|
||||||
template: "",
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const getDefaultEmptyConfiguration = (): SendgridConfiguration => {
|
export const getDefaultEmptyConfiguration = (): SendgridConfiguration => {
|
||||||
const defaultConfig: SendgridConfiguration = {
|
const defaultConfig: SendgridConfiguration = sendgridConfigurationSchema.parse({
|
||||||
id: "",
|
id: "id",
|
||||||
active: true,
|
name: "name",
|
||||||
configurationName: "",
|
apiKey: "key",
|
||||||
senderName: undefined,
|
channels: {
|
||||||
senderEmail: undefined,
|
excludedFrom: [],
|
||||||
apiKey: "",
|
restrictedTo: [],
|
||||||
sandboxMode: false,
|
},
|
||||||
events: getDefaultEventsConfiguration(),
|
events: getDefaultEventsConfiguration(),
|
||||||
};
|
});
|
||||||
|
|
||||||
return defaultConfig;
|
return defaultConfig;
|
||||||
};
|
};
|
||||||
|
@ -44,6 +43,7 @@ const getConfiguration =
|
||||||
export interface FilterConfigurationsArgs {
|
export interface FilterConfigurationsArgs {
|
||||||
ids?: string[];
|
ids?: string[];
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
|
availableInChannel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getConfigurations =
|
const getConfigurations =
|
||||||
|
@ -55,14 +55,27 @@ const getConfigurations =
|
||||||
|
|
||||||
let filtered = sendgridConfigRoot.configurations;
|
let filtered = sendgridConfigRoot.configurations;
|
||||||
|
|
||||||
if (filter?.ids?.length) {
|
if (!filter) {
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.ids?.length) {
|
||||||
filtered = filtered.filter((c) => filter?.ids?.includes(c.id));
|
filtered = filtered.filter((c) => filter?.ids?.includes(c.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter?.active !== undefined) {
|
if (filter.active !== undefined) {
|
||||||
filtered = filtered.filter((c) => c.active === filter.active);
|
filtered = filtered.filter((c) => c.active === filter.active);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filter.availableInChannel?.length) {
|
||||||
|
filtered = filtered.filter((c) =>
|
||||||
|
isAvailableInChannel({
|
||||||
|
channel: filter.availableInChannel!,
|
||||||
|
channelConfiguration: c.channels,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return filtered;
|
return filtered;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -77,6 +90,7 @@ const createConfiguration =
|
||||||
id: generateRandomId(),
|
id: generateRandomId(),
|
||||||
events: getDefaultEventsConfiguration(),
|
events: getDefaultEventsConfiguration(),
|
||||||
};
|
};
|
||||||
|
|
||||||
sendgridConfigNormalized.configurations.push(newConfiguration);
|
sendgridConfigNormalized.configurations.push(newConfiguration);
|
||||||
return sendgridConfigNormalized;
|
return sendgridConfigNormalized;
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,37 +1,26 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { messageEventTypes } from "../../event-handlers/message-event-types";
|
import { messageEventTypes } from "../../event-handlers/message-event-types";
|
||||||
|
import {
|
||||||
|
sendgridConfigurationEventSchema,
|
||||||
|
sendgridConfigurationSchema,
|
||||||
|
} from "./sendgrid-config-schema";
|
||||||
|
import { channelConfigurationSchema } from "../../channels/channel-configuration-schema";
|
||||||
|
|
||||||
export const sendgridConfigurationEventObjectSchema = z.object({
|
export const sendgridCreateConfigurationInputSchema = sendgridConfigurationSchema.pick({
|
||||||
active: z.boolean(),
|
name: true,
|
||||||
eventType: z.enum(messageEventTypes),
|
apiKey: true,
|
||||||
template: z.string(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sendgridConfigurationBaseObjectSchema = z.object({
|
export type SendgridCreateConfigurationInput = z.infer<
|
||||||
active: z.boolean(),
|
typeof sendgridCreateConfigurationInputSchema
|
||||||
configurationName: z.string().min(1),
|
>;
|
||||||
sandboxMode: z.boolean(),
|
|
||||||
apiKey: z.string().min(1),
|
export const sendgridConfigurationIdInputSchema = sendgridConfigurationSchema.pick({
|
||||||
senderName: z.string().min(1).optional(),
|
id: true,
|
||||||
senderEmail: z.string().email().min(5).optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sendgridCreateConfigurationSchema = sendgridConfigurationBaseObjectSchema.omit({
|
export type SendgridGetConfigurationIdInput = z.infer<typeof sendgridConfigurationIdInputSchema>;
|
||||||
senderEmail: true,
|
|
||||||
senderName: true,
|
|
||||||
});
|
|
||||||
export const sendgridUpdateOrCreateConfigurationSchema =
|
|
||||||
sendgridConfigurationBaseObjectSchema.merge(
|
|
||||||
z.object({
|
|
||||||
id: z.string().optional(),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
export const sendgridGetConfigurationInputSchema = z.object({
|
|
||||||
id: z.string(),
|
|
||||||
});
|
|
||||||
export const sendgridDeleteConfigurationInputSchema = z.object({
|
|
||||||
id: z.string(),
|
|
||||||
});
|
|
||||||
export const sendgridGetConfigurationsInputSchema = z
|
export const sendgridGetConfigurationsInputSchema = z
|
||||||
.object({
|
.object({
|
||||||
ids: z.array(z.string()).optional(),
|
ids: z.array(z.string()).optional(),
|
||||||
|
@ -39,13 +28,61 @@ export const sendgridGetConfigurationsInputSchema = z
|
||||||
})
|
})
|
||||||
.optional();
|
.optional();
|
||||||
|
|
||||||
|
export type SendgridGetConfigurationsInput = z.infer<typeof sendgridGetConfigurationsInputSchema>;
|
||||||
|
|
||||||
export const sendgridUpdateEventConfigurationInputSchema = z
|
export const sendgridUpdateEventConfigurationInputSchema = z
|
||||||
.object({
|
.object({
|
||||||
configurationId: z.string(),
|
configurationId: z.string(),
|
||||||
})
|
})
|
||||||
.merge(sendgridConfigurationEventObjectSchema);
|
.merge(sendgridConfigurationEventSchema);
|
||||||
|
|
||||||
|
export type SendgridUpdateEventConfigurationInput = z.infer<
|
||||||
|
typeof sendgridUpdateEventConfigurationInputSchema
|
||||||
|
>;
|
||||||
|
|
||||||
export const sendgridGetEventConfigurationInputSchema = z.object({
|
export const sendgridGetEventConfigurationInputSchema = z.object({
|
||||||
configurationId: z.string(),
|
configurationId: z.string(),
|
||||||
eventType: z.enum(messageEventTypes),
|
eventType: z.enum(messageEventTypes),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type SendgridGetEventConfigurationInput = z.infer<
|
||||||
|
typeof sendgridGetEventConfigurationInputSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const sendgridUpdateBasicInformationSchema = sendgridConfigurationSchema.pick({
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SendgridUpdateBasicInformation = z.infer<typeof sendgridUpdateBasicInformationSchema>;
|
||||||
|
|
||||||
|
export const sendgridUpdateApiConnectionSchema = sendgridConfigurationSchema.pick({
|
||||||
|
id: true,
|
||||||
|
apiKey: true,
|
||||||
|
sandboxMode: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SendgridUpdateApiConnection = z.infer<typeof sendgridUpdateApiConnectionSchema>;
|
||||||
|
|
||||||
|
export const sendgridUpdateSenderSchema = sendgridConfigurationSchema.pick({
|
||||||
|
id: true,
|
||||||
|
sender: true,
|
||||||
|
});
|
||||||
|
export type SendgridUpdateSender = z.infer<typeof sendgridUpdateSenderSchema>;
|
||||||
|
|
||||||
|
export const sendgridUpdateChannelsSchema = channelConfigurationSchema.merge(
|
||||||
|
sendgridConfigurationSchema.pick({
|
||||||
|
id: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export type SendgridUpdateChannels = z.infer<typeof sendgridUpdateChannelsSchema>;
|
||||||
|
|
||||||
|
export const sendgridUpdateEventSchema = sendgridConfigurationEventSchema.merge(
|
||||||
|
sendgridConfigurationSchema.pick({
|
||||||
|
id: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export type SendgridUpdateEvent = z.infer<typeof sendgridUpdateEventSchema>;
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
import { messageEventTypes } from "../../event-handlers/message-event-types";
|
||||||
|
import { channelConfigurationSchema } from "../../channels/channel-configuration-schema";
|
||||||
|
|
||||||
|
export const sendgridConfigurationEventSchema = z.object({
|
||||||
|
active: z.boolean().default(false),
|
||||||
|
eventType: z.enum(messageEventTypes),
|
||||||
|
template: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SendgridEventConfiguration = z.infer<typeof sendgridConfigurationEventSchema>;
|
||||||
|
|
||||||
|
export const sendgridConfigurationSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
active: z.boolean().default(true),
|
||||||
|
name: z.string().min(1),
|
||||||
|
sandboxMode: z.boolean().default(false),
|
||||||
|
apiKey: z.string().min(1),
|
||||||
|
sender: z.string().min(1).optional(),
|
||||||
|
senderEmail: z.string().email().optional(),
|
||||||
|
senderName: z.string().optional(),
|
||||||
|
channels: channelConfigurationSchema,
|
||||||
|
events: z.array(sendgridConfigurationEventSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SendgridConfiguration = z.infer<typeof sendgridConfigurationSchema>;
|
||||||
|
|
||||||
|
export const sendgridConfigSchema = z.object({
|
||||||
|
configurations: z.array(sendgridConfigurationSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SendgridConfig = z.infer<typeof sendgridConfigSchema>;
|
|
@ -1,22 +0,0 @@
|
||||||
import { MessageEventTypes } from "../../event-handlers/message-event-types";
|
|
||||||
|
|
||||||
export interface SendgridEventConfiguration {
|
|
||||||
active: boolean;
|
|
||||||
eventType: MessageEventTypes;
|
|
||||||
template: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SendgridConfiguration {
|
|
||||||
id: string;
|
|
||||||
active: boolean;
|
|
||||||
configurationName: string;
|
|
||||||
sandboxMode: boolean;
|
|
||||||
senderName?: string;
|
|
||||||
senderEmail?: string;
|
|
||||||
apiKey: string;
|
|
||||||
events: SendgridEventConfiguration[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SendgridConfig = {
|
|
||||||
configurations: SendgridConfiguration[];
|
|
||||||
};
|
|
|
@ -1,17 +1,22 @@
|
||||||
import { createLogger } from "@saleor/apps-shared";
|
import { createLogger } from "@saleor/apps-shared";
|
||||||
import {
|
import {
|
||||||
sendgridCreateConfigurationSchema,
|
sendgridConfigurationIdInputSchema,
|
||||||
sendgridDeleteConfigurationInputSchema,
|
sendgridCreateConfigurationInputSchema,
|
||||||
sendgridGetConfigurationInputSchema,
|
|
||||||
sendgridGetConfigurationsInputSchema,
|
sendgridGetConfigurationsInputSchema,
|
||||||
sendgridGetEventConfigurationInputSchema,
|
sendgridGetEventConfigurationInputSchema,
|
||||||
|
sendgridUpdateApiConnectionSchema,
|
||||||
|
sendgridUpdateBasicInformationSchema,
|
||||||
sendgridUpdateEventConfigurationInputSchema,
|
sendgridUpdateEventConfigurationInputSchema,
|
||||||
sendgridUpdateOrCreateConfigurationSchema,
|
sendgridUpdateEventSchema,
|
||||||
|
sendgridUpdateSenderSchema,
|
||||||
} from "./sendgrid-config-input-schema";
|
} from "./sendgrid-config-input-schema";
|
||||||
import { SendgridConfigurationService } from "./get-sendgrid-configuration.service";
|
import { SendgridConfigurationService } from "./get-sendgrid-configuration.service";
|
||||||
import { router } from "../../trpc/trpc-server";
|
import { router } from "../../trpc/trpc-server";
|
||||||
import { protectedClientProcedure } from "../../trpc/protected-client-procedure";
|
import { protectedClientProcedure } from "../../trpc/protected-client-procedure";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { getDefaultEmptyConfiguration } from "./sendgrid-config-container";
|
||||||
|
import { fetchSenders } from "../sendgrid-api";
|
||||||
|
import { updateChannelsInputSchema } from "../../channels/channel-configuration-schema";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Allow access only for the dashboard users and attaches the
|
* Allow access only for the dashboard users and attaches the
|
||||||
|
@ -38,7 +43,7 @@ export const sendgridConfigurationRouter = router({
|
||||||
}),
|
}),
|
||||||
getConfiguration: protectedWithConfigurationService
|
getConfiguration: protectedWithConfigurationService
|
||||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||||
.input(sendgridGetConfigurationInputSchema)
|
.input(sendgridConfigurationIdInputSchema)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
|
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
|
||||||
|
|
||||||
|
@ -56,16 +61,21 @@ export const sendgridConfigurationRouter = router({
|
||||||
}),
|
}),
|
||||||
createConfiguration: protectedWithConfigurationService
|
createConfiguration: protectedWithConfigurationService
|
||||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||||
.input(sendgridCreateConfigurationSchema)
|
.input(sendgridCreateConfigurationInputSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
|
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
|
||||||
|
|
||||||
logger.debug(input, "sendgridConfigurationRouter.create called");
|
logger.debug(input, "sendgridConfigurationRouter.create called");
|
||||||
return await ctx.configurationService.createConfiguration(input);
|
const newConfiguration = {
|
||||||
|
...getDefaultEmptyConfiguration(),
|
||||||
|
...input,
|
||||||
|
};
|
||||||
|
|
||||||
|
return await ctx.configurationService.createConfiguration(newConfiguration);
|
||||||
}),
|
}),
|
||||||
deleteConfiguration: protectedWithConfigurationService
|
deleteConfiguration: protectedWithConfigurationService
|
||||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||||
.input(sendgridDeleteConfigurationInputSchema)
|
.input(sendgridConfigurationIdInputSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
|
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
|
||||||
|
|
||||||
|
@ -81,37 +91,6 @@ export const sendgridConfigurationRouter = router({
|
||||||
await ctx.configurationService.deleteConfiguration(input);
|
await ctx.configurationService.deleteConfiguration(input);
|
||||||
return null;
|
return null;
|
||||||
}),
|
}),
|
||||||
updateOrCreateConfiguration: protectedWithConfigurationService
|
|
||||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
|
||||||
.input(sendgridUpdateOrCreateConfigurationSchema)
|
|
||||||
.mutation(async ({ ctx, input }) => {
|
|
||||||
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
|
|
||||||
|
|
||||||
logger.debug(input, "sendgridConfigurationRouter.update or create called");
|
|
||||||
|
|
||||||
const { id } = input;
|
|
||||||
|
|
||||||
if (!id) {
|
|
||||||
return await ctx.configurationService.createConfiguration(input);
|
|
||||||
} else {
|
|
||||||
const existingConfiguration = await ctx.configurationService.getConfiguration({ id });
|
|
||||||
|
|
||||||
if (!existingConfiguration) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "BAD_REQUEST",
|
|
||||||
message: "Configuration not found",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const configuration = {
|
|
||||||
id,
|
|
||||||
...input,
|
|
||||||
events: existingConfiguration.events,
|
|
||||||
};
|
|
||||||
|
|
||||||
await ctx.configurationService.updateConfiguration(configuration);
|
|
||||||
return configuration;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
getEventConfiguration: protectedWithConfigurationService
|
getEventConfiguration: protectedWithConfigurationService
|
||||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||||
.input(sendgridGetEventConfigurationInputSchema)
|
.input(sendgridGetEventConfigurationInputSchema)
|
||||||
|
@ -167,6 +146,130 @@ export const sendgridConfigurationRouter = router({
|
||||||
eventType: input.eventType,
|
eventType: input.eventType,
|
||||||
template: input.template,
|
template: input.template,
|
||||||
};
|
};
|
||||||
|
await ctx.configurationService.updateConfiguration(configuration);
|
||||||
|
return configuration;
|
||||||
|
}),
|
||||||
|
updateBasicInformation: protectedWithConfigurationService
|
||||||
|
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||||
|
.input(sendgridUpdateBasicInformationSchema)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const configuration = await ctx.configurationService.getConfiguration({
|
||||||
|
id: input.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!configuration) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Configuration not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.configurationService.updateConfiguration({ ...configuration, ...input });
|
||||||
|
return configuration;
|
||||||
|
}),
|
||||||
|
updateApiConnection: protectedWithConfigurationService
|
||||||
|
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||||
|
.input(sendgridUpdateApiConnectionSchema)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const configuration = await ctx.configurationService.getConfiguration({
|
||||||
|
id: input.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!configuration) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Configuration not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.configurationService.updateConfiguration({ ...configuration, ...input });
|
||||||
|
return configuration;
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateSender: protectedWithConfigurationService
|
||||||
|
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||||
|
.input(sendgridUpdateSenderSchema)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const configuration = await ctx.configurationService.getConfiguration({
|
||||||
|
id: input.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!configuration) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Configuration not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull fresh sender data from the API
|
||||||
|
const senders = await fetchSenders({ apiKey: configuration.apiKey })();
|
||||||
|
|
||||||
|
const chosenSender = senders.find((s) => s.value === input.sender);
|
||||||
|
|
||||||
|
if (!chosenSender) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Sender does not exist",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.configurationService.updateConfiguration({
|
||||||
|
...configuration,
|
||||||
|
...input,
|
||||||
|
senderEmail: chosenSender.from_email,
|
||||||
|
senderName: chosenSender.label,
|
||||||
|
});
|
||||||
|
return configuration;
|
||||||
|
}),
|
||||||
|
updateChannels: protectedWithConfigurationService
|
||||||
|
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||||
|
.input(updateChannelsInputSchema)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const configuration = await ctx.configurationService.getConfiguration({
|
||||||
|
id: input.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!configuration) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Configuration not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
configuration.channels = {
|
||||||
|
override: input.override,
|
||||||
|
channels: input.channels,
|
||||||
|
mode: input.mode,
|
||||||
|
};
|
||||||
|
await ctx.configurationService.updateConfiguration(configuration);
|
||||||
|
return configuration;
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateEvent: protectedWithConfigurationService
|
||||||
|
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||||
|
.input(sendgridUpdateEventSchema)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const configuration = await ctx.configurationService.getConfiguration({
|
||||||
|
id: input.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!configuration) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Configuration not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const event = configuration.events.find((e) => e.eventType === input.eventType);
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Configuration event not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
event.template = input.template;
|
||||||
|
event.active = input.active;
|
||||||
|
|
||||||
await ctx.configurationService.updateConfiguration(configuration);
|
await ctx.configurationService.updateConfiguration(configuration);
|
||||||
return configuration;
|
return configuration;
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { SendgridConfig } from "./sendgrid-config";
|
import { SendgridConfig } from "./sendgrid-config-schema";
|
||||||
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
|
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
|
||||||
|
|
||||||
export interface SendgridConfigurator {
|
export interface SendgridConfigurator {
|
||||||
|
|
|
@ -1,342 +0,0 @@
|
||||||
import { Controller, useForm } from "react-hook-form";
|
|
||||||
import {
|
|
||||||
Divider,
|
|
||||||
FormControl,
|
|
||||||
InputLabel,
|
|
||||||
MenuItem,
|
|
||||||
Select,
|
|
||||||
TextField,
|
|
||||||
TextFieldProps,
|
|
||||||
Typography,
|
|
||||||
} from "@material-ui/core";
|
|
||||||
import { Button, makeStyles, SwitchSelector, SwitchSelectorButton } from "@saleor/macaw-ui";
|
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import { SendgridConfiguration } from "../sendgrid-config";
|
|
||||||
import { trpcClient } from "../../../trpc/trpc-client";
|
|
||||||
import { useAppBridge, actions } from "@saleor/app-sdk/app-bridge";
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { fetchSenders } from "../../sendgrid-api";
|
|
||||||
import { useDashboardNotification } from "@saleor/apps-shared";
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => ({
|
|
||||||
field: {
|
|
||||||
marginBottom: 20,
|
|
||||||
},
|
|
||||||
editor: {
|
|
||||||
marginBottom: 20,
|
|
||||||
},
|
|
||||||
preview: {
|
|
||||||
marginBottom: 20,
|
|
||||||
},
|
|
||||||
sectionHeader: {
|
|
||||||
marginTop: 20,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
onConfigurationSaved: () => void;
|
|
||||||
initialData: SendgridConfiguration;
|
|
||||||
configurationId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SendgridConfigurationForm = (props: Props) => {
|
|
||||||
const styles = useStyles();
|
|
||||||
const { appBridge } = useAppBridge();
|
|
||||||
const { notifySuccess, notifyError } = useDashboardNotification();
|
|
||||||
const [senderId, setSenderId] = useState<string | undefined>(undefined);
|
|
||||||
|
|
||||||
const { handleSubmit, control, reset, setError, setValue } = useForm<SendgridConfiguration>({
|
|
||||||
defaultValues: props.initialData,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: sendersChoices, isLoading: isSendersChoicesLoading } = useQuery({
|
|
||||||
queryKey: ["sendgridSenders"],
|
|
||||||
queryFn: fetchSenders({ apiKey: props.initialData.apiKey }),
|
|
||||||
enabled: !!props.initialData.apiKey?.length,
|
|
||||||
onSuccess(data) {
|
|
||||||
// we are not keeping senders ID in the database, so we need to find the ID of the sender
|
|
||||||
// configuration contains nickname and email set up in the Sendgrid account
|
|
||||||
if (data.length) {
|
|
||||||
const sender = data?.find((sender) => sender.from_email === props.initialData.senderEmail);
|
|
||||||
if (sender?.value) {
|
|
||||||
setSenderId(sender?.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const { mutate: createOrUpdateConfiguration } =
|
|
||||||
trpcClient.sendgridConfiguration.updateOrCreateConfiguration.useMutation({
|
|
||||||
onSuccess: async (data, variables) => {
|
|
||||||
await queryClient.cancelQueries({
|
|
||||||
queryKey: ["sendgridConfiguration", "getConfigurations"],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Optimistically update to the new value
|
|
||||||
queryClient.setQueryData<Array<SendgridConfiguration>>(
|
|
||||||
["sendgridConfiguration", "getConfigurations", undefined],
|
|
||||||
(old) => {
|
|
||||||
if (old) {
|
|
||||||
const index = old.findIndex((c) => c.id === data.id);
|
|
||||||
// If thats an update, replace the old one
|
|
||||||
if (index !== -1) {
|
|
||||||
old[index] = data;
|
|
||||||
return [...old];
|
|
||||||
} else {
|
|
||||||
return [...old, data];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return [data];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Trigger refetch to make sure we have a fresh data
|
|
||||||
props.onConfigurationSaved();
|
|
||||||
notifySuccess("Configuration saved");
|
|
||||||
},
|
|
||||||
onError(error) {
|
|
||||||
let isFieldErrorSet = false;
|
|
||||||
const fieldErrors = error.data?.zodError?.fieldErrors || {};
|
|
||||||
for (const fieldName in fieldErrors) {
|
|
||||||
for (const message of fieldErrors[fieldName] || []) {
|
|
||||||
isFieldErrorSet = true;
|
|
||||||
setError(fieldName as keyof SendgridConfiguration, {
|
|
||||||
type: "manual",
|
|
||||||
message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const formErrors = error.data?.zodError?.formErrors || [];
|
|
||||||
const formErrorMessage = formErrors.length ? formErrors.join("\n") : undefined;
|
|
||||||
|
|
||||||
notifyError(
|
|
||||||
"Could not save the configuration",
|
|
||||||
isFieldErrorSet ? "Submitted form contain errors" : "Error saving configuration",
|
|
||||||
formErrorMessage
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// when the configuration tab is changed, initialData change and form has to be updated
|
|
||||||
useEffect(() => {
|
|
||||||
reset(props.initialData);
|
|
||||||
}, [props.initialData, props.configurationId, reset]);
|
|
||||||
|
|
||||||
// fill sender email and name when sender is changed
|
|
||||||
useEffect(() => {
|
|
||||||
const sender = sendersChoices?.find((choice) => choice.value === senderId);
|
|
||||||
if (sender) {
|
|
||||||
setValue("senderName", sender.nickname);
|
|
||||||
setValue("senderEmail", sender.from_email);
|
|
||||||
} else {
|
|
||||||
setValue("senderName", undefined);
|
|
||||||
setValue("senderEmail", undefined);
|
|
||||||
}
|
|
||||||
}, [senderId, sendersChoices]);
|
|
||||||
|
|
||||||
const CommonFieldProps: TextFieldProps = {
|
|
||||||
className: styles.field,
|
|
||||||
fullWidth: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const isNewConfiguration = !props.configurationId;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit((data, event) => {
|
|
||||||
createOrUpdateConfiguration({
|
|
||||||
...data,
|
|
||||||
});
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{isNewConfiguration ? (
|
|
||||||
<Typography variant="h2" paragraph>
|
|
||||||
Create a new configuration
|
|
||||||
</Typography>
|
|
||||||
) : (
|
|
||||||
<Typography variant="h2" paragraph>
|
|
||||||
Configuration
|
|
||||||
<strong>{` ${props.initialData.configurationName} `}</strong>
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
name="configurationName"
|
|
||||||
control={control}
|
|
||||||
render={({ field: { onChange, value }, fieldState: { error }, formState: { errors } }) => (
|
|
||||||
<TextField
|
|
||||||
label="Configuration name"
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
error={!!error}
|
|
||||||
helperText={
|
|
||||||
error?.message || "Name of the configuration, for example 'Production' or 'Test'"
|
|
||||||
}
|
|
||||||
{...CommonFieldProps}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="active"
|
|
||||||
render={({ field: { value, name, onChange } }) => (
|
|
||||||
<div className={styles.field}>
|
|
||||||
{/* TODO: fix types in the MacawUI */}
|
|
||||||
{/* @ts-ignore: MacawUI use wrong type for */}
|
|
||||||
<SwitchSelector key={name} className={styles.field}>
|
|
||||||
{[
|
|
||||||
{ label: "Active", value: true },
|
|
||||||
{ label: "Disabled", value: false },
|
|
||||||
].map((button) => (
|
|
||||||
// @ts-ignore: MacawUI use wrong type for SwitchSelectorButton
|
|
||||||
<SwitchSelectorButton
|
|
||||||
value={button.value.toString()}
|
|
||||||
onClick={() => onChange(button.value)}
|
|
||||||
activeTab={value.toString()}
|
|
||||||
key={button.label}
|
|
||||||
>
|
|
||||||
{button.label}
|
|
||||||
</SwitchSelectorButton>
|
|
||||||
))}
|
|
||||||
</SwitchSelector>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<Typography variant="h3" paragraph className={styles.sectionHeader}>
|
|
||||||
API configuration
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
name="apiKey"
|
|
||||||
control={control}
|
|
||||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
|
||||||
<TextField
|
|
||||||
label="Sendgrid API key"
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
helperText={
|
|
||||||
error?.message ||
|
|
||||||
"The API key can be generated at Sendgrid dashboard, in the Settings / API Keys section"
|
|
||||||
}
|
|
||||||
error={!!error}
|
|
||||||
{...CommonFieldProps}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="sandboxMode"
|
|
||||||
render={({ field: { value, name, onChange } }) => (
|
|
||||||
<div className={styles.field}>
|
|
||||||
{/* TODO: fix types in the MacawUI */}
|
|
||||||
{/* @ts-ignore: MacawUI use wrong type for */}
|
|
||||||
<SwitchSelector key={name} className={styles.field}>
|
|
||||||
{[
|
|
||||||
{ label: "Live", value: false },
|
|
||||||
{ label: "Sandbox", value: true },
|
|
||||||
].map((button) => (
|
|
||||||
// @ts-ignore: MacawUI use wrong type for SwitchSelectorButton
|
|
||||||
<SwitchSelectorButton
|
|
||||||
value={button.value.toString()}
|
|
||||||
onClick={() => onChange(button.value)}
|
|
||||||
activeTab={value.toString()}
|
|
||||||
key={button.label}
|
|
||||||
>
|
|
||||||
{button.label}
|
|
||||||
</SwitchSelectorButton>
|
|
||||||
))}
|
|
||||||
</SwitchSelector>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{/* Sender can be chosen after the API key is saved in the configuration */}
|
|
||||||
{!isNewConfiguration && (
|
|
||||||
<>
|
|
||||||
<Typography variant="h3" paragraph className={styles.sectionHeader}>
|
|
||||||
Sender details
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<FormControl className={styles.field} fullWidth>
|
|
||||||
<InputLabel>Sender</InputLabel>
|
|
||||||
<Select
|
|
||||||
variant="outlined"
|
|
||||||
value={senderId}
|
|
||||||
disabled={isSendersChoicesLoading}
|
|
||||||
onChange={(event, val) => {
|
|
||||||
if (val) {
|
|
||||||
const node = val as React.ReactElement;
|
|
||||||
setSenderId(node.props.value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSenderId(undefined);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MenuItem key="none" value={undefined}>
|
|
||||||
No sender
|
|
||||||
</MenuItem>
|
|
||||||
{!!sendersChoices &&
|
|
||||||
sendersChoices.map((choice) => (
|
|
||||||
<MenuItem key={choice.value} value={choice.value}>
|
|
||||||
{choice.label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
{!sendersChoices?.length && (
|
|
||||||
<Typography variant="caption" color="textSecondary">
|
|
||||||
Please set up and verify senders in your Sendgrid dashboard.
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
name="senderName"
|
|
||||||
control={control}
|
|
||||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
|
||||||
<TextField
|
|
||||||
label="Sender name"
|
|
||||||
disabled={true}
|
|
||||||
error={!!error}
|
|
||||||
helperText={error?.message}
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
{...CommonFieldProps}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
name="senderEmail"
|
|
||||||
control={control}
|
|
||||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
|
||||||
<>
|
|
||||||
<TextField
|
|
||||||
label="Sender email"
|
|
||||||
value={value}
|
|
||||||
disabled={true}
|
|
||||||
helperText={error?.message}
|
|
||||||
error={!!error}
|
|
||||||
onChange={onChange}
|
|
||||||
{...CommonFieldProps}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Button type="submit" fullWidth variant="primary">
|
|
||||||
Save configuration
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,166 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
import { IconButton, makeStyles } from "@saleor/macaw-ui";
|
|
||||||
import { AppColumnsLayout } from "../../../ui/app-columns-layout";
|
|
||||||
import { trpcClient } from "../../../trpc/trpc-client";
|
|
||||||
import { SendgridConfigurationForm } from "./sendgrid-configuration-form";
|
|
||||||
import { getDefaultEmptyConfiguration } from "../sendgrid-config-container";
|
|
||||||
import { NextRouter, useRouter } from "next/router";
|
|
||||||
import { SideMenu } from "../../../app-configuration/ui/side-menu";
|
|
||||||
import { SendgridConfiguration } from "../sendgrid-config";
|
|
||||||
import { LoadingIndicator } from "../../../ui/loading-indicator";
|
|
||||||
import { Add } from "@material-ui/icons";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { sendgridUrls } from "../../urls";
|
|
||||||
import { SendgridTemplatesCard } from "./sendgrid-templates-card";
|
|
||||||
import { SendgridInstructions } from "./sendgrid-instructions";
|
|
||||||
import { useDashboardNotification } from "@saleor/apps-shared";
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => {
|
|
||||||
return {
|
|
||||||
formContainer: {
|
|
||||||
top: 0,
|
|
||||||
},
|
|
||||||
instructionsContainer: {
|
|
||||||
padding: 15,
|
|
||||||
},
|
|
||||||
configurationColumn: {
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 20,
|
|
||||||
maxWidth: 700,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
interface SendgridConfigurationTabProps {
|
|
||||||
configurationId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const navigateToFirstConfiguration = (
|
|
||||||
router: NextRouter,
|
|
||||||
configurations?: SendgridConfiguration[]
|
|
||||||
) => {
|
|
||||||
if (!configurations || !configurations?.length) {
|
|
||||||
router.replace(sendgridUrls.configuration());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const firstConfigurationId = configurations[0]?.id;
|
|
||||||
if (firstConfigurationId) {
|
|
||||||
router.replace(sendgridUrls.configuration(firstConfigurationId));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SendgridConfigurationTab = ({ configurationId }: SendgridConfigurationTabProps) => {
|
|
||||||
const styles = useStyles();
|
|
||||||
const router = useRouter();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { notifySuccess, notifyError } = useDashboardNotification();
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: configurations,
|
|
||||||
refetch: refetchConfigurations,
|
|
||||||
isLoading: configurationsIsLoading,
|
|
||||||
isFetching: configurationsIsFetching,
|
|
||||||
isRefetching: configurationsIsRefetching,
|
|
||||||
} = trpcClient.sendgridConfiguration.getConfigurations.useQuery(undefined, {
|
|
||||||
onSuccess(data) {
|
|
||||||
if (!configurationId) {
|
|
||||||
console.log("no conf id! navigate to first");
|
|
||||||
navigateToFirstConfiguration(router, data);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutate: deleteConfiguration } =
|
|
||||||
trpcClient.sendgridConfiguration.deleteConfiguration.useMutation({
|
|
||||||
onError: (error) => {
|
|
||||||
notifyError("Could not remove the configuration", error.message);
|
|
||||||
},
|
|
||||||
onSuccess: async (_data, variables) => {
|
|
||||||
await queryClient.cancelQueries({
|
|
||||||
queryKey: ["sendgridConfiguration", "getConfigurations"],
|
|
||||||
});
|
|
||||||
// remove value from the cache after the success
|
|
||||||
queryClient.setQueryData<Array<SendgridConfiguration>>(
|
|
||||||
["sendgridConfiguration", "getConfigurations"],
|
|
||||||
(old) => {
|
|
||||||
if (old) {
|
|
||||||
const index = old.findIndex((c) => c.id === variables.id);
|
|
||||||
if (index !== -1) {
|
|
||||||
delete old[index];
|
|
||||||
return [...old];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// if we just deleted the configuration that was selected
|
|
||||||
// we have to update the URL
|
|
||||||
if (variables.id === configurationId) {
|
|
||||||
router.replace(sendgridUrls.configuration());
|
|
||||||
}
|
|
||||||
|
|
||||||
refetchConfigurations();
|
|
||||||
|
|
||||||
notifySuccess("Success", "Removed successfully");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (configurationsIsLoading || configurationsIsFetching) {
|
|
||||||
return <LoadingIndicator />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const configuration = configurations?.find((c) => c.id === configurationId?.toString());
|
|
||||||
|
|
||||||
if (configurationId && !configuration) {
|
|
||||||
return <div>Configuration not found</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppColumnsLayout>
|
|
||||||
<SideMenu
|
|
||||||
title="Configurations"
|
|
||||||
selectedItemId={configurationId}
|
|
||||||
headerToolbar={
|
|
||||||
<IconButton
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => {
|
|
||||||
router.replace(sendgridUrls.configuration());
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Add />
|
|
||||||
</IconButton>
|
|
||||||
}
|
|
||||||
onClick={(id) => router.replace(sendgridUrls.configuration(id))}
|
|
||||||
onDelete={(id) => {
|
|
||||||
deleteConfiguration({ id });
|
|
||||||
}}
|
|
||||||
items={configurations?.map((c) => ({ label: c.configurationName, id: c.id })) || []}
|
|
||||||
/>
|
|
||||||
<div className={styles.configurationColumn}>
|
|
||||||
{configurationsIsLoading || configurationsIsFetching ? (
|
|
||||||
<LoadingIndicator />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<SendgridConfigurationForm
|
|
||||||
onConfigurationSaved={() => refetchConfigurations()}
|
|
||||||
initialData={configuration || getDefaultEmptyConfiguration()}
|
|
||||||
configurationId={configurationId}
|
|
||||||
/>
|
|
||||||
{!!configurationId && !!configuration && (
|
|
||||||
<SendgridTemplatesCard
|
|
||||||
configurationId={configurationId}
|
|
||||||
configuration={configuration}
|
|
||||||
onEventChanged={() => {
|
|
||||||
refetchConfigurations();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<SendgridInstructions />
|
|
||||||
</AppColumnsLayout>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,219 +0,0 @@
|
||||||
import { Controller, useForm } from "react-hook-form";
|
|
||||||
import {
|
|
||||||
FormControl,
|
|
||||||
Grid,
|
|
||||||
InputLabel,
|
|
||||||
MenuItem,
|
|
||||||
Select,
|
|
||||||
TextFieldProps,
|
|
||||||
Typography,
|
|
||||||
} from "@material-ui/core";
|
|
||||||
import {
|
|
||||||
BackSmallIcon,
|
|
||||||
Button,
|
|
||||||
IconButton,
|
|
||||||
makeStyles,
|
|
||||||
SwitchSelector,
|
|
||||||
SwitchSelectorButton,
|
|
||||||
} from "@saleor/macaw-ui";
|
|
||||||
import React from "react";
|
|
||||||
import { SendgridConfiguration, SendgridEventConfiguration } from "../sendgrid-config";
|
|
||||||
import {
|
|
||||||
MessageEventTypes,
|
|
||||||
messageEventTypesLabels,
|
|
||||||
} from "../../../event-handlers/message-event-types";
|
|
||||||
import { trpcClient } from "../../../trpc/trpc-client";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { sendgridUrls } from "../../urls";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { fetchTemplates } from "../../sendgrid-api";
|
|
||||||
import { useDashboardNotification } from "@saleor/apps-shared";
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => ({
|
|
||||||
viewContainer: {
|
|
||||||
padding: theme.spacing(2),
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "flex-start",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing(2),
|
|
||||||
marginBottom: theme.spacing(2),
|
|
||||||
margin: "0 auto",
|
|
||||||
},
|
|
||||||
previewHeader: {
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing(1),
|
|
||||||
marginTop: theme.spacing(2),
|
|
||||||
marginBottom: theme.spacing(2),
|
|
||||||
},
|
|
||||||
|
|
||||||
field: {
|
|
||||||
marginBottom: theme.spacing(3),
|
|
||||||
},
|
|
||||||
editor: {
|
|
||||||
marginBottom: theme.spacing(3),
|
|
||||||
},
|
|
||||||
preview: {
|
|
||||||
marginBottom: theme.spacing(3),
|
|
||||||
},
|
|
||||||
form: {
|
|
||||||
maxWidth: 800,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
type EventConfigurationFormProps = {
|
|
||||||
initialData: SendgridEventConfiguration;
|
|
||||||
configurationId: string;
|
|
||||||
eventType: MessageEventTypes;
|
|
||||||
configuration: SendgridConfiguration;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const EventConfigurationForm = ({
|
|
||||||
initialData,
|
|
||||||
configurationId,
|
|
||||||
eventType,
|
|
||||||
configuration,
|
|
||||||
}: EventConfigurationFormProps) => {
|
|
||||||
const router = useRouter();
|
|
||||||
const { notifySuccess, notifyError } = useDashboardNotification();
|
|
||||||
|
|
||||||
const { handleSubmit, control, getValues, setError } = useForm<SendgridEventConfiguration>({
|
|
||||||
defaultValues: initialData,
|
|
||||||
});
|
|
||||||
|
|
||||||
const styles = useStyles();
|
|
||||||
|
|
||||||
const { data: templateChoices, isLoading: isTemplateChoicesLoading } = useQuery({
|
|
||||||
queryKey: ["sendgridTemplates"],
|
|
||||||
queryFn: fetchTemplates({ apiKey: configuration.apiKey }),
|
|
||||||
enabled: !!configuration.apiKey?.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
const CommonFieldProps: TextFieldProps = {
|
|
||||||
className: styles.field,
|
|
||||||
fullWidth: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { mutate: updateEventConfiguration } =
|
|
||||||
trpcClient.sendgridConfiguration.updateEventConfiguration.useMutation({
|
|
||||||
onSuccess: (data) => {
|
|
||||||
notifySuccess("Configuration saved");
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
let isFieldErrorSet = false;
|
|
||||||
const fieldErrors = error.data?.zodError?.fieldErrors || {};
|
|
||||||
for (const fieldName in fieldErrors) {
|
|
||||||
for (const message of fieldErrors[fieldName] || []) {
|
|
||||||
isFieldErrorSet = true;
|
|
||||||
setError(fieldName as keyof SendgridEventConfiguration, {
|
|
||||||
type: "manual",
|
|
||||||
message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const formErrors = error.data?.zodError?.formErrors || [];
|
|
||||||
const formErrorMessage = formErrors.length ? formErrors.join("\n") : undefined;
|
|
||||||
notifyError(
|
|
||||||
"Could not save the configuration",
|
|
||||||
isFieldErrorSet ? "Submitted form contain errors" : "Error saving configuration",
|
|
||||||
formErrorMessage
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.viewContainer}>
|
|
||||||
<div className={styles.header}>
|
|
||||||
<IconButton
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => {
|
|
||||||
router.push(sendgridUrls.configuration(configurationId));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<BackSmallIcon />
|
|
||||||
</IconButton>
|
|
||||||
<Typography variant="h2">
|
|
||||||
{messageEventTypesLabels[eventType]} event configuration
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
<Grid container spacing={2}>
|
|
||||||
<Grid item xs={12} lg={7}>
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit((data, event) => {
|
|
||||||
updateEventConfiguration({ ...data, configurationId });
|
|
||||||
})}
|
|
||||||
className={styles.form}
|
|
||||||
>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="active"
|
|
||||||
render={({ field: { value, name, onChange } }) => (
|
|
||||||
<div className={styles.field}>
|
|
||||||
{/* TODO: fix types in the MacawUI */}
|
|
||||||
{/* @ts-ignore: MacawUI use wrong type for */}
|
|
||||||
<SwitchSelector key={name} className={styles.field}>
|
|
||||||
{[
|
|
||||||
{ label: "Active", value: true },
|
|
||||||
{ label: "Disabled", value: false },
|
|
||||||
].map((button) => (
|
|
||||||
// @ts-ignore: MacawUI use wrong type for SwitchSelectorButton
|
|
||||||
<SwitchSelectorButton
|
|
||||||
value={button.value.toString()}
|
|
||||||
onClick={() => onChange(button.value)}
|
|
||||||
activeTab={value.toString()}
|
|
||||||
key={button.label}
|
|
||||||
>
|
|
||||||
{button.label}
|
|
||||||
</SwitchSelectorButton>
|
|
||||||
))}
|
|
||||||
</SwitchSelector>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="template"
|
|
||||||
render={({ field: { value, onChange } }) => {
|
|
||||||
return (
|
|
||||||
<FormControl className={styles.field} fullWidth>
|
|
||||||
<InputLabel>Template</InputLabel>
|
|
||||||
<Select
|
|
||||||
variant="outlined"
|
|
||||||
value={value}
|
|
||||||
onChange={(event, val) => {
|
|
||||||
onChange(event.target.value);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MenuItem key="none" value={undefined}>
|
|
||||||
No template
|
|
||||||
</MenuItem>
|
|
||||||
{!!templateChoices &&
|
|
||||||
templateChoices.map((choice) => (
|
|
||||||
<MenuItem key={choice.value} value={choice.value}>
|
|
||||||
{choice.label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
{!templateChoices?.length && (
|
|
||||||
<Typography variant="caption" color="textSecondary">
|
|
||||||
No templates found in your account. Visit Sendgrid dashboard and create one.
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</FormControl>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button type="submit" fullWidth variant="primary">
|
|
||||||
Save configuration
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,103 +0,0 @@
|
||||||
import { Link, Paper, Typography } from "@material-ui/core";
|
|
||||||
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
|
||||||
import { makeStyles } from "@saleor/macaw-ui";
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => {
|
|
||||||
return {
|
|
||||||
instructionsContainer: {
|
|
||||||
padding: 15,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export const SendgridInstructions = () => {
|
|
||||||
const styles = useStyles();
|
|
||||||
|
|
||||||
const { appBridge } = useAppBridge();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper elevation={0} className={styles.instructionsContainer}>
|
|
||||||
<Typography paragraph variant="h4">
|
|
||||||
Sendgrid Provider
|
|
||||||
</Typography>
|
|
||||||
<Typography paragraph>
|
|
||||||
The integration uses dynamic email templates to send the messages to your customers.
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Typography paragraph>
|
|
||||||
<Link
|
|
||||||
href="https://sendgrid.com/"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
appBridge?.dispatch(
|
|
||||||
actions.Redirect({
|
|
||||||
to: "https://sendgrid.com/",
|
|
||||||
newContext: true,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Visit the Sendgrid Homepage
|
|
||||||
</Link>
|
|
||||||
</Typography>
|
|
||||||
<Typography paragraph variant="h4">
|
|
||||||
How to configure
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Typography paragraph>
|
|
||||||
Before configuring the app, make sure you have a Sendgrid account set up. To proceed you
|
|
||||||
will need:
|
|
||||||
<br />
|
|
||||||
<Link
|
|
||||||
href="https://app.sendgrid.com/settings/api_keys"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
appBridge?.dispatch(
|
|
||||||
actions.Redirect({
|
|
||||||
to: "https://app.sendgrid.com/settings/api_keys",
|
|
||||||
newContext: true,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
API key which can be generated in the Sendgrid dashboard
|
|
||||||
</Link>
|
|
||||||
<br />
|
|
||||||
<Link
|
|
||||||
href="https://app.sendgrid.com/settings/sender_auth"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
appBridge?.dispatch(
|
|
||||||
actions.Redirect({
|
|
||||||
to: "https://app.sendgrid.com/settings/sender_auth",
|
|
||||||
newContext: true,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Verified sender account
|
|
||||||
</Link>
|
|
||||||
<br />
|
|
||||||
<Link
|
|
||||||
href="https://mc.sendgrid.com/dynamic-templates"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
appBridge?.dispatch(
|
|
||||||
actions.Redirect({
|
|
||||||
to: "https://mc.sendgrid.com/dynamic-templates",
|
|
||||||
newContext: true,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Created dynamic email templates
|
|
||||||
</Link>
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Typography paragraph>
|
|
||||||
Create a new configuration and fill in the required fields. After the configuration is
|
|
||||||
saved, you will be able to assign the email template to each of the events.
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,131 +0,0 @@
|
||||||
import { Divider, Paper, Typography } from "@material-ui/core";
|
|
||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
EditIcon,
|
|
||||||
IconButton,
|
|
||||||
List,
|
|
||||||
ListHeader,
|
|
||||||
ListItem,
|
|
||||||
ListItemCell,
|
|
||||||
makeStyles,
|
|
||||||
SwitchSelector,
|
|
||||||
SwitchSelectorButton,
|
|
||||||
} from "@saleor/macaw-ui";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { messageEventTypesLabels } from "../../../event-handlers/message-event-types";
|
|
||||||
import { trpcClient } from "../../../trpc/trpc-client";
|
|
||||||
import { useAppBridge, actions } from "@saleor/app-sdk/app-bridge";
|
|
||||||
import { SendgridConfiguration } from "../sendgrid-config";
|
|
||||||
import { sendgridUrls } from "../../urls";
|
|
||||||
import { useDashboardNotification } from "@saleor/apps-shared";
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => {
|
|
||||||
return {
|
|
||||||
spaceBetween: {
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
rowActions: {
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "flex-end",
|
|
||||||
gap: theme.spacing(1),
|
|
||||||
},
|
|
||||||
tableRow: {
|
|
||||||
minHeight: "48px",
|
|
||||||
"&::after": {
|
|
||||||
display: "none",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
interface SendgridTemplatesCardProps {
|
|
||||||
configurationId: string;
|
|
||||||
configuration: SendgridConfiguration;
|
|
||||||
onEventChanged: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SendgridTemplatesCard = ({
|
|
||||||
configurationId,
|
|
||||||
configuration,
|
|
||||||
onEventChanged,
|
|
||||||
}: SendgridTemplatesCardProps) => {
|
|
||||||
const classes = useStyles();
|
|
||||||
const router = useRouter();
|
|
||||||
const { notifySuccess } = useDashboardNotification();
|
|
||||||
|
|
||||||
const { mutate: updateEventConfiguration } =
|
|
||||||
trpcClient.sendgridConfiguration.updateEventConfiguration.useMutation({
|
|
||||||
onSuccess(_data, variables) {
|
|
||||||
onEventChanged();
|
|
||||||
notifySuccess(variables.active ? "Event enabled" : "Event disabled");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper elevation={0}>
|
|
||||||
<ListHeader>
|
|
||||||
<ListItem className={classes.tableRow}>
|
|
||||||
<ListItemCell>Supported events and templates</ListItemCell>
|
|
||||||
</ListItem>
|
|
||||||
</ListHeader>
|
|
||||||
<List gridTemplate={["1fr"]}>
|
|
||||||
<Divider />
|
|
||||||
{configuration.events.map((eventConfiguration) => (
|
|
||||||
<React.Fragment key={eventConfiguration.eventType}>
|
|
||||||
<ListItem className={classes.tableRow}>
|
|
||||||
<ListItemCell>
|
|
||||||
<div className={classes.spaceBetween}>
|
|
||||||
<Typography>{messageEventTypesLabels[eventConfiguration.eventType]}</Typography>
|
|
||||||
<div className={classes.rowActions}>
|
|
||||||
{/* TODO: fix types in the MacawUI */}
|
|
||||||
{/* @ts-ignore: MacawUI use wrong type for */}
|
|
||||||
<SwitchSelector key={eventConfiguration.eventType}>
|
|
||||||
{[
|
|
||||||
{ label: "Active", value: true },
|
|
||||||
{ label: "Disabled", value: false },
|
|
||||||
].map((button) => (
|
|
||||||
// @ts-ignore: MacawUI use wrong type for SwitchSelectorButton
|
|
||||||
<SwitchSelectorButton
|
|
||||||
value={button.value.toString()}
|
|
||||||
onClick={() => {
|
|
||||||
updateEventConfiguration({
|
|
||||||
configurationId,
|
|
||||||
...eventConfiguration,
|
|
||||||
active: button.value,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
activeTab={eventConfiguration.active.toString()}
|
|
||||||
key={button.label}
|
|
||||||
>
|
|
||||||
{button.label}
|
|
||||||
</SwitchSelectorButton>
|
|
||||||
))}
|
|
||||||
</SwitchSelector>
|
|
||||||
<IconButton
|
|
||||||
variant="secondary"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
router.push(
|
|
||||||
sendgridUrls.eventConfiguration(
|
|
||||||
configurationId,
|
|
||||||
eventConfiguration.eventType
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<EditIcon />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ListItemCell>
|
|
||||||
</ListItem>
|
|
||||||
<Divider />
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,31 +0,0 @@
|
||||||
import { MenuItem, Select } from "@material-ui/core";
|
|
||||||
|
|
||||||
interface TemplateSelectionFieldProps {
|
|
||||||
templateChoices?: { label: string; value: string }[];
|
|
||||||
value?: string;
|
|
||||||
onChange: (value: unknown) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TemplateSelectionField = ({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
templateChoices,
|
|
||||||
}: TemplateSelectionFieldProps) => {
|
|
||||||
return (
|
|
||||||
<Select
|
|
||||||
variant="outlined"
|
|
||||||
value={value}
|
|
||||||
onChange={(event, val) => onChange(event.target.value)}
|
|
||||||
>
|
|
||||||
{!templateChoices?.length ? (
|
|
||||||
<MenuItem value="">No templates available</MenuItem>
|
|
||||||
) : (
|
|
||||||
templateChoices.map((choice) => (
|
|
||||||
<MenuItem key={choice.value} value={choice.value}>
|
|
||||||
{choice.label}
|
|
||||||
</MenuItem>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Select>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { createLogger } from "@saleor/apps-shared";
|
import { createLogger } from "@saleor/apps-shared";
|
||||||
import { SendgridConfiguration } from "./configuration/sendgrid-config";
|
import { SendgridConfiguration } from "./configuration/sendgrid-config-schema";
|
||||||
import { MailService } from "@sendgrid/mail";
|
import { MailService } from "@sendgrid/mail";
|
||||||
import { MessageEventTypes } from "../event-handlers/message-event-types";
|
import { MessageEventTypes } from "../event-handlers/message-event-types";
|
||||||
|
|
||||||
|
|
|
@ -30,7 +30,7 @@ export const fetchTemplates =
|
||||||
};
|
};
|
||||||
const templates =
|
const templates =
|
||||||
resJson.result?.map((r) => ({
|
resJson.result?.map((r) => ({
|
||||||
value: r.id,
|
value: r.id.toString(),
|
||||||
label: r.name,
|
label: r.name,
|
||||||
})) || [];
|
})) || [];
|
||||||
return templates;
|
return templates;
|
||||||
|
@ -65,7 +65,7 @@ export const fetchSenders =
|
||||||
};
|
};
|
||||||
const senders =
|
const senders =
|
||||||
resJson.results?.map((r) => ({
|
resJson.results?.map((r) => ({
|
||||||
value: r.id,
|
value: r.id.toString(),
|
||||||
label: `${r.nickname} (${r.from_email})`,
|
label: `${r.nickname} (${r.from_email})`,
|
||||||
nickname: r.nickname,
|
nickname: r.nickname,
|
||||||
from_email: r.from_email,
|
from_email: r.from_email,
|
||||||
|
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { SendgridConfiguration } from "../configuration/sendgrid-config-schema";
|
||||||
|
import { BoxWithBorder } from "../../../components/box-with-border";
|
||||||
|
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
||||||
|
import { defaultPadding } from "../../../components/ui-defaults";
|
||||||
|
import { useDashboardNotification } from "@saleor/apps-shared";
|
||||||
|
import { trpcClient } from "../../trpc/trpc-client";
|
||||||
|
import {
|
||||||
|
SendgridUpdateApiConnection,
|
||||||
|
sendgridUpdateApiConnectionSchema,
|
||||||
|
} from "../configuration/sendgrid-config-input-schema";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { BoxFooter } from "../../../components/box-footer";
|
||||||
|
import { SectionWithDescription } from "../../../components/section-with-description";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { setBackendErrors } from "../../../lib/set-backend-errors";
|
||||||
|
import { Input } from "@saleor/react-hook-form-macaw";
|
||||||
|
|
||||||
|
interface ApiConnectionSectionProps {
|
||||||
|
configuration: SendgridConfiguration;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ApiConnectionSection = ({ configuration }: ApiConnectionSectionProps) => {
|
||||||
|
const { notifySuccess, notifyError } = useDashboardNotification();
|
||||||
|
|
||||||
|
const { handleSubmit, control, setError, register } = useForm<SendgridUpdateApiConnection>({
|
||||||
|
defaultValues: {
|
||||||
|
id: configuration.id,
|
||||||
|
apiKey: configuration.apiKey,
|
||||||
|
sandboxMode: configuration.sandboxMode,
|
||||||
|
},
|
||||||
|
resolver: zodResolver(sendgridUpdateApiConnectionSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const trpcContext = trpcClient.useContext();
|
||||||
|
const { mutate } = trpcClient.sendgridConfiguration.updateApiConnection.useMutation({
|
||||||
|
onSuccess: async (data, variables) => {
|
||||||
|
notifySuccess("Configuration saved");
|
||||||
|
trpcContext.sendgridConfiguration.invalidate();
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
setBackendErrors<SendgridUpdateApiConnection>({
|
||||||
|
error,
|
||||||
|
setError,
|
||||||
|
notifyError,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionWithDescription title="API Connection">
|
||||||
|
<BoxWithBorder>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit((data, event) => {
|
||||||
|
mutate({
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Box padding={defaultPadding} display="flex" flexDirection="column" gap={10}>
|
||||||
|
<Input
|
||||||
|
label="API Key"
|
||||||
|
name="apiKey"
|
||||||
|
control={control}
|
||||||
|
helperText="Name of the configuration, for example 'Production' or 'Test'"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" {...register("sandboxMode")} />
|
||||||
|
<Text paddingLeft={defaultPadding}>Sandbox mode</Text>
|
||||||
|
</label>
|
||||||
|
</Box>
|
||||||
|
<BoxFooter>
|
||||||
|
<Button type="submit">Save provider</Button>
|
||||||
|
</BoxFooter>
|
||||||
|
</form>
|
||||||
|
</BoxWithBorder>
|
||||||
|
</SectionWithDescription>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { SendgridConfiguration } from "../configuration/sendgrid-config-schema";
|
||||||
|
import { BoxWithBorder } from "../../../components/box-with-border";
|
||||||
|
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
||||||
|
import { defaultPadding } from "../../../components/ui-defaults";
|
||||||
|
import { useDashboardNotification } from "@saleor/apps-shared";
|
||||||
|
import { trpcClient } from "../../trpc/trpc-client";
|
||||||
|
import {
|
||||||
|
SendgridUpdateBasicInformation,
|
||||||
|
sendgridUpdateBasicInformationSchema,
|
||||||
|
} from "../configuration/sendgrid-config-input-schema";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { BoxFooter } from "../../../components/box-footer";
|
||||||
|
import { SectionWithDescription } from "../../../components/section-with-description";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { setBackendErrors } from "../../../lib/set-backend-errors";
|
||||||
|
import { Input } from "@saleor/react-hook-form-macaw";
|
||||||
|
|
||||||
|
interface SendgridBasicInformationSectionProps {
|
||||||
|
configuration: SendgridConfiguration;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SendgridBasicInformationSection = ({
|
||||||
|
configuration,
|
||||||
|
}: SendgridBasicInformationSectionProps) => {
|
||||||
|
const { notifySuccess, notifyError } = useDashboardNotification();
|
||||||
|
const { handleSubmit, control, setError, register } = useForm<SendgridUpdateBasicInformation>({
|
||||||
|
defaultValues: {
|
||||||
|
id: configuration.id,
|
||||||
|
name: configuration.name,
|
||||||
|
active: configuration.active,
|
||||||
|
},
|
||||||
|
resolver: zodResolver(sendgridUpdateBasicInformationSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const trpcContext = trpcClient.useContext();
|
||||||
|
const { mutate } = trpcClient.sendgridConfiguration.updateBasicInformation.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
notifySuccess("Configuration saved");
|
||||||
|
trpcContext.sendgridConfiguration.invalidate();
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
setBackendErrors<SendgridUpdateBasicInformation>({
|
||||||
|
error,
|
||||||
|
setError,
|
||||||
|
notifyError,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionWithDescription
|
||||||
|
title="Connect Sendgrid"
|
||||||
|
description={
|
||||||
|
<Text>
|
||||||
|
Provide unique name for your configuration - you can create more than one. For example -
|
||||||
|
production and development. Then, pass your API Key. Obtain it here.
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<BoxWithBorder>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit((data, event) => {
|
||||||
|
mutate({
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Box padding={defaultPadding} display="flex" flexDirection="column" gap={10}>
|
||||||
|
<Input
|
||||||
|
label="Configuration name"
|
||||||
|
control={control}
|
||||||
|
name="name"
|
||||||
|
helperText="Name of the configuration, for example 'Production' or 'Test'"
|
||||||
|
/>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" placeholder="Enabled" {...register("active")} />
|
||||||
|
<Text paddingLeft={defaultPadding}>Active</Text>
|
||||||
|
</label>
|
||||||
|
</Box>
|
||||||
|
<BoxFooter>
|
||||||
|
<Button type="submit">Save provider</Button>
|
||||||
|
</BoxFooter>
|
||||||
|
</form>
|
||||||
|
</BoxWithBorder>
|
||||||
|
</SectionWithDescription>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { SendgridConfiguration } from "../configuration/sendgrid-config-schema";
|
||||||
|
import { useDashboardNotification } from "@saleor/apps-shared";
|
||||||
|
import { trpcClient } from "../../trpc/trpc-client";
|
||||||
|
import { setBackendErrors } from "../../../lib/set-backend-errors";
|
||||||
|
import { UniversalChannelsSection } from "../../channels/ui/universal-channels-section";
|
||||||
|
import { UpdateChannelsInput } from "../../channels/channel-configuration-schema";
|
||||||
|
|
||||||
|
interface SendgridChannelsSectionProps {
|
||||||
|
configuration: SendgridConfiguration;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SendgridChannelsSection = ({ configuration }: SendgridChannelsSectionProps) => {
|
||||||
|
const { notifySuccess, notifyError } = useDashboardNotification();
|
||||||
|
|
||||||
|
const trpcContext = trpcClient.useContext();
|
||||||
|
const { mutate } = trpcClient.sendgridConfiguration.updateChannels.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
notifySuccess("Configuration saved");
|
||||||
|
trpcContext.sendgridConfiguration.invalidate();
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
setBackendErrors<UpdateChannelsInput>({ error, notifyError });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UniversalChannelsSection
|
||||||
|
configurationId={configuration.id}
|
||||||
|
channelConfiguration={configuration.channels}
|
||||||
|
onSubmit={mutate}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
||||||
|
import { BoxWithBorder } from "../../../components/box-with-border";
|
||||||
|
import { SectionWithDescription } from "../../../components/section-with-description";
|
||||||
|
import { defaultPadding } from "../../../components/ui-defaults";
|
||||||
|
import { BoxFooter } from "../../../components/box-footer";
|
||||||
|
import { SendgridConfiguration } from "../configuration/sendgrid-config-schema";
|
||||||
|
import { trpcClient } from "../../trpc/trpc-client";
|
||||||
|
import { useDashboardNotification } from "@saleor/apps-shared";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { SendgridGetConfigurationIdInput } from "../configuration/sendgrid-config-input-schema";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { appUrls } from "../../app-configuration/urls";
|
||||||
|
import { setBackendErrors } from "../../../lib/set-backend-errors";
|
||||||
|
|
||||||
|
interface SendgridDangerousSectionProps {
|
||||||
|
configuration: SendgridConfiguration;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SendgridDangerousSection = ({ configuration }: SendgridDangerousSectionProps) => {
|
||||||
|
const { notifySuccess, notifyError } = useDashboardNotification();
|
||||||
|
const { replace } = useRouter();
|
||||||
|
const { handleSubmit, setError } = useForm<SendgridGetConfigurationIdInput>({
|
||||||
|
defaultValues: {
|
||||||
|
id: configuration.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate } = trpcClient.sendgridConfiguration.deleteConfiguration.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
notifySuccess("Configuration removed");
|
||||||
|
replace(appUrls.configuration());
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
setBackendErrors<SendgridGetConfigurationIdInput>({ error, setError, notifyError });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionWithDescription title="Danger zone">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit((data, event) => {
|
||||||
|
mutate({
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<BoxWithBorder backgroundColor="surfaceCriticalSubdued" borderColor="criticalSubdued">
|
||||||
|
<Box padding={defaultPadding}>
|
||||||
|
<Text variant="heading" display="block">
|
||||||
|
Remove provider
|
||||||
|
</Text>
|
||||||
|
<Text display="block">You can remove provider configuration.</Text>
|
||||||
|
<Text display="block">
|
||||||
|
This operation will remove all settings related to this configuration. Data will be
|
||||||
|
permanently removed from the App.{" "}
|
||||||
|
</Text>
|
||||||
|
<Text display="block">This operation cant be undone.</Text>
|
||||||
|
<Text display="block">You still can create new configuration.</Text>
|
||||||
|
</Box>
|
||||||
|
<BoxFooter borderColor="criticalSubdued">
|
||||||
|
<Button
|
||||||
|
color="textNeutralSubdued"
|
||||||
|
backgroundColor="interactiveCriticalDefault"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Remove provider
|
||||||
|
</Button>
|
||||||
|
</BoxFooter>
|
||||||
|
</BoxWithBorder>
|
||||||
|
</form>
|
||||||
|
</SectionWithDescription>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,119 @@
|
||||||
|
import {
|
||||||
|
SendgridConfiguration,
|
||||||
|
SendgridEventConfiguration,
|
||||||
|
} from "../configuration/sendgrid-config-schema";
|
||||||
|
import { BoxWithBorder } from "../../../components/box-with-border";
|
||||||
|
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
||||||
|
import { defaultPadding } from "../../../components/ui-defaults";
|
||||||
|
import { useDashboardNotification } from "@saleor/apps-shared";
|
||||||
|
import { trpcClient } from "../../trpc/trpc-client";
|
||||||
|
import {
|
||||||
|
SendgridUpdateEvent,
|
||||||
|
sendgridUpdateEventSchema,
|
||||||
|
} from "../configuration/sendgrid-config-input-schema";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { BoxFooter } from "../../../components/box-footer";
|
||||||
|
import { SectionWithDescription } from "../../../components/section-with-description";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { fetchTemplates } from "../sendgrid-api";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { setBackendErrors } from "../../../lib/set-backend-errors";
|
||||||
|
import { Combobox } from "@saleor/react-hook-form-macaw";
|
||||||
|
|
||||||
|
interface EventBoxProps {
|
||||||
|
configuration: SendgridConfiguration;
|
||||||
|
event: SendgridEventConfiguration;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EventBox = ({ event, configuration }: EventBoxProps) => {
|
||||||
|
const { notifySuccess, notifyError } = useDashboardNotification();
|
||||||
|
|
||||||
|
const { data: templatesChoices } = useQuery({
|
||||||
|
queryKey: ["sendgridTemplates"],
|
||||||
|
queryFn: fetchTemplates({ apiKey: configuration.apiKey }),
|
||||||
|
enabled: !!configuration.apiKey?.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { handleSubmit, control, setError, register } = useForm<SendgridUpdateEvent>({
|
||||||
|
defaultValues: {
|
||||||
|
id: configuration.id,
|
||||||
|
...event,
|
||||||
|
},
|
||||||
|
resolver: zodResolver(sendgridUpdateEventSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const trpcContext = trpcClient.useContext();
|
||||||
|
const { mutate } = trpcClient.sendgridConfiguration.updateEvent.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
notifySuccess("Configuration saved");
|
||||||
|
trpcContext.sendgridConfiguration.invalidate();
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
setBackendErrors<SendgridUpdateEvent>({ error, setError, notifyError });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit((data, event) => {
|
||||||
|
mutate({
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<BoxWithBorder>
|
||||||
|
<Box padding={defaultPadding} display="flex" flexDirection="column" gap={defaultPadding}>
|
||||||
|
<Text variant="heading">{event.eventType}</Text>
|
||||||
|
{templatesChoices?.length ? (
|
||||||
|
<Combobox
|
||||||
|
name="template"
|
||||||
|
control={control}
|
||||||
|
label="Template"
|
||||||
|
options={templatesChoices.map((sender) => ({
|
||||||
|
label: sender.label,
|
||||||
|
value: sender.value,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Combobox name="template" control={control} label="Template" options={[]} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" placeholder="Enabled" {...register("active")} />
|
||||||
|
<Text paddingLeft={defaultPadding}>Active</Text>
|
||||||
|
</label>
|
||||||
|
</Box>
|
||||||
|
<BoxFooter>
|
||||||
|
<Button type="submit">Save event</Button>
|
||||||
|
</BoxFooter>
|
||||||
|
</BoxWithBorder>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SendgridEventsSectionProps {
|
||||||
|
configuration: SendgridConfiguration;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SendgridEventsSection = ({ configuration }: SendgridEventsSectionProps) => {
|
||||||
|
return (
|
||||||
|
<SectionWithDescription
|
||||||
|
title="Events"
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
<Text display="block">
|
||||||
|
Provide unique name for your configuration - you can create more than one. For example -
|
||||||
|
production and development.
|
||||||
|
</Text>
|
||||||
|
<Text display="block">Then, pass your API Key. Obtain it here.</Text>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Box display="flex" flexDirection="column" gap={defaultPadding}>
|
||||||
|
{configuration.events.map((event) => (
|
||||||
|
<EventBox key={event.eventType} configuration={configuration} event={event} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</SectionWithDescription>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { SendgridConfiguration } from "../configuration/sendgrid-config-schema";
|
||||||
|
import { BoxWithBorder } from "../../../components/box-with-border";
|
||||||
|
import { Box, Button } from "@saleor/macaw-ui/next";
|
||||||
|
import { defaultPadding } from "../../../components/ui-defaults";
|
||||||
|
import { useDashboardNotification } from "@saleor/apps-shared";
|
||||||
|
import { trpcClient } from "../../trpc/trpc-client";
|
||||||
|
import {
|
||||||
|
SendgridUpdateSender,
|
||||||
|
sendgridUpdateSenderSchema,
|
||||||
|
} from "../configuration/sendgrid-config-input-schema";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { BoxFooter } from "../../../components/box-footer";
|
||||||
|
import { SectionWithDescription } from "../../../components/section-with-description";
|
||||||
|
import { fetchSenders } from "../sendgrid-api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { setBackendErrors } from "../../../lib/set-backend-errors";
|
||||||
|
import { Combobox } from "@saleor/react-hook-form-macaw";
|
||||||
|
|
||||||
|
interface SendgridSenderSectionProps {
|
||||||
|
configuration: SendgridConfiguration;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SendgridSenderSection = ({ configuration }: SendgridSenderSectionProps) => {
|
||||||
|
const { notifySuccess, notifyError } = useDashboardNotification();
|
||||||
|
|
||||||
|
const { data: sendersChoices } = useQuery({
|
||||||
|
queryKey: ["sendgridSenders"],
|
||||||
|
queryFn: fetchSenders({ apiKey: configuration.apiKey }),
|
||||||
|
enabled: !!configuration.apiKey?.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { handleSubmit, control, setError } = useForm<SendgridUpdateSender>({
|
||||||
|
defaultValues: {
|
||||||
|
id: configuration.id,
|
||||||
|
sender: configuration.sender,
|
||||||
|
},
|
||||||
|
resolver: zodResolver(sendgridUpdateSenderSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const trpcContext = trpcClient.useContext();
|
||||||
|
const { mutate } = trpcClient.sendgridConfiguration.updateSender.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
notifySuccess("Configuration saved");
|
||||||
|
trpcContext.sendgridConfiguration.invalidate();
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
setBackendErrors<SendgridUpdateSender>({ error, setError, notifyError });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionWithDescription title="Sender">
|
||||||
|
<BoxWithBorder>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit((data, event) => {
|
||||||
|
mutate({
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Box padding={defaultPadding} display="flex" flexDirection="column" gap={10}>
|
||||||
|
{sendersChoices?.length ? (
|
||||||
|
<Combobox
|
||||||
|
name="sender"
|
||||||
|
control={control}
|
||||||
|
label="Sender"
|
||||||
|
options={sendersChoices.map((sender) => ({
|
||||||
|
label: sender.label,
|
||||||
|
value: sender.value,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Combobox name="sender" control={control} label="Sender" options={[]} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<BoxFooter>
|
||||||
|
<Button type="submit">Save provider</Button>
|
||||||
|
</BoxFooter>
|
||||||
|
</form>
|
||||||
|
</BoxWithBorder>
|
||||||
|
</SectionWithDescription>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,8 +1,8 @@
|
||||||
import { MessageEventTypes } from "../event-handlers/message-event-types";
|
import { MessageEventTypes } from "../event-handlers/message-event-types";
|
||||||
|
|
||||||
export const sendgridUrls = {
|
export const sendgridUrls = {
|
||||||
configuration: (id?: string) =>
|
newConfiguration: () => `/configuration/sendgrid/new`,
|
||||||
!id ? "/configuration/sendgrid" : `/configuration/sendgrid/${id}`,
|
configuration: (id: string) => `/configuration/sendgrid/${id}`,
|
||||||
eventConfiguration: (id: string, event: MessageEventTypes) =>
|
eventConfiguration: (id: string, event: MessageEventTypes) =>
|
||||||
`/configuration/sendgrid/${id}/event/${event}`,
|
`/configuration/sendgrid/${id}/event/${event}`,
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,20 +1,20 @@
|
||||||
import { MjmlConfigurator, PrivateMetadataMjmlConfigurator } from "./mjml-configurator";
|
import { SmtpConfigurator, PrivateMetadataSmtpConfigurator } from "./smtp-configurator";
|
||||||
import { Client } from "urql";
|
import { Client } from "urql";
|
||||||
import { createLogger } from "@saleor/apps-shared";
|
import { createLogger } from "@saleor/apps-shared";
|
||||||
import { MjmlConfig, MjmlConfiguration } from "./mjml-config";
|
import { FilterConfigurationsArgs, SmtpConfigContainer } from "./smtp-config-container";
|
||||||
import { FilterConfigurationsArgs, MjmlConfigContainer } from "./mjml-config-container";
|
|
||||||
import { createSettingsManager } from "../../../lib/metadata-manager";
|
import { createSettingsManager } from "../../../lib/metadata-manager";
|
||||||
|
import { SmtpConfig, SmtpConfiguration } from "./smtp-config-schema";
|
||||||
|
|
||||||
const logger = createLogger({
|
const logger = createLogger({
|
||||||
service: "MjmlConfigurationService",
|
service: "SmtpConfigurationService",
|
||||||
});
|
});
|
||||||
|
|
||||||
export class MjmlConfigurationService {
|
export class SmtpConfigurationService {
|
||||||
private configurationData?: MjmlConfig;
|
private configurationData?: SmtpConfig;
|
||||||
private metadataConfigurator: MjmlConfigurator;
|
private metadataConfigurator: SmtpConfigurator;
|
||||||
|
|
||||||
constructor(args: { apiClient: Client; saleorApiUrl: string; initialData?: MjmlConfig }) {
|
constructor(args: { apiClient: Client; saleorApiUrl: string; initialData?: SmtpConfig }) {
|
||||||
this.metadataConfigurator = new PrivateMetadataMjmlConfigurator(
|
this.metadataConfigurator = new PrivateMetadataSmtpConfigurator(
|
||||||
createSettingsManager(args.apiClient),
|
createSettingsManager(args.apiClient),
|
||||||
args.saleorApiUrl
|
args.saleorApiUrl
|
||||||
);
|
);
|
||||||
|
@ -61,7 +61,7 @@ export class MjmlConfigurationService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Saves configuration to Saleor API and cache it
|
// Saves configuration to Saleor API and cache it
|
||||||
async setConfigurationRoot(config: MjmlConfig) {
|
async setConfigurationRoot(config: SmtpConfig) {
|
||||||
logger.debug("Set configuration root");
|
logger.debug("Set configuration root");
|
||||||
|
|
||||||
this.configurationData = config;
|
this.configurationData = config;
|
||||||
|
@ -70,17 +70,17 @@ export class MjmlConfigurationService {
|
||||||
|
|
||||||
async getConfiguration({ id }: { id: string }) {
|
async getConfiguration({ id }: { id: string }) {
|
||||||
logger.debug("Get configuration");
|
logger.debug("Get configuration");
|
||||||
return MjmlConfigContainer.getConfiguration(await this.getConfigurationRoot())({ id });
|
return SmtpConfigContainer.getConfiguration(await this.getConfigurationRoot())({ id });
|
||||||
}
|
}
|
||||||
|
|
||||||
async getConfigurations(filter?: FilterConfigurationsArgs) {
|
async getConfigurations(filter?: FilterConfigurationsArgs) {
|
||||||
logger.debug("Get configuration");
|
logger.debug("Get configuration");
|
||||||
return MjmlConfigContainer.getConfigurations(await this.getConfigurationRoot())(filter);
|
return SmtpConfigContainer.getConfigurations(await this.getConfigurationRoot())(filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createConfiguration(config: Omit<MjmlConfiguration, "id" | "events">) {
|
async createConfiguration(config: Omit<SmtpConfiguration, "id" | "events">) {
|
||||||
logger.debug("Create configuration");
|
logger.debug("Create configuration");
|
||||||
const updatedConfigurationRoot = MjmlConfigContainer.createConfiguration(
|
const updatedConfigurationRoot = SmtpConfigContainer.createConfiguration(
|
||||||
await this.getConfigurationRoot()
|
await this.getConfigurationRoot()
|
||||||
)(config);
|
)(config);
|
||||||
|
|
||||||
|
@ -91,9 +91,9 @@ export class MjmlConfigurationService {
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateConfiguration(config: MjmlConfiguration) {
|
async updateConfiguration(config: SmtpConfiguration) {
|
||||||
logger.debug("Update configuration");
|
logger.debug("Update configuration");
|
||||||
const updatedConfigurationRoot = MjmlConfigContainer.updateConfiguration(
|
const updatedConfigurationRoot = SmtpConfigContainer.updateConfiguration(
|
||||||
await this.getConfigurationRoot()
|
await this.getConfigurationRoot()
|
||||||
)(config);
|
)(config);
|
||||||
|
|
||||||
|
@ -102,7 +102,7 @@ export class MjmlConfigurationService {
|
||||||
|
|
||||||
async deleteConfiguration({ id }: { id: string }) {
|
async deleteConfiguration({ id }: { id: string }) {
|
||||||
logger.debug("Delete configuration");
|
logger.debug("Delete configuration");
|
||||||
const updatedConfigurationRoot = MjmlConfigContainer.deleteConfiguration(
|
const updatedConfigurationRoot = SmtpConfigContainer.deleteConfiguration(
|
||||||
await this.getConfigurationRoot()
|
await this.getConfigurationRoot()
|
||||||
)({ id });
|
)({ id });
|
||||||
|
|
|
@ -0,0 +1,142 @@
|
||||||
|
import { messageEventTypes } from "../../event-handlers/message-event-types";
|
||||||
|
|
||||||
|
import { defaultMjmlTemplates, defaultMjmlSubjectTemplates } from "../default-templates";
|
||||||
|
import { generateRandomId } from "../../../lib/generate-random-id";
|
||||||
|
import {
|
||||||
|
SmtpConfiguration,
|
||||||
|
smtpConfigurationSchema,
|
||||||
|
smtpConfigurationEventSchema,
|
||||||
|
SmtpConfig,
|
||||||
|
} from "./smtp-config-schema";
|
||||||
|
import { isAvailableInChannel } from "../../channels/is-available-in-channel";
|
||||||
|
|
||||||
|
export const getDefaultEventsConfiguration = (): SmtpConfiguration["events"] =>
|
||||||
|
messageEventTypes.map((eventType) =>
|
||||||
|
smtpConfigurationEventSchema.parse({
|
||||||
|
eventType: eventType,
|
||||||
|
template: defaultMjmlTemplates[eventType],
|
||||||
|
subject: defaultMjmlSubjectTemplates[eventType],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getDefaultEmptyConfiguration = (): SmtpConfiguration => {
|
||||||
|
const defaultConfig: SmtpConfiguration = smtpConfigurationSchema.parse({
|
||||||
|
id: "id",
|
||||||
|
name: "name",
|
||||||
|
active: true,
|
||||||
|
smtpHost: "host",
|
||||||
|
smtpPort: "1024",
|
||||||
|
channels: {
|
||||||
|
excludedFrom: [],
|
||||||
|
restrictedTo: [],
|
||||||
|
},
|
||||||
|
events: getDefaultEventsConfiguration(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return defaultConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface GetConfigurationArgs {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getConfiguration =
|
||||||
|
(smtpConfigRoot: SmtpConfig | null | undefined) =>
|
||||||
|
({ id }: GetConfigurationArgs) => {
|
||||||
|
if (!smtpConfigRoot || !smtpConfigRoot.configurations) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return smtpConfigRoot.configurations.find((c) => c.id === id);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface FilterConfigurationsArgs {
|
||||||
|
ids?: string[];
|
||||||
|
availableInChannel?: string;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getConfigurations =
|
||||||
|
(smtpConfigRoot: SmtpConfig | null | undefined) =>
|
||||||
|
(filter: FilterConfigurationsArgs | undefined): SmtpConfiguration[] => {
|
||||||
|
if (!smtpConfigRoot || !smtpConfigRoot.configurations) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let filtered = smtpConfigRoot.configurations;
|
||||||
|
|
||||||
|
if (!filter) {
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.ids?.length) {
|
||||||
|
filtered = filtered.filter((c) => filter?.ids?.includes(c.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.active !== undefined) {
|
||||||
|
filtered = filtered.filter((c) => c.active === filter.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.availableInChannel?.length) {
|
||||||
|
filtered = filtered.filter((c) =>
|
||||||
|
isAvailableInChannel({
|
||||||
|
channel: filter.availableInChannel!,
|
||||||
|
channelConfiguration: c.channels,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createConfiguration =
|
||||||
|
(smtpConfigRoot: SmtpConfig | null | undefined) =>
|
||||||
|
(smtpConfiguration: Omit<SmtpConfiguration, "id" | "events">) => {
|
||||||
|
const configNormalized = structuredClone(smtpConfigRoot) ?? { configurations: [] };
|
||||||
|
|
||||||
|
// for creating a new configurations, the ID has to be generated
|
||||||
|
const newConfiguration = {
|
||||||
|
...smtpConfiguration,
|
||||||
|
id: generateRandomId(),
|
||||||
|
events: getDefaultEventsConfiguration(),
|
||||||
|
};
|
||||||
|
|
||||||
|
configNormalized.configurations.push(newConfiguration);
|
||||||
|
return configNormalized;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateConfiguration =
|
||||||
|
(smtpConfig: SmtpConfig | null | undefined) => (smtpConfiguration: SmtpConfiguration) => {
|
||||||
|
const configNormalized = structuredClone(smtpConfig) ?? { configurations: [] };
|
||||||
|
|
||||||
|
const configurationIndex = configNormalized.configurations.findIndex(
|
||||||
|
(configuration) => configuration.id === smtpConfiguration.id
|
||||||
|
);
|
||||||
|
|
||||||
|
configNormalized.configurations[configurationIndex] = smtpConfiguration;
|
||||||
|
return configNormalized;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DeleteConfigurationArgs {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteConfiguration =
|
||||||
|
(smtpConfig: SmtpConfig | null | undefined) =>
|
||||||
|
({ id }: DeleteConfigurationArgs) => {
|
||||||
|
const configNormalized = structuredClone(smtpConfig) ?? { configurations: [] };
|
||||||
|
|
||||||
|
configNormalized.configurations = configNormalized.configurations.filter(
|
||||||
|
(configuration) => configuration.id !== id
|
||||||
|
);
|
||||||
|
|
||||||
|
return configNormalized;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SmtpConfigContainer = {
|
||||||
|
createConfiguration,
|
||||||
|
getConfiguration,
|
||||||
|
updateConfiguration,
|
||||||
|
deleteConfiguration,
|
||||||
|
getConfigurations,
|
||||||
|
};
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
import { messageEventTypes } from "../../event-handlers/message-event-types";
|
||||||
|
import { smtpConfigurationEventSchema, smtpConfigurationSchema } from "./smtp-config-schema";
|
||||||
|
import { channelConfigurationSchema } from "../../channels/channel-configuration-schema";
|
||||||
|
|
||||||
|
export const smtpCreateConfigurationInputSchema = smtpConfigurationSchema.pick({
|
||||||
|
name: true,
|
||||||
|
smtpHost: true,
|
||||||
|
smtpPort: true,
|
||||||
|
smtpUser: true,
|
||||||
|
smtpPassword: true,
|
||||||
|
encryption: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SmtpCreateConfigurationInput = z.infer<typeof smtpCreateConfigurationInputSchema>;
|
||||||
|
|
||||||
|
export const smtpConfigurationIdInputSchema = smtpConfigurationSchema.pick({
|
||||||
|
id: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SmtpGetConfigurationIdInput = z.infer<typeof smtpConfigurationIdInputSchema>;
|
||||||
|
|
||||||
|
export const smtpGetConfigurationsInputSchema = z
|
||||||
|
.object({
|
||||||
|
ids: z.array(z.string()).optional(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.optional();
|
||||||
|
|
||||||
|
export type SmtpGetConfigurationsInput = z.infer<typeof smtpGetConfigurationsInputSchema>;
|
||||||
|
|
||||||
|
export const smtpUpdateBasicInformationSchema = smtpConfigurationSchema.pick({
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SmtpUpdateBasicInformation = z.infer<typeof smtpUpdateBasicInformationSchema>;
|
||||||
|
|
||||||
|
export const smtpUpdateSmtpSchema = smtpConfigurationSchema.pick({
|
||||||
|
id: true,
|
||||||
|
smtpHost: true,
|
||||||
|
smtpPort: true,
|
||||||
|
smtpPassword: true,
|
||||||
|
smtpUser: true,
|
||||||
|
encryption: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SmtpUpdateSmtp = z.infer<typeof smtpUpdateSmtpSchema>;
|
||||||
|
|
||||||
|
export const smtpUpdateSenderSchema = smtpConfigurationSchema.pick({
|
||||||
|
id: true,
|
||||||
|
senderEmail: true,
|
||||||
|
senderName: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SmtpUpdateSender = z.infer<typeof smtpUpdateSenderSchema>;
|
||||||
|
|
||||||
|
export const smtpUpdateChannelsSchema = channelConfigurationSchema.merge(
|
||||||
|
smtpConfigurationSchema.pick({
|
||||||
|
id: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export type SmtpUpdateChannels = z.infer<typeof smtpUpdateChannelsSchema>;
|
||||||
|
|
||||||
|
export const smtpUpdateEventSchema = smtpConfigurationEventSchema.merge(
|
||||||
|
smtpConfigurationSchema.pick({
|
||||||
|
id: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export type SmtpUpdateEvent = z.infer<typeof smtpUpdateEventSchema>;
|
||||||
|
|
||||||
|
export const smtpGetEventConfigurationInputSchema = smtpConfigurationIdInputSchema.merge(
|
||||||
|
z.object({
|
||||||
|
eventType: z.enum(messageEventTypes),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export type SmtpGetEventConfigurationInput = z.infer<typeof smtpGetEventConfigurationInputSchema>;
|
||||||
|
|
||||||
|
export const smtpUpdateEventConfigurationInputSchema = smtpConfigurationIdInputSchema.merge(
|
||||||
|
smtpConfigurationEventSchema
|
||||||
|
);
|
||||||
|
|
||||||
|
export type SmtpUpdateEventConfigurationInput = z.infer<
|
||||||
|
typeof smtpUpdateEventConfigurationInputSchema
|
||||||
|
>;
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
import { messageEventTypes } from "../../event-handlers/message-event-types";
|
||||||
|
import { channelConfigurationSchema } from "../../channels/channel-configuration-schema";
|
||||||
|
|
||||||
|
export const smtpEncryptionTypes = ["NONE", "TLS", "SSL"] as const;
|
||||||
|
|
||||||
|
export const smtpConfigurationEventSchema = z.object({
|
||||||
|
active: z.boolean().default(false),
|
||||||
|
eventType: z.enum(messageEventTypes),
|
||||||
|
template: z.string(),
|
||||||
|
subject: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SmtpEventConfiguration = z.infer<typeof smtpConfigurationEventSchema>;
|
||||||
|
|
||||||
|
export const smtpConfigurationSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
active: z.boolean().default(true),
|
||||||
|
name: z.string().min(1),
|
||||||
|
senderName: z.string().optional(),
|
||||||
|
senderEmail: z.string().email().min(5).optional(),
|
||||||
|
smtpHost: z.string().min(1),
|
||||||
|
smtpPort: z.string().min(1),
|
||||||
|
smtpUser: z.string().optional(),
|
||||||
|
smtpPassword: z.string().optional(),
|
||||||
|
encryption: z.enum(smtpEncryptionTypes).default("NONE"),
|
||||||
|
channels: channelConfigurationSchema,
|
||||||
|
events: z.array(smtpConfigurationEventSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SmtpConfiguration = z.infer<typeof smtpConfigurationSchema>;
|
||||||
|
|
||||||
|
export const smtpConfigSchema = z.object({
|
||||||
|
configurations: z.array(smtpConfigurationSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SmtpConfig = z.infer<typeof smtpConfigSchema>;
|
|
@ -1,20 +1,24 @@
|
||||||
import { createLogger } from "@saleor/apps-shared";
|
import { createLogger } from "@saleor/apps-shared";
|
||||||
import {
|
import { SmtpConfigurationService } from "./get-smtp-configuration.service";
|
||||||
mjmlCreateConfigurationSchema,
|
|
||||||
mjmlDeleteConfigurationInputSchema,
|
|
||||||
mjmlGetConfigurationInputSchema,
|
|
||||||
mjmlGetConfigurationsInputSchema,
|
|
||||||
mjmlGetEventConfigurationInputSchema,
|
|
||||||
mjmlUpdateEventConfigurationInputSchema,
|
|
||||||
mjmlUpdateOrCreateConfigurationSchema,
|
|
||||||
} from "./mjml-config-input-schema";
|
|
||||||
import { MjmlConfigurationService } from "./get-mjml-configuration.service";
|
|
||||||
import { router } from "../../trpc/trpc-server";
|
import { router } from "../../trpc/trpc-server";
|
||||||
import { protectedClientProcedure } from "../../trpc/protected-client-procedure";
|
import { protectedClientProcedure } from "../../trpc/protected-client-procedure";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { compileMjml } from "../compile-mjml";
|
import { compileMjml } from "../compile-mjml";
|
||||||
import Handlebars from "handlebars";
|
import Handlebars from "handlebars";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { getDefaultEmptyConfiguration } from "./smtp-config-container";
|
||||||
|
import {
|
||||||
|
smtpConfigurationIdInputSchema,
|
||||||
|
smtpCreateConfigurationInputSchema,
|
||||||
|
smtpGetConfigurationsInputSchema,
|
||||||
|
smtpGetEventConfigurationInputSchema,
|
||||||
|
smtpUpdateBasicInformationSchema,
|
||||||
|
smtpUpdateEventConfigurationInputSchema,
|
||||||
|
smtpUpdateEventSchema,
|
||||||
|
smtpUpdateSenderSchema,
|
||||||
|
smtpUpdateSmtpSchema,
|
||||||
|
} from "./smtp-config-input-schema";
|
||||||
|
import { updateChannelsInputSchema } from "../../channels/channel-configuration-schema";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Allow access only for the dashboard users and attaches the
|
* Allow access only for the dashboard users and attaches the
|
||||||
|
@ -24,7 +28,7 @@ const protectedWithConfigurationService = protectedClientProcedure.use(({ next,
|
||||||
next({
|
next({
|
||||||
ctx: {
|
ctx: {
|
||||||
...ctx,
|
...ctx,
|
||||||
configurationService: new MjmlConfigurationService({
|
configurationService: new SmtpConfigurationService({
|
||||||
apiClient: ctx.apiClient,
|
apiClient: ctx.apiClient,
|
||||||
saleorApiUrl: ctx.saleorApiUrl,
|
saleorApiUrl: ctx.saleorApiUrl,
|
||||||
}),
|
}),
|
||||||
|
@ -32,47 +36,53 @@ const protectedWithConfigurationService = protectedClientProcedure.use(({ next,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
export const mjmlConfigurationRouter = router({
|
export const smtpConfigurationRouter = router({
|
||||||
fetch: protectedWithConfigurationService.query(async ({ ctx }) => {
|
fetch: protectedWithConfigurationService.query(async ({ ctx }) => {
|
||||||
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
|
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
|
||||||
|
|
||||||
logger.debug("mjmlConfigurationRouter.fetch called");
|
logger.debug("smtpConfigurationRouter.fetch called");
|
||||||
return ctx.configurationService.getConfigurationRoot();
|
return ctx.configurationService.getConfigurationRoot();
|
||||||
}),
|
}),
|
||||||
getConfiguration: protectedWithConfigurationService
|
getConfiguration: protectedWithConfigurationService
|
||||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||||
.input(mjmlGetConfigurationInputSchema)
|
.input(smtpConfigurationIdInputSchema)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
|
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
|
||||||
|
|
||||||
logger.debug(input, "mjmlConfigurationRouter.get called");
|
logger.debug(input, "smtpConfigurationRouter.get called");
|
||||||
return ctx.configurationService.getConfiguration(input);
|
return ctx.configurationService.getConfiguration(input);
|
||||||
}),
|
}),
|
||||||
getConfigurations: protectedWithConfigurationService
|
getConfigurations: protectedWithConfigurationService
|
||||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||||
.input(mjmlGetConfigurationsInputSchema)
|
.input(smtpGetConfigurationsInputSchema)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
|
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
|
||||||
|
|
||||||
logger.debug(input, "mjmlConfigurationRouter.getConfigurations called");
|
logger.debug(input, "smtpConfigurationRouter.getConfigurations called");
|
||||||
return ctx.configurationService.getConfigurations(input);
|
return ctx.configurationService.getConfigurations(input);
|
||||||
}),
|
}),
|
||||||
createConfiguration: protectedWithConfigurationService
|
createConfiguration: protectedWithConfigurationService
|
||||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||||
.input(mjmlCreateConfigurationSchema)
|
.input(smtpCreateConfigurationInputSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
|
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
|
||||||
|
|
||||||
logger.debug(input, "mjmlConfigurationRouter.create called");
|
logger.debug(input, "smtpConfigurationRouter.create called");
|
||||||
return await ctx.configurationService.createConfiguration(input);
|
const newConfiguration = {
|
||||||
|
...getDefaultEmptyConfiguration(),
|
||||||
|
...input,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(newConfiguration, "this is newConfiguration");
|
||||||
|
return await ctx.configurationService.createConfiguration(newConfiguration);
|
||||||
}),
|
}),
|
||||||
deleteConfiguration: protectedWithConfigurationService
|
deleteConfiguration: protectedWithConfigurationService
|
||||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||||
.input(mjmlDeleteConfigurationInputSchema)
|
.input(smtpConfigurationIdInputSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
|
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
|
||||||
|
|
||||||
logger.debug(input, "mjmlConfigurationRouter.delete called");
|
logger.debug(input, "smtpConfigurationRouter.delete called");
|
||||||
const existingConfiguration = await ctx.configurationService.getConfiguration(input);
|
const existingConfiguration = await ctx.configurationService.getConfiguration(input);
|
||||||
|
|
||||||
if (!existingConfiguration) {
|
if (!existingConfiguration) {
|
||||||
|
@ -84,47 +94,16 @@ export const mjmlConfigurationRouter = router({
|
||||||
await ctx.configurationService.deleteConfiguration(input);
|
await ctx.configurationService.deleteConfiguration(input);
|
||||||
return null;
|
return null;
|
||||||
}),
|
}),
|
||||||
updateOrCreateConfiguration: protectedWithConfigurationService
|
|
||||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
|
||||||
.input(mjmlUpdateOrCreateConfigurationSchema)
|
|
||||||
.mutation(async ({ ctx, input }) => {
|
|
||||||
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
|
|
||||||
|
|
||||||
logger.debug(input, "mjmlConfigurationRouter.update or create called");
|
|
||||||
|
|
||||||
const { id } = input;
|
|
||||||
|
|
||||||
if (!id) {
|
|
||||||
return await ctx.configurationService.createConfiguration(input);
|
|
||||||
} else {
|
|
||||||
const existingConfiguration = await ctx.configurationService.getConfiguration({ id });
|
|
||||||
|
|
||||||
if (!existingConfiguration) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "BAD_REQUEST",
|
|
||||||
message: "Configuration not found",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const configuration = {
|
|
||||||
id,
|
|
||||||
...input,
|
|
||||||
events: existingConfiguration.events,
|
|
||||||
};
|
|
||||||
|
|
||||||
await ctx.configurationService.updateConfiguration(configuration);
|
|
||||||
return configuration;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
getEventConfiguration: protectedWithConfigurationService
|
getEventConfiguration: protectedWithConfigurationService
|
||||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||||
.input(mjmlGetEventConfigurationInputSchema)
|
.input(smtpGetEventConfigurationInputSchema)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
|
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
|
||||||
|
|
||||||
logger.debug(input, "mjmlConfigurationRouter.getEventConfiguration or create called");
|
logger.debug(input, "smtpConfigurationRouter.getEventConfiguration or create called");
|
||||||
|
|
||||||
const configuration = await ctx.configurationService.getConfiguration({
|
const configuration = await ctx.configurationService.getConfiguration({
|
||||||
id: input.configurationId,
|
id: input.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!configuration) {
|
if (!configuration) {
|
||||||
|
@ -146,14 +125,14 @@ export const mjmlConfigurationRouter = router({
|
||||||
}),
|
}),
|
||||||
updateEventConfiguration: protectedWithConfigurationService
|
updateEventConfiguration: protectedWithConfigurationService
|
||||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||||
.input(mjmlUpdateEventConfigurationInputSchema)
|
.input(smtpUpdateEventConfigurationInputSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
|
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
|
||||||
|
|
||||||
logger.debug(input, "mjmlConfigurationRouter.updateEventConfiguration or create called");
|
logger.debug(input, "mjmlConfigurationRouter.updateEventConfiguration or create called");
|
||||||
|
|
||||||
const configuration = await ctx.configurationService.getConfiguration({
|
const configuration = await ctx.configurationService.getConfiguration({
|
||||||
id: input.configurationId,
|
id: input.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!configuration) {
|
if (!configuration) {
|
||||||
|
@ -218,4 +197,114 @@ export const mjmlConfigurationRouter = router({
|
||||||
renderedEmailBody: renderedEmail,
|
renderedEmailBody: renderedEmail,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
updateBasicInformation: protectedWithConfigurationService
|
||||||
|
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||||
|
.input(smtpUpdateBasicInformationSchema)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const configuration = await ctx.configurationService.getConfiguration({
|
||||||
|
id: input.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!configuration) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Configuration not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.configurationService.updateConfiguration({ ...configuration, ...input });
|
||||||
|
return configuration;
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateSmtp: protectedWithConfigurationService
|
||||||
|
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||||
|
.input(smtpUpdateSmtpSchema)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const configuration = await ctx.configurationService.getConfiguration({
|
||||||
|
id: input.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!configuration) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Configuration not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.configurationService.updateConfiguration({ ...configuration, ...input });
|
||||||
|
return configuration;
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateSender: protectedWithConfigurationService
|
||||||
|
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||||
|
.input(smtpUpdateSenderSchema)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const configuration = await ctx.configurationService.getConfiguration({
|
||||||
|
id: input.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!configuration) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Configuration not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.configurationService.updateConfiguration({ ...configuration, ...input });
|
||||||
|
return configuration;
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateChannels: protectedWithConfigurationService
|
||||||
|
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||||
|
.input(updateChannelsInputSchema)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const configuration = await ctx.configurationService.getConfiguration({
|
||||||
|
id: input.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!configuration) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Configuration not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
configuration.channels = {
|
||||||
|
override: input.override,
|
||||||
|
channels: input.channels,
|
||||||
|
mode: input.mode,
|
||||||
|
};
|
||||||
|
await ctx.configurationService.updateConfiguration(configuration);
|
||||||
|
return configuration;
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateEvent: protectedWithConfigurationService
|
||||||
|
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||||
|
.input(smtpUpdateEventSchema)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const configuration = await ctx.configurationService.getConfiguration({
|
||||||
|
id: input.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!configuration) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Configuration not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const event = configuration.events.find((e) => e.eventType === input.eventType);
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Configuration event not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
event.template = input.template;
|
||||||
|
event.active = input.active;
|
||||||
|
|
||||||
|
await ctx.configurationService.updateConfiguration(configuration);
|
||||||
|
return configuration;
|
||||||
|
}),
|
||||||
});
|
});
|
|
@ -1,17 +1,17 @@
|
||||||
import { MjmlConfig } from "./mjml-config";
|
|
||||||
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
|
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
|
||||||
|
import { SmtpConfig } from "./smtp-config-schema";
|
||||||
|
|
||||||
export interface MjmlConfigurator {
|
export interface SmtpConfigurator {
|
||||||
setConfig(config: MjmlConfig): Promise<void>;
|
setConfig(config: SmtpConfig): Promise<void>;
|
||||||
getConfig(): Promise<MjmlConfig | undefined>;
|
getConfig(): Promise<SmtpConfig | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PrivateMetadataMjmlConfigurator implements MjmlConfigurator {
|
export class PrivateMetadataSmtpConfigurator implements SmtpConfigurator {
|
||||||
private metadataKey = "mjml-config";
|
private metadataKey = "smtp-config";
|
||||||
|
|
||||||
constructor(private metadataManager: SettingsManager, private saleorApiUrl: string) {}
|
constructor(private metadataManager: SettingsManager, private saleorApiUrl: string) {}
|
||||||
|
|
||||||
getConfig(): Promise<MjmlConfig | undefined> {
|
getConfig(): Promise<SmtpConfig | 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 +25,7 @@ export class PrivateMetadataMjmlConfigurator implements MjmlConfigurator {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setConfig(config: MjmlConfig): Promise<void> {
|
setConfig(config: SmtpConfig): Promise<void> {
|
||||||
return this.metadataManager.set({
|
return this.metadataManager.set({
|
||||||
key: this.metadataKey,
|
key: this.metadataKey,
|
||||||
value: JSON.stringify(config),
|
value: JSON.stringify(config),
|
|
@ -4,10 +4,10 @@ import { compileHandlebarsTemplate } from "./compile-handlebars-template";
|
||||||
import { sendEmailWithSmtp, SendMailArgs } from "./send-email-with-smtp";
|
import { sendEmailWithSmtp, SendMailArgs } from "./send-email-with-smtp";
|
||||||
import { MessageEventTypes } from "../event-handlers/message-event-types";
|
import { MessageEventTypes } from "../event-handlers/message-event-types";
|
||||||
import { htmlToPlaintext } from "./html-to-plaintext";
|
import { htmlToPlaintext } from "./html-to-plaintext";
|
||||||
import { MjmlConfiguration } from "./configuration/mjml-config";
|
import { SmtpConfiguration } from "./configuration/smtp-config-schema";
|
||||||
|
|
||||||
interface SendMjmlArgs {
|
interface SendSmtpArgs {
|
||||||
mjmlConfiguration: MjmlConfiguration;
|
smtpConfiguration: SmtpConfiguration;
|
||||||
recipientEmail: string;
|
recipientEmail: string;
|
||||||
event: MessageEventTypes;
|
event: MessageEventTypes;
|
||||||
payload: any;
|
payload: any;
|
||||||
|
@ -20,18 +20,18 @@ export interface EmailServiceResponse {
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sendMjml = async ({
|
export const sendSmtp = async ({
|
||||||
payload,
|
payload,
|
||||||
recipientEmail,
|
recipientEmail,
|
||||||
event,
|
event,
|
||||||
mjmlConfiguration,
|
smtpConfiguration,
|
||||||
}: SendMjmlArgs) => {
|
}: SendSmtpArgs) => {
|
||||||
const logger = createLogger({
|
const logger = createLogger({
|
||||||
fn: "sendMjml",
|
fn: "sendSmtp",
|
||||||
event,
|
event,
|
||||||
});
|
});
|
||||||
|
|
||||||
const eventSettings = mjmlConfiguration.events.find((e) => e.eventType === event);
|
const eventSettings = smtpConfiguration.events.find((e) => e.eventType === event);
|
||||||
|
|
||||||
if (!eventSettings) {
|
if (!eventSettings) {
|
||||||
logger.debug("No active settings for this event, skipping");
|
logger.debug("No active settings for this event, skipping");
|
||||||
|
@ -133,20 +133,20 @@ export const sendMjml = async ({
|
||||||
mailData: {
|
mailData: {
|
||||||
text: emailBodyPlaintext,
|
text: emailBodyPlaintext,
|
||||||
html: emailBodyHtml,
|
html: emailBodyHtml,
|
||||||
from: `${mjmlConfiguration.senderName} <${mjmlConfiguration.senderEmail}>`,
|
from: `${smtpConfiguration.senderName} <${smtpConfiguration.senderEmail}>`,
|
||||||
to: recipientEmail,
|
to: recipientEmail,
|
||||||
subject: emailSubject,
|
subject: emailSubject,
|
||||||
},
|
},
|
||||||
smtpSettings: {
|
smtpSettings: {
|
||||||
host: mjmlConfiguration.smtpHost,
|
host: smtpConfiguration.smtpHost,
|
||||||
port: parseInt(mjmlConfiguration.smtpPort, 10),
|
port: parseInt(smtpConfiguration.smtpPort, 10),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (mjmlConfiguration.smtpUser) {
|
if (smtpConfiguration.smtpUser) {
|
||||||
sendEmailSettings.smtpSettings.auth = {
|
sendEmailSettings.smtpSettings.auth = {
|
||||||
user: mjmlConfiguration.smtpUser,
|
user: smtpConfiguration.smtpUser,
|
||||||
pass: mjmlConfiguration.smtpPassword,
|
pass: smtpConfiguration.smtpPassword,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import React, { useCallback, useRef } from "react";
|
import React, { useCallback, useRef } from "react";
|
||||||
|
|
||||||
import Editor from "@monaco-editor/react";
|
import Editor from "@monaco-editor/react";
|
||||||
import { useTheme } from "@saleor/macaw-ui";
|
import { useTheme } from "@saleor/macaw-ui/next";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onChange(value: string): void;
|
onChange(value: string): void;
|
||||||
|
@ -11,7 +11,7 @@ type Props = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CodeEditor = ({ initialTemplate, onChange, value, language }: Props) => {
|
export const CodeEditor = ({ initialTemplate, onChange, value, language }: Props) => {
|
||||||
const { themeType } = useTheme();
|
const { theme } = useTheme();
|
||||||
const editorRef = useRef(null);
|
const editorRef = useRef(null);
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
@ -31,7 +31,7 @@ export const CodeEditor = ({ initialTemplate, onChange, value, language }: Props
|
||||||
<Editor
|
<Editor
|
||||||
height="600px"
|
height="600px"
|
||||||
value={value}
|
value={value}
|
||||||
theme={themeType === "dark" ? "vs-dark" : "vs-light"}
|
theme={theme === "defaultDark" ? "vs-dark" : "vs-light"}
|
||||||
defaultLanguage={language}
|
defaultLanguage={language}
|
||||||
defaultValue={initialTemplate}
|
defaultValue={initialTemplate}
|
||||||
onMount={handleEditorDidMount}
|
onMount={handleEditorDidMount}
|
163
apps/emails-and-messages/src/modules/smtp/ui/event-form.tsx
Normal file
163
apps/emails-and-messages/src/modules/smtp/ui/event-form.tsx
Normal file
|
@ -0,0 +1,163 @@
|
||||||
|
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
||||||
|
import { SmtpConfiguration } from "../configuration/smtp-config-schema";
|
||||||
|
import { MessageEventTypes } from "../../event-handlers/message-event-types";
|
||||||
|
import { useDashboardNotification } from "@saleor/apps-shared";
|
||||||
|
import { trpcClient } from "../../trpc/trpc-client";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import {
|
||||||
|
SmtpUpdateEventConfigurationInput,
|
||||||
|
smtpUpdateEventConfigurationInputSchema,
|
||||||
|
} from "../configuration/smtp-config-input-schema";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { CodeEditor } from "./code-edtor";
|
||||||
|
import { useDebounce } from "usehooks-ts";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { examplePayloads } from "../../event-handlers/default-payloads";
|
||||||
|
import { MjmlPreview } from "./mjml-preview";
|
||||||
|
import { defaultPadding } from "../../../components/ui-defaults";
|
||||||
|
import { setBackendErrors } from "../../../lib/set-backend-errors";
|
||||||
|
import { Input } from "@saleor/react-hook-form-macaw";
|
||||||
|
const PREVIEW_DEBOUNCE_DELAY = 500;
|
||||||
|
|
||||||
|
interface EventFormProps {
|
||||||
|
configuration: SmtpConfiguration;
|
||||||
|
eventType: MessageEventTypes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EventForm = ({ configuration, eventType }: EventFormProps) => {
|
||||||
|
const { notifySuccess, notifyError } = useDashboardNotification();
|
||||||
|
|
||||||
|
const eventConfiguration = configuration?.events.find(
|
||||||
|
(eventConfiguration) => eventConfiguration.eventType === eventType
|
||||||
|
)!; // Event conf is not optional, so we can use ! here
|
||||||
|
|
||||||
|
const { handleSubmit, control, getValues, setError } = useForm<SmtpUpdateEventConfigurationInput>(
|
||||||
|
{
|
||||||
|
defaultValues: {
|
||||||
|
id: configuration.id,
|
||||||
|
...eventConfiguration,
|
||||||
|
},
|
||||||
|
resolver: zodResolver(smtpUpdateEventConfigurationInputSchema),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const trpcContext = trpcClient.useContext();
|
||||||
|
const { mutate } = trpcClient.smtpConfiguration.updateSender.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
notifySuccess("Configuration saved");
|
||||||
|
trpcContext.smtpConfiguration.invalidate();
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
setBackendErrors<SmtpUpdateEventConfigurationInput>({
|
||||||
|
error,
|
||||||
|
setError,
|
||||||
|
notifyError,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: fetchTemplatePreview, isLoading: isFetchingTemplatePreview } =
|
||||||
|
trpcClient.smtpConfiguration.renderTemplate.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data.renderedEmailBody) {
|
||||||
|
setLastValidRenderedTemplate(data.renderedEmailBody);
|
||||||
|
}
|
||||||
|
if (data.renderedSubject) {
|
||||||
|
setLastValidRenderedSubject(data.renderedSubject);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [lastValidRenderedTemplate, setLastValidRenderedTemplate] = useState("");
|
||||||
|
|
||||||
|
const [lastValidRenderedSubject, setLastValidRenderedSubject] = useState("");
|
||||||
|
|
||||||
|
const [payload, setPayload] = useState<string>(
|
||||||
|
JSON.stringify(examplePayloads[eventType], undefined, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
const { template, subject } = getValues();
|
||||||
|
const debouncedMutationVariables = useDebounce(
|
||||||
|
{ template, subject, payload },
|
||||||
|
PREVIEW_DEBOUNCE_DELAY
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
template: debouncedTemplate,
|
||||||
|
subject: debouncedSubject,
|
||||||
|
payload: debouncedPayload,
|
||||||
|
} = debouncedMutationVariables;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTemplatePreview({
|
||||||
|
template: debouncedTemplate,
|
||||||
|
subject: debouncedSubject,
|
||||||
|
payload: debouncedPayload,
|
||||||
|
});
|
||||||
|
}, [debouncedPayload, debouncedSubject, debouncedTemplate, fetchTemplatePreview]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit((data, event) => {
|
||||||
|
mutate({
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Box display="flex" flexDirection="column" gap={defaultPadding}>
|
||||||
|
<Box display="flex" justifyContent="space-between">
|
||||||
|
<Text variant="hero">Edit template</Text>
|
||||||
|
<Button type="submit">Save</Button>
|
||||||
|
</Box>
|
||||||
|
<Box display="grid" gridTemplateColumns={{ desktop: 3, mobile: 1 }}>
|
||||||
|
<Input control={control} name="subject" label="Subject" />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box display="grid" gridTemplateColumns={{ desktop: 5, mobile: 1 }} gap={defaultPadding}>
|
||||||
|
<Box
|
||||||
|
gridColumnStart={{ desktop: "1", mobile: "1" }}
|
||||||
|
gridColumnEnd={{ desktop: "3", mobile: "6" }}
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="template"
|
||||||
|
render={({ field: { value, onChange } }) => {
|
||||||
|
return (
|
||||||
|
<CodeEditor
|
||||||
|
initialTemplate={value}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
language="xml"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
gridColumnStart={{ desktop: "3", mobile: "1" }}
|
||||||
|
gridColumnEnd={{ desktop: "5", mobile: "6" }}
|
||||||
|
>
|
||||||
|
<CodeEditor
|
||||||
|
initialTemplate={payload}
|
||||||
|
value={payload}
|
||||||
|
onChange={setPayload}
|
||||||
|
language="json"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
gridColumnStart={{ desktop: "5", mobile: "1" }}
|
||||||
|
gridColumnEnd={{ desktop: "6", mobile: "6" }}
|
||||||
|
display="flex"
|
||||||
|
flexDirection="column"
|
||||||
|
gap={defaultPadding}
|
||||||
|
>
|
||||||
|
<Text variant="heading" as="p">
|
||||||
|
Subject: {lastValidRenderedSubject}
|
||||||
|
</Text>
|
||||||
|
<MjmlPreview value={lastValidRenderedTemplate} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,4 +1,3 @@
|
||||||
import { Card } from "@material-ui/core";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
@ -7,12 +6,12 @@ type Props = {
|
||||||
|
|
||||||
export const MjmlPreview = ({ value }: Props) => {
|
export const MjmlPreview = ({ value }: Props) => {
|
||||||
return (
|
return (
|
||||||
<Card style={{ padding: "2rem", width: "100%" }}>
|
<>
|
||||||
{value?.length ? (
|
{value?.length ? (
|
||||||
<div dangerouslySetInnerHTML={{ __html: value }} />
|
<div dangerouslySetInnerHTML={{ __html: value }} />
|
||||||
) : (
|
) : (
|
||||||
<p>No template preview</p>
|
<p>No template preview</p>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { SmtpConfiguration } from "../configuration/smtp-config-schema";
|
||||||
|
import { BoxWithBorder } from "../../../components/box-with-border";
|
||||||
|
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
||||||
|
import { defaultPadding } from "../../../components/ui-defaults";
|
||||||
|
import { useDashboardNotification } from "@saleor/apps-shared";
|
||||||
|
import { trpcClient } from "../../trpc/trpc-client";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { BoxFooter } from "../../../components/box-footer";
|
||||||
|
import { SectionWithDescription } from "../../../components/section-with-description";
|
||||||
|
import {
|
||||||
|
SmtpUpdateBasicInformation,
|
||||||
|
smtpUpdateBasicInformationSchema,
|
||||||
|
} from "../configuration/smtp-config-input-schema";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { setBackendErrors } from "../../../lib/set-backend-errors";
|
||||||
|
import { Input } from "@saleor/react-hook-form-macaw";
|
||||||
|
|
||||||
|
interface SmtpBasicInformationSectionProps {
|
||||||
|
configuration: SmtpConfiguration;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SmtpBasicInformationSection = ({
|
||||||
|
configuration,
|
||||||
|
}: SmtpBasicInformationSectionProps) => {
|
||||||
|
const { notifySuccess, notifyError } = useDashboardNotification();
|
||||||
|
const { handleSubmit, control, setError, register } = useForm<SmtpUpdateBasicInformation>({
|
||||||
|
defaultValues: {
|
||||||
|
id: configuration.id,
|
||||||
|
name: configuration.name,
|
||||||
|
active: configuration.active,
|
||||||
|
},
|
||||||
|
resolver: zodResolver(smtpUpdateBasicInformationSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const trpcContext = trpcClient.useContext();
|
||||||
|
const { mutate } = trpcClient.smtpConfiguration.updateBasicInformation.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
notifySuccess("Configuration saved");
|
||||||
|
trpcContext.smtpConfiguration.invalidate();
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
setBackendErrors<SmtpUpdateBasicInformation>({
|
||||||
|
error,
|
||||||
|
setError,
|
||||||
|
notifyError,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionWithDescription
|
||||||
|
title="Connect SMTP"
|
||||||
|
description={
|
||||||
|
<Text>
|
||||||
|
Provide unique name for your configuration - you can create more than one. For example -
|
||||||
|
production and development. Then, pass your API Key. Obtain it here.
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<BoxWithBorder>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit((data, event) => {
|
||||||
|
mutate({
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Box padding={defaultPadding} display="flex" flexDirection="column" gap={10}>
|
||||||
|
<Input
|
||||||
|
name="name"
|
||||||
|
label="Configuration name"
|
||||||
|
control={control}
|
||||||
|
helperText="Name of the configuration, for example 'Production' or 'Test'"
|
||||||
|
/>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" placeholder="Enabled" {...register("active")} />
|
||||||
|
<Text paddingLeft={defaultPadding}>Active</Text>
|
||||||
|
</label>
|
||||||
|
</Box>
|
||||||
|
<BoxFooter>
|
||||||
|
<Button type="submit">Save provider</Button>
|
||||||
|
</BoxFooter>
|
||||||
|
</form>
|
||||||
|
</BoxWithBorder>
|
||||||
|
</SectionWithDescription>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { SmtpConfiguration } from "../configuration/smtp-config-schema";
|
||||||
|
import { useDashboardNotification } from "@saleor/apps-shared";
|
||||||
|
import { trpcClient } from "../../trpc/trpc-client";
|
||||||
|
import { setBackendErrors } from "../../../lib/set-backend-errors";
|
||||||
|
import { UniversalChannelsSection } from "../../channels/ui/universal-channels-section";
|
||||||
|
import { UpdateChannelsInput } from "../../channels/channel-configuration-schema";
|
||||||
|
|
||||||
|
interface SmtpChannelsSectionProps {
|
||||||
|
configuration: SmtpConfiguration;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SmtpChannelsSection = ({ configuration }: SmtpChannelsSectionProps) => {
|
||||||
|
const { notifySuccess, notifyError } = useDashboardNotification();
|
||||||
|
|
||||||
|
const trpcContext = trpcClient.useContext();
|
||||||
|
const { mutate } = trpcClient.smtpConfiguration.updateChannels.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
notifySuccess("Configuration saved");
|
||||||
|
trpcContext.smtpConfiguration.invalidate();
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
setBackendErrors<UpdateChannelsInput>({
|
||||||
|
error,
|
||||||
|
notifyError,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UniversalChannelsSection
|
||||||
|
configurationId={configuration.id}
|
||||||
|
channelConfiguration={configuration.channels}
|
||||||
|
onSubmit={mutate}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
||||||
|
import { BoxWithBorder } from "../../../components/box-with-border";
|
||||||
|
import { SectionWithDescription } from "../../../components/section-with-description";
|
||||||
|
import { defaultPadding } from "../../../components/ui-defaults";
|
||||||
|
import { BoxFooter } from "../../../components/box-footer";
|
||||||
|
import { trpcClient } from "../../trpc/trpc-client";
|
||||||
|
import { useDashboardNotification } from "@saleor/apps-shared";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { SmtpConfiguration } from "../configuration/smtp-config-schema";
|
||||||
|
import { SmtpGetConfigurationIdInput } from "../configuration/smtp-config-input-schema";
|
||||||
|
import { setBackendErrors } from "../../../lib/set-backend-errors";
|
||||||
|
|
||||||
|
interface SmtpDangerousSectionProps {
|
||||||
|
configuration: SmtpConfiguration;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SmtpDangerousSection = ({ configuration }: SmtpDangerousSectionProps) => {
|
||||||
|
const { notifySuccess, notifyError } = useDashboardNotification();
|
||||||
|
const { replace } = useRouter();
|
||||||
|
const { handleSubmit, setError } = useForm<SmtpGetConfigurationIdInput>({
|
||||||
|
defaultValues: {
|
||||||
|
id: configuration.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate } = trpcClient.smtpConfiguration.deleteConfiguration.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
notifySuccess("Configuration removed");
|
||||||
|
replace("/configuration");
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
setBackendErrors<SmtpGetConfigurationIdInput>({
|
||||||
|
error,
|
||||||
|
setError,
|
||||||
|
notifyError,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionWithDescription title="Danger zone">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit((data, event) => {
|
||||||
|
mutate({
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<BoxWithBorder backgroundColor="surfaceCriticalSubdued" borderColor="criticalSubdued">
|
||||||
|
<Box padding={defaultPadding}>
|
||||||
|
<Text variant="heading" as="h1">
|
||||||
|
Remove provider
|
||||||
|
</Text>
|
||||||
|
<Text as="p">You can remove provider configuration.</Text>
|
||||||
|
<Text as="p">
|
||||||
|
This operation will remove all settings related to this configuration. Data will be
|
||||||
|
permanently removed from the App.
|
||||||
|
</Text>
|
||||||
|
<Text as="p">This operation cant be undone.</Text>
|
||||||
|
<Text as="p">You still can create new configuration.</Text>
|
||||||
|
</Box>
|
||||||
|
<BoxFooter borderColor="criticalSubdued">
|
||||||
|
<Button
|
||||||
|
color="textNeutralSubdued"
|
||||||
|
backgroundColor="interactiveCriticalDefault"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Remove provider
|
||||||
|
</Button>
|
||||||
|
</BoxFooter>
|
||||||
|
</BoxWithBorder>
|
||||||
|
</form>
|
||||||
|
</SectionWithDescription>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { SmtpConfiguration, SmtpEventConfiguration } from "../configuration/smtp-config-schema";
|
||||||
|
import { BoxWithBorder } from "../../../components/box-with-border";
|
||||||
|
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
||||||
|
import { defaultPadding } from "../../../components/ui-defaults";
|
||||||
|
import { useDashboardNotification } from "@saleor/apps-shared";
|
||||||
|
import { trpcClient } from "../../trpc/trpc-client";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { BoxFooter } from "../../../components/box-footer";
|
||||||
|
import { SectionWithDescription } from "../../../components/section-with-description";
|
||||||
|
import { SmtpUpdateEvent, smtpUpdateEventSchema } from "../configuration/smtp-config-input-schema";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { smtpUrls } from "../urls";
|
||||||
|
import { setBackendErrors } from "../../../lib/set-backend-errors";
|
||||||
|
|
||||||
|
interface EventBoxProps {
|
||||||
|
configuration: SmtpConfiguration;
|
||||||
|
event: SmtpEventConfiguration;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EventBox = ({ event, configuration }: EventBoxProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { notifySuccess, notifyError } = useDashboardNotification();
|
||||||
|
|
||||||
|
const { handleSubmit, control, setError, register } = useForm<SmtpUpdateEvent>({
|
||||||
|
defaultValues: {
|
||||||
|
id: configuration.id,
|
||||||
|
...event,
|
||||||
|
},
|
||||||
|
resolver: zodResolver(smtpUpdateEventSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const trpcContext = trpcClient.useContext();
|
||||||
|
const { mutate } = trpcClient.smtpConfiguration.updateEvent.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
notifySuccess("Configuration saved");
|
||||||
|
trpcContext.smtpConfiguration.invalidate();
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
setBackendErrors<SmtpUpdateEvent>({
|
||||||
|
error,
|
||||||
|
setError,
|
||||||
|
notifyError,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit((data, event) => {
|
||||||
|
mutate({
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<BoxWithBorder>
|
||||||
|
<Box padding={defaultPadding} display="flex" flexDirection="column" gap={defaultPadding}>
|
||||||
|
<Text variant="heading">{event.eventType}</Text>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
router.push(smtpUrls.eventConfiguration(configuration.id, event.eventType));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit template
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" placeholder="Enabled" {...register("active")} />
|
||||||
|
<Text paddingLeft={defaultPadding}>Active</Text>
|
||||||
|
</label>
|
||||||
|
</Box>
|
||||||
|
<BoxFooter>
|
||||||
|
<Button type="submit">Save event</Button>
|
||||||
|
</BoxFooter>
|
||||||
|
</BoxWithBorder>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SmtpEventsSectionProps {
|
||||||
|
configuration: SmtpConfiguration;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SmtpEventsSection = ({ configuration }: SmtpEventsSectionProps) => {
|
||||||
|
return (
|
||||||
|
<SectionWithDescription
|
||||||
|
title="Events"
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
<Text display="block">
|
||||||
|
Provide unique name for your configuration - you can create more than one. For example -
|
||||||
|
production and development.
|
||||||
|
</Text>
|
||||||
|
<Text display="block">Then, pass your API Key. Obtain it here.</Text>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Box display="flex" flexDirection="column" gap={defaultPadding}>
|
||||||
|
{configuration.events.map((event) => (
|
||||||
|
<EventBox key={event.eventType} configuration={configuration} event={event} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</SectionWithDescription>
|
||||||
|
);
|
||||||
|
};
|
109
apps/emails-and-messages/src/modules/smtp/ui/smtp-section.tsx
Normal file
109
apps/emails-and-messages/src/modules/smtp/ui/smtp-section.tsx
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
import { SmtpConfiguration } from "../configuration/smtp-config-schema";
|
||||||
|
import { BoxWithBorder } from "../../../components/box-with-border";
|
||||||
|
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
||||||
|
import { defaultPadding } from "../../../components/ui-defaults";
|
||||||
|
import { useDashboardNotification } from "@saleor/apps-shared";
|
||||||
|
import { trpcClient } from "../../trpc/trpc-client";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { BoxFooter } from "../../../components/box-footer";
|
||||||
|
import { SectionWithDescription } from "../../../components/section-with-description";
|
||||||
|
import { SmtpUpdateSmtp, smtpUpdateSmtpSchema } from "../configuration/smtp-config-input-schema";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { setBackendErrors } from "../../../lib/set-backend-errors";
|
||||||
|
import { Input } from "@saleor/react-hook-form-macaw";
|
||||||
|
|
||||||
|
interface SmtpSectionProps {
|
||||||
|
configuration: SmtpConfiguration;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SmtpSection = ({ configuration }: SmtpSectionProps) => {
|
||||||
|
const { notifySuccess, notifyError } = useDashboardNotification();
|
||||||
|
const { handleSubmit, control, setError, register } = useForm<SmtpUpdateSmtp>({
|
||||||
|
defaultValues: {
|
||||||
|
id: configuration.id,
|
||||||
|
smtpHost: configuration.smtpHost,
|
||||||
|
smtpPort: configuration.smtpPort,
|
||||||
|
smtpUser: configuration.smtpUser,
|
||||||
|
smtpPassword: configuration.smtpPassword,
|
||||||
|
encryption: configuration.encryption,
|
||||||
|
},
|
||||||
|
resolver: zodResolver(smtpUpdateSmtpSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const trpcContext = trpcClient.useContext();
|
||||||
|
const { mutate } = trpcClient.smtpConfiguration.updateSmtp.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
notifySuccess("Configuration saved");
|
||||||
|
trpcContext.smtpConfiguration.invalidate();
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
setBackendErrors<SmtpUpdateSmtp>({
|
||||||
|
error,
|
||||||
|
setError,
|
||||||
|
notifyError,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionWithDescription
|
||||||
|
title="Connect SMTP server"
|
||||||
|
description={<Text>Provide SMTP server configuration</Text>}
|
||||||
|
>
|
||||||
|
<BoxWithBorder>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit((data, event) => {
|
||||||
|
mutate({
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Box padding={defaultPadding} display="flex" flexDirection="column" gap={10}>
|
||||||
|
<Input
|
||||||
|
label="Host"
|
||||||
|
helperText="Server host that will be connected."
|
||||||
|
control={control}
|
||||||
|
name="smtpHost"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Port"
|
||||||
|
name="smtpPort"
|
||||||
|
control={control}
|
||||||
|
helperText="Port that will be used"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="User"
|
||||||
|
name="smtpUser"
|
||||||
|
control={control}
|
||||||
|
helperText="User for the SMTP server connection"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Password"
|
||||||
|
name="smtpPassword"
|
||||||
|
control={control}
|
||||||
|
helperText="Password for the SMTP server connection"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box display="flex" gap={defaultPadding}>
|
||||||
|
<label>
|
||||||
|
<input {...register("encryption")} type="radio" value="NONE" />
|
||||||
|
<Text paddingLeft={defaultPadding}>No encryption</Text>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input {...register("encryption")} type="radio" value="SSL" />
|
||||||
|
<Text paddingLeft={defaultPadding}>SSL</Text>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input {...register("encryption")} type="radio" value="TLS" />
|
||||||
|
<Text paddingLeft={defaultPadding}>TLS</Text>
|
||||||
|
</label>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<BoxFooter>
|
||||||
|
<Button type="submit">Save provider</Button>
|
||||||
|
</BoxFooter>
|
||||||
|
</form>
|
||||||
|
</BoxWithBorder>
|
||||||
|
</SectionWithDescription>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { SmtpConfiguration } from "../configuration/smtp-config-schema";
|
||||||
|
import { BoxWithBorder } from "../../../components/box-with-border";
|
||||||
|
import { Box, Button } from "@saleor/macaw-ui/next";
|
||||||
|
import { defaultPadding } from "../../../components/ui-defaults";
|
||||||
|
import { useDashboardNotification } from "@saleor/apps-shared";
|
||||||
|
import { trpcClient } from "../../trpc/trpc-client";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { BoxFooter } from "../../../components/box-footer";
|
||||||
|
import { SectionWithDescription } from "../../../components/section-with-description";
|
||||||
|
import {
|
||||||
|
SmtpUpdateSender,
|
||||||
|
smtpUpdateSenderSchema,
|
||||||
|
} from "../configuration/smtp-config-input-schema";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { setBackendErrors } from "../../../lib/set-backend-errors";
|
||||||
|
import { Input } from "@saleor/react-hook-form-macaw";
|
||||||
|
|
||||||
|
interface SmtpSenderSectionProps {
|
||||||
|
configuration: SmtpConfiguration;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SmtpSenderSection = ({ configuration }: SmtpSenderSectionProps) => {
|
||||||
|
const { notifySuccess, notifyError } = useDashboardNotification();
|
||||||
|
const { handleSubmit, control, setError, register } = useForm<SmtpUpdateSender>({
|
||||||
|
defaultValues: {
|
||||||
|
id: configuration.id,
|
||||||
|
senderName: configuration.senderName,
|
||||||
|
senderEmail: configuration.senderEmail,
|
||||||
|
},
|
||||||
|
resolver: zodResolver(smtpUpdateSenderSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const trpcContext = trpcClient.useContext();
|
||||||
|
const { mutate } = trpcClient.smtpConfiguration.updateSender.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
notifySuccess("Configuration saved");
|
||||||
|
trpcContext.smtpConfiguration.invalidate();
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
setBackendErrors<SmtpUpdateSender>({
|
||||||
|
error,
|
||||||
|
setError,
|
||||||
|
notifyError,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionWithDescription title="Sender">
|
||||||
|
<BoxWithBorder>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit((data, event) => {
|
||||||
|
mutate({
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Box padding={defaultPadding} display="flex" flexDirection="column" gap={10}>
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
name="senderEmail"
|
||||||
|
control={control}
|
||||||
|
helperText="Email address that will be used as sender"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Name"
|
||||||
|
name="senderName"
|
||||||
|
control={control}
|
||||||
|
helperText="Name that will be used as sender"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<BoxFooter>
|
||||||
|
<Button type="submit">Save provider</Button>
|
||||||
|
</BoxFooter>
|
||||||
|
</form>
|
||||||
|
</BoxWithBorder>
|
||||||
|
</SectionWithDescription>
|
||||||
|
);
|
||||||
|
};
|
8
apps/emails-and-messages/src/modules/smtp/urls.ts
Normal file
8
apps/emails-and-messages/src/modules/smtp/urls.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import { MessageEventTypes } from "../event-handlers/message-event-types";
|
||||||
|
|
||||||
|
export const smtpUrls = {
|
||||||
|
newConfiguration: () => `/configuration/smtp/new`,
|
||||||
|
configuration: (id: string) => `/configuration/smtp/${id}`,
|
||||||
|
eventConfiguration: (id: string, event: MessageEventTypes) =>
|
||||||
|
`/configuration/smtp/${id}/event/${event}`,
|
||||||
|
};
|
|
@ -1,13 +1,11 @@
|
||||||
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 { smtpConfigurationRouter } from "../smtp/configuration/smtp-configuration.router";
|
||||||
import { mjmlConfigurationRouter } from "../mjml/configuration/mjml-configuration.router";
|
|
||||||
import { sendgridConfigurationRouter } from "../sendgrid/configuration/sendgrid-configuration.router";
|
import { sendgridConfigurationRouter } from "../sendgrid/configuration/sendgrid-configuration.router";
|
||||||
|
|
||||||
export const appRouter = router({
|
export const appRouter = router({
|
||||||
channels: channelsRouter,
|
channels: channelsRouter,
|
||||||
appConfiguration: appConfigurationRouter,
|
smtpConfiguration: smtpConfigurationRouter,
|
||||||
mjmlConfiguration: mjmlConfigurationRouter,
|
|
||||||
sendgridConfiguration: sendgridConfigurationRouter,
|
sendgridConfiguration: sendgridConfigurationRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,20 +0,0 @@
|
||||||
import { makeStyles } from "@saleor/macaw-ui";
|
|
||||||
import { PropsWithChildren } from "react";
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => ({
|
|
||||||
root: {
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "280px auto 400px",
|
|
||||||
alignItems: "start",
|
|
||||||
gap: theme.spacing(3),
|
|
||||||
padding: "20px 0",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
type AppColumnsLayoutProps = PropsWithChildren<{}>;
|
|
||||||
|
|
||||||
export const AppColumnsLayout = ({ children }: AppColumnsLayoutProps) => {
|
|
||||||
const styles = useStyles();
|
|
||||||
|
|
||||||
return <div className={styles.root}>{children}</div>;
|
|
||||||
};
|
|
|
@ -1,50 +0,0 @@
|
||||||
import React, { PropsWithChildren } from "react";
|
|
||||||
import { makeStyles, PageTab, PageTabs } from "@saleor/macaw-ui";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => ({
|
|
||||||
appContainer: {
|
|
||||||
marginTop: theme.spacing(3),
|
|
||||||
marginLeft: theme.spacing(3),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
type Props = PropsWithChildren<{}>;
|
|
||||||
|
|
||||||
export const ConfigurationPageBaseLayout = ({ children }: Props) => {
|
|
||||||
const styles = useStyles();
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const tabs = [
|
|
||||||
{
|
|
||||||
key: "channels",
|
|
||||||
label: "Channels",
|
|
||||||
url: "/configuration/channels",
|
|
||||||
},
|
|
||||||
{ key: "mjml", label: "MJML", url: "/configuration/mjml" },
|
|
||||||
{
|
|
||||||
key: "sendgrid",
|
|
||||||
label: "Sendgrid",
|
|
||||||
url: "/configuration/sendgrid",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const activePath = tabs.find((tab) => router.pathname.startsWith(tab.url))?.key;
|
|
||||||
|
|
||||||
const navigateToTab = (value: string) => {
|
|
||||||
const redirectionUrl = tabs.find((tab) => tab.key === value)?.url;
|
|
||||||
if (redirectionUrl) {
|
|
||||||
router.push(redirectionUrl);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div className={styles.appContainer}>
|
|
||||||
<PageTabs value={activePath} onChange={navigateToTab}>
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<PageTab key={tab.key} value={tab.key} label={tab.label} />
|
|
||||||
))}
|
|
||||||
</PageTabs>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,22 +0,0 @@
|
||||||
import { CircularProgress } from "@material-ui/core";
|
|
||||||
import { makeStyles } from "@saleor/macaw-ui";
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => {
|
|
||||||
return {
|
|
||||||
loaderContainer: {
|
|
||||||
margin: "50px auto",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export const LoadingIndicator = () => {
|
|
||||||
const styles = useStyles();
|
|
||||||
return (
|
|
||||||
<div className={styles.loaderContainer}>
|
|
||||||
<CircularProgress color="primary" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,6 +1,4 @@
|
||||||
import { AlertBase, Button } from "@saleor/macaw-ui";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Typography } from "@material-ui/core";
|
|
||||||
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||||
import { appName } from "./const";
|
import { appName } from "./const";
|
||||||
|
|
||||||
|
@ -10,26 +8,7 @@ export const NotReadyPage = () => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>{appName}</h1>
|
<h1>{appName}</h1>
|
||||||
<AlertBase variant="error">
|
|
||||||
<Typography variant="h3" paragraph>
|
|
||||||
App can not be used
|
App can not be used
|
||||||
</Typography>
|
|
||||||
<Typography paragraph>
|
|
||||||
To configure the 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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,65 +1,15 @@
|
||||||
|
import "@saleor/macaw-ui/next/style";
|
||||||
import "../styles/globals.css";
|
import "../styles/globals.css";
|
||||||
|
|
||||||
import { Theme } from "@material-ui/core/styles";
|
|
||||||
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 {
|
import React from "react";
|
||||||
dark,
|
|
||||||
light,
|
|
||||||
SaleorThemeColors,
|
|
||||||
ThemeProvider as MacawUIThemeProvider,
|
|
||||||
} from "@saleor/macaw-ui";
|
|
||||||
import React, { PropsWithChildren, useEffect } from "react";
|
|
||||||
import { AppProps } from "next/app";
|
import { AppProps } from "next/app";
|
||||||
|
import { ThemeProvider } from "@saleor/macaw-ui/next";
|
||||||
|
|
||||||
import { ThemeSynchronizer } from "../lib/theme-synchronizer";
|
import { ThemeSynchronizer } from "../lib/theme-synchronizer";
|
||||||
import { NoSSRWrapper } from "../lib/no-ssr-wrapper";
|
import { NoSSRWrapper } from "../lib/no-ssr-wrapper";
|
||||||
import { trpcClient } from "../modules/trpc/trpc-client";
|
import { trpcClient } from "../modules/trpc/trpc-client";
|
||||||
import { createGenerateClassName, StylesProvider } from "@material-ui/core";
|
|
||||||
|
|
||||||
type PalettesOverride = Record<"light" | "dark", SaleorThemeColors>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Temporary override of colors, to match new dashboard palette.
|
|
||||||
* Long term this will be replaced with Macaw UI 2.x with up to date design tokens
|
|
||||||
*/
|
|
||||||
const palettes: PalettesOverride = {
|
|
||||||
light: {
|
|
||||||
...light,
|
|
||||||
background: {
|
|
||||||
default: "#fff",
|
|
||||||
paper: "#fff",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
dark: {
|
|
||||||
...dark,
|
|
||||||
background: {
|
|
||||||
default: "hsla(211, 42%, 14%, 1)",
|
|
||||||
paper: "hsla(211, 42%, 14%, 1)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const themeOverrides: Partial<Theme> = {
|
|
||||||
overrides: {
|
|
||||||
MuiTableCell: {
|
|
||||||
body: {
|
|
||||||
paddingBottom: 8,
|
|
||||||
paddingTop: 8,
|
|
||||||
},
|
|
||||||
root: {
|
|
||||||
height: 56,
|
|
||||||
paddingBottom: 4,
|
|
||||||
paddingTop: 4,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateClassName = createGenerateClassName({
|
|
||||||
productionPrefix: "c",
|
|
||||||
disableGlobal: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure instance is a singleton.
|
* Ensure instance is a singleton.
|
||||||
|
@ -67,34 +17,15 @@ const generateClassName = createGenerateClassName({
|
||||||
*/
|
*/
|
||||||
export const appBridgeInstance = typeof window !== "undefined" ? new AppBridge() : undefined;
|
export const appBridgeInstance = typeof window !== "undefined" ? new AppBridge() : undefined;
|
||||||
|
|
||||||
/**
|
|
||||||
* That's a hack required by Macaw-UI incompatibility with React@18
|
|
||||||
*/
|
|
||||||
const ThemeProvider = MacawUIThemeProvider as React.FC<
|
|
||||||
PropsWithChildren<{ overrides?: Partial<Theme>; ssr: boolean; palettes: PalettesOverride }>
|
|
||||||
>;
|
|
||||||
|
|
||||||
function NextApp({ Component, pageProps }: AppProps) {
|
function NextApp({ Component, pageProps }: AppProps) {
|
||||||
/**
|
|
||||||
* Configure JSS (used by MacawUI) for SSR. If Macaw is not used, can be removed.
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
const jssStyles = document.querySelector("#jss-server-side");
|
|
||||||
if (jssStyles) {
|
|
||||||
jssStyles?.parentElement?.removeChild(jssStyles);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NoSSRWrapper>
|
<NoSSRWrapper>
|
||||||
<AppBridgeProvider appBridgeInstance={appBridgeInstance}>
|
<AppBridgeProvider appBridgeInstance={appBridgeInstance}>
|
||||||
<StylesProvider generateClassName={generateClassName}>
|
<ThemeProvider defaultTheme="defaultLight">
|
||||||
<ThemeProvider palettes={palettes} overrides={themeOverrides} ssr>
|
|
||||||
<ThemeSynchronizer />
|
<ThemeSynchronizer />
|
||||||
<RoutePropagator />
|
<RoutePropagator />
|
||||||
<Component {...pageProps} />
|
<Component {...pageProps} />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</StylesProvider>
|
|
||||||
</AppBridgeProvider>
|
</AppBridgeProvider>
|
||||||
</NoSSRWrapper>
|
</NoSSRWrapper>
|
||||||
);
|
);
|
||||||
|
|
|
@ -28,7 +28,6 @@ export default createAppRegisterHandler({
|
||||||
onAuthAplSaved: async (request, ctx) => {
|
onAuthAplSaved: async (request, ctx) => {
|
||||||
// Subscribe to Notify using the mutation since it does not use subscriptions and can't be subscribed via manifest
|
// Subscribe to Notify using the mutation since it does not use subscriptions and can't be subscribed via manifest
|
||||||
logger.debug("onAuthAplSaved executing");
|
logger.debug("onAuthAplSaved executing");
|
||||||
|
|
||||||
const baseUrl = getBaseUrl(request.headers);
|
const baseUrl = getBaseUrl(request.headers);
|
||||||
const client = createClient(ctx.authData.saleorApiUrl, async () =>
|
const client = createClient(ctx.authData.saleorApiUrl, async () =>
|
||||||
Promise.resolve({ token: ctx.authData.token })
|
Promise.resolve({ token: ctx.authData.token })
|
||||||
|
|
|
@ -1,24 +0,0 @@
|
||||||
import { NextPage } from "next";
|
|
||||||
import React, { useEffect } from "react";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { trpcClient } from "../../../modules/trpc/trpc-client";
|
|
||||||
import { ConfigurationPageBaseLayout } from "../../../modules/ui/configuration-page-base-layout";
|
|
||||||
import { ChannelsConfigurationTab } from "../../../modules/app-configuration/ui/channels-configuration-tab";
|
|
||||||
|
|
||||||
const ChannelsConfigurationPage: NextPage = () => {
|
|
||||||
const channels = trpcClient.channels.fetch.useQuery();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (router && channels.isSuccess && channels.data.length === 0) {
|
|
||||||
router.push("/not-ready");
|
|
||||||
}
|
|
||||||
}, [channels.data, channels.isSuccess, router]);
|
|
||||||
return (
|
|
||||||
<ConfigurationPageBaseLayout>
|
|
||||||
<ChannelsConfigurationTab />
|
|
||||||
</ConfigurationPageBaseLayout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ChannelsConfigurationPage;
|
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { Box, Text } from "@saleor/macaw-ui/next";
|
||||||
|
import { NextPage } from "next";
|
||||||
|
import { SectionWithDescription } from "../../components/section-with-description";
|
||||||
|
import { ProviderSelectionBox } from "../../modules/app-configuration/ui/provider-selection-box";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { sendgridUrls } from "../../modules/sendgrid/urls";
|
||||||
|
import { smtpUrls } from "../../modules/smtp/urls";
|
||||||
|
import { appUrls } from "../../modules/app-configuration/urls";
|
||||||
|
import { BasicLayout } from "../../components/basic-layout";
|
||||||
|
|
||||||
|
const ChooseProviderPage: NextPage = () => {
|
||||||
|
const { push } = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BasicLayout
|
||||||
|
breadcrumbs={[
|
||||||
|
{ name: "Configuration", href: appUrls.configuration() },
|
||||||
|
{ name: "Add provider" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Box display="grid" gridTemplateColumns={{ desktop: 3, mobile: 1 }}>
|
||||||
|
<Box>
|
||||||
|
<Text>Select and configure providers to connect Saleor with selected services.</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<SectionWithDescription title="Choose provider">
|
||||||
|
<Box display="grid" gridTemplateColumns={2} gap={6}>
|
||||||
|
<ProviderSelectionBox
|
||||||
|
providerName="Sendgrid"
|
||||||
|
providerDescription="Use dynamic templates created in Sendgrid dashboard to send messages. Event data will be forwarded to Sendgrid."
|
||||||
|
onClick={() => push(sendgridUrls.newConfiguration())}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProviderSelectionBox
|
||||||
|
providerName="SMTP"
|
||||||
|
providerDescription="Provide your own SMTP credentials and map Saleor event to custom MJML templates."
|
||||||
|
onClick={() => push(smtpUrls.newConfiguration())}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</SectionWithDescription>
|
||||||
|
</BasicLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChooseProviderPage;
|
60
apps/emails-and-messages/src/pages/configuration/index.tsx
Normal file
60
apps/emails-and-messages/src/pages/configuration/index.tsx
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
import { Box, Text } from "@saleor/macaw-ui/next";
|
||||||
|
import { NextPage } from "next";
|
||||||
|
import { SectionWithDescription } from "../../components/section-with-description";
|
||||||
|
import {
|
||||||
|
ConfigurationListItem,
|
||||||
|
MessagingProvidersBox,
|
||||||
|
} from "../../modules/app-configuration/ui/messaging-providers-box";
|
||||||
|
import { trpcClient } from "../../modules/trpc/trpc-client";
|
||||||
|
import { appUrls } from "../../modules/app-configuration/urls";
|
||||||
|
import { BasicLayout } from "../../components/basic-layout";
|
||||||
|
|
||||||
|
const ConfigurationPage: NextPage = () => {
|
||||||
|
const { data: dataSendgrid, isLoading: isLoadingSendgrid } =
|
||||||
|
trpcClient.sendgridConfiguration.getConfigurations.useQuery();
|
||||||
|
|
||||||
|
const { data: dataSmtp, isLoading: isLoadingSmtp } =
|
||||||
|
trpcClient.smtpConfiguration.getConfigurations.useQuery();
|
||||||
|
|
||||||
|
const data: ConfigurationListItem[] = [
|
||||||
|
...(dataSendgrid?.map((configuration) => ({
|
||||||
|
name: configuration.name,
|
||||||
|
provider: "sendgrid" as const,
|
||||||
|
id: configuration.id,
|
||||||
|
active: configuration.active,
|
||||||
|
})) || []),
|
||||||
|
...(dataSmtp?.map((configuration) => ({
|
||||||
|
name: configuration.name,
|
||||||
|
provider: "smtp" as const,
|
||||||
|
id: configuration.id,
|
||||||
|
active: configuration.active,
|
||||||
|
})) || []),
|
||||||
|
];
|
||||||
|
|
||||||
|
const isLoading = isLoadingSendgrid || isLoadingSmtp;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BasicLayout breadcrumbs={[{ name: "Configuration", href: appUrls.configuration() }]}>
|
||||||
|
<Box display="grid" gridTemplateColumns={{ desktop: 3, mobile: 1 }}>
|
||||||
|
<Box>
|
||||||
|
<Text>
|
||||||
|
Configure Emails & Messages app to deliver Saleor Events webhooks to various messaging
|
||||||
|
providers
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<SectionWithDescription
|
||||||
|
title="Messaging providers"
|
||||||
|
description={
|
||||||
|
<Text>
|
||||||
|
Manage providers configuration to connect Saleor events with 3rd party services.
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MessagingProvidersBox configurations={data || []} isLoading={isLoading} />
|
||||||
|
</SectionWithDescription>
|
||||||
|
</BasicLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConfigurationPage;
|
|
@ -1,19 +0,0 @@
|
||||||
import { NextPage } from "next";
|
|
||||||
import React from "react";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { ConfigurationPageBaseLayout } from "../../../modules/ui/configuration-page-base-layout";
|
|
||||||
import { MjmlConfigurationTab } from "../../../modules/mjml/configuration/ui/mjml-configuration-tab";
|
|
||||||
|
|
||||||
const MjmlConfigurationPage: NextPage = () => {
|
|
||||||
const router = useRouter();
|
|
||||||
const configurationId = router.query.configurationId
|
|
||||||
? router.query.configurationId[0] // optional routes are passed as an array
|
|
||||||
: undefined;
|
|
||||||
return (
|
|
||||||
<ConfigurationPageBaseLayout>
|
|
||||||
<MjmlConfigurationTab configurationId={configurationId} />
|
|
||||||
</ConfigurationPageBaseLayout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MjmlConfigurationPage;
|
|
|
@ -1,69 +0,0 @@
|
||||||
import { NextPage } from "next";
|
|
||||||
import React from "react";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { trpcClient } from "../../../../../modules/trpc/trpc-client";
|
|
||||||
|
|
||||||
import { parseMessageEventType } from "../../../../../modules/event-handlers/parse-message-event-type";
|
|
||||||
import { ConfigurationPageBaseLayout } from "../../../../../modules/ui/configuration-page-base-layout";
|
|
||||||
import { EventConfigurationForm } from "../../../../../modules/mjml/configuration/ui/mjml-event-configuration-form";
|
|
||||||
import { LoadingIndicator } from "../../../../../modules/ui/loading-indicator";
|
|
||||||
|
|
||||||
const EventConfigurationPage: NextPage = () => {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const configurationId = router.query.configurationId as string;
|
|
||||||
const eventTypeFromQuery = router.query.eventType as string | undefined;
|
|
||||||
const eventType = parseMessageEventType(eventTypeFromQuery);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: configuration,
|
|
||||||
isError,
|
|
||||||
isFetched,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
} = trpcClient.mjmlConfiguration.getEventConfiguration.useQuery(
|
|
||||||
{
|
|
||||||
configurationId,
|
|
||||||
// if event type is not valid, it calling the query will not be enabled
|
|
||||||
// so we can safely cast it
|
|
||||||
eventType: eventType!,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: !!configurationId && !!eventType,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// TODO: better error messages
|
|
||||||
if (!eventType || !configurationId) {
|
|
||||||
return <>Error: no event type or configuration id</>;
|
|
||||||
}
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<ConfigurationPageBaseLayout>
|
|
||||||
<LoadingIndicator />
|
|
||||||
</ConfigurationPageBaseLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
Error: could not load the config: fetched: {isFetched} is error {isError}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!configuration) {
|
|
||||||
return <>Error: no configuration with given id</>;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<ConfigurationPageBaseLayout>
|
|
||||||
<EventConfigurationForm
|
|
||||||
initialData={configuration}
|
|
||||||
configurationId={configurationId}
|
|
||||||
eventType={eventType}
|
|
||||||
/>
|
|
||||||
</ConfigurationPageBaseLayout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EventConfigurationPage;
|
|
|
@ -1,19 +0,0 @@
|
||||||
import { NextPage } from "next";
|
|
||||||
import React from "react";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { ConfigurationPageBaseLayout } from "../../../modules/ui/configuration-page-base-layout";
|
|
||||||
import { SendgridConfigurationTab } from "../../../modules/sendgrid/configuration/ui/sendgrid-configuration-tab";
|
|
||||||
|
|
||||||
const SendgridConfigurationPage: NextPage = () => {
|
|
||||||
const router = useRouter();
|
|
||||||
const configurationId = router.query.configurationId
|
|
||||||
? router.query.configurationId[0] // optional routes are passed as an array
|
|
||||||
: undefined;
|
|
||||||
return (
|
|
||||||
<ConfigurationPageBaseLayout>
|
|
||||||
<SendgridConfigurationTab configurationId={configurationId} />
|
|
||||||
</ConfigurationPageBaseLayout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SendgridConfigurationPage;
|
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { Box, Text } from "@saleor/macaw-ui/next";
|
||||||
|
import { NextPage } from "next";
|
||||||
|
import { trpcClient } from "../../../modules/trpc/trpc-client";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { SendgridBasicInformationSection } from "../../../modules/sendgrid/ui/sendgrid-basic-information-section";
|
||||||
|
import { SendgridDangerousSection } from "../../../modules/sendgrid/ui/sendgrid-dangrous-section";
|
||||||
|
import { ApiConnectionSection } from "../../../modules/sendgrid/ui/api-connection-section";
|
||||||
|
import { SendgridSenderSection } from "../../../modules/sendgrid/ui/sendgrid-sender-section";
|
||||||
|
import { SendgridEventsSection } from "../../../modules/sendgrid/ui/sendgrid-events-section";
|
||||||
|
import { useDashboardNotification } from "@saleor/apps-shared";
|
||||||
|
import { BasicLayout } from "../../../components/basic-layout";
|
||||||
|
import { SendgridChannelsSection } from "../../../modules/sendgrid/ui/sendgrid-channels-section";
|
||||||
|
import { appUrls } from "../../../modules/app-configuration/urls";
|
||||||
|
|
||||||
|
const LoadingView = () => {
|
||||||
|
return (
|
||||||
|
<BasicLayout
|
||||||
|
breadcrumbs={[
|
||||||
|
{ name: "Configuration", href: appUrls.configuration() },
|
||||||
|
{ name: "Sendgrid provider" },
|
||||||
|
{ name: "..." },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text variant="hero">Loading...</Text>
|
||||||
|
</BasicLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const NotFoundView = () => {
|
||||||
|
return (
|
||||||
|
<BasicLayout
|
||||||
|
breadcrumbs={[
|
||||||
|
{ name: "Configuration", href: appUrls.configuration() },
|
||||||
|
{ name: "Sendgrid provider" },
|
||||||
|
{ name: "Not found" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text variant="hero">Could not find the requested configuration.</Text>
|
||||||
|
</BasicLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EditSendgridConfigurationPage: NextPage = () => {
|
||||||
|
const { notifyError } = useDashboardNotification();
|
||||||
|
const router = useRouter();
|
||||||
|
const configurationId = router.query.configurationId
|
||||||
|
? (router.query.configurationId as string)
|
||||||
|
: undefined;
|
||||||
|
const { data: configuration, isLoading } =
|
||||||
|
trpcClient.sendgridConfiguration.getConfiguration.useQuery(
|
||||||
|
{
|
||||||
|
id: configurationId!,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!configurationId,
|
||||||
|
onSettled(data, error) {
|
||||||
|
if (error) {
|
||||||
|
console.log("Error: ", error);
|
||||||
|
}
|
||||||
|
if (error?.data?.code === "NOT_FOUND" || !data) {
|
||||||
|
notifyError("The requested configuration does not exist.");
|
||||||
|
router.replace("/configuration");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <LoadingView />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!configuration) {
|
||||||
|
return <NotFoundView />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BasicLayout
|
||||||
|
breadcrumbs={[
|
||||||
|
{ name: "Configuration", href: appUrls.configuration() },
|
||||||
|
{
|
||||||
|
name: `Sendgrid: ${configuration.name}`,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Box display="grid" gridTemplateColumns={{ desktop: 3, mobile: 1 }}>
|
||||||
|
<Box>
|
||||||
|
<Text>Connect Sendgrid with Saleor.</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<SendgridBasicInformationSection configuration={configuration} />
|
||||||
|
<ApiConnectionSection configuration={configuration} />
|
||||||
|
<SendgridSenderSection configuration={configuration} />
|
||||||
|
<SendgridEventsSection configuration={configuration} />
|
||||||
|
<SendgridChannelsSection configuration={configuration} />
|
||||||
|
<SendgridDangerousSection configuration={configuration} />
|
||||||
|
</BasicLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditSendgridConfigurationPage;
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue