Compare commits

...

7 commits

Author SHA1 Message Date
Krzysztof Wolski
f0a62fe580 Use TextLink 2023-05-24 16:11:23 +02:00
Krzysztof Wolski
767d7fb4d3 Use breadcrumbs 2023-05-24 16:11:23 +02:00
Krzysztof Wolski
40eda0045a Use the SemanticChip 2023-05-24 16:11:23 +02:00
Krzysztof Wolski
cb4a9e8cad
Use macaw input bindings (#508) 2023-05-24 16:09:45 +02:00
Krzysztof Wolski
44741aa74e Update E&M Provider UI (#459)
* update to macaw next
* update ui to new design
2023-05-24 13:25:05 +02:00
Krzysztof Wolski
e04ee04b3d Remove old code 2023-05-24 12:42:42 +02:00
Krzysztof Wolski
55b998a75f Remove channels config 2023-05-24 12:41:59 +02:00
112 changed files with 3801 additions and 4080 deletions

View file

@ -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");

View file

@ -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": {

View 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>
);
};

View 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>
);
};

View 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>
);
};

View file

@ -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>
);
};

View file

@ -0,0 +1 @@
export const defaultPadding = 6;

View file

@ -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}`;
}; };

View 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
);
}

View file

@ -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");
});
});
});

View file

@ -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);

View file

@ -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,
};

View file

@ -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(),
}),
});

View file

@ -1,11 +0,0 @@
export interface AppConfigurationPerChannel {
active: boolean;
mjmlConfigurationId?: string;
sendgridConfigurationId?: string;
}
export type AppConfigurationsChannelMap = Record<string, AppConfigurationPerChannel>;
export type AppConfig = {
configurationsPerChannel: AppConfigurationsChannelMap;
};

View file

@ -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;
}),
});

View file

@ -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,
});
}
}

View file

@ -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: {} }
);
},
};

View file

@ -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);
}
}

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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",
}; };

View file

@ -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>;

View file

@ -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);
});
});
});

View file

@ -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);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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,75 +30,53 @@ 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,
availableInChannel: channel,
});
if (!channelAppConfiguration) { for (const smtpConfiguration of availableSmtpConfigurations) {
logger.warn("App has no configuration for this channel"); const smtpStatus = await sendSmtp({
return; event,
} payload,
logger.debug("Channel has assigned app configuration"); recipientEmail,
smtpConfiguration,
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({ if (smtpStatus?.errors.length) {
id: channelAppConfiguration.mjmlConfigurationId, logger.error("SMTP errors");
}); logger.error(smtpStatus?.errors);
if (mjmlConfiguration) {
const mjmlStatus = await sendMjml({
event,
payload,
recipientEmail,
mjmlConfiguration,
});
if (mjmlStatus?.errors.length) {
logger.error("MJML errors");
logger.error(mjmlStatus?.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({
apiClient: client, apiClient: client,
saleorApiUrl: authData.saleorApiUrl, saleorApiUrl: authData.saleorApiUrl,
});
const availableSendgridConfigurations = await sendgridConfigurationService.getConfigurations({
active: true,
availableInChannel: channel,
});
for (const sendgridConfiguration of availableSendgridConfigurations) {
const sendgridStatus = await sendSendgrid({
event,
payload,
recipientEmail,
sendgridConfiguration,
}); });
const sendgridConfiguration = await sendgridConfigurationService.getConfiguration({ if (sendgridStatus?.errors.length) {
id: channelAppConfiguration.sendgridConfigurationId, logger.error("Sendgrid errors");
}); logger.error(sendgridStatus?.errors);
if (sendgridConfiguration) {
const sendgridStatus = await sendSendgrid({
event,
payload,
recipientEmail,
sendgridConfiguration,
});
if (sendgridStatus?.errors.length) {
logger.error("Sendgrid errors");
logger.error(sendgridStatus?.errors);
}
} }
} }
}; };

View file

@ -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,
};

View file

@ -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),
});

View file

@ -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[];
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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}`,
};

View file

@ -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";

View file

@ -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;
}; };

View file

@ -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>;

View file

@ -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>;

View file

@ -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[];
};

View file

@ -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;
}), }),

View file

@ -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 {

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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";

View file

@ -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,

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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}
/>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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}`,
}; };

View file

@ -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 });

View file

@ -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,
};

View file

@ -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
>;

View file

@ -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>;

View file

@ -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;
}),
}); });

View file

@ -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),

View file

@ -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,
}; };
} }

View file

@ -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}

View 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>
);
};

View file

@ -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> </>
); );
}; };

View file

@ -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>
);
};

View file

@ -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}
/>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View 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>
);
};

View file

@ -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>
);
};

View 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}`,
};

View file

@ -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,
}); });

View file

@ -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>;
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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"> App can not be used
<Typography variant="h3" paragraph>
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>
); );
}; };

View file

@ -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>
); );

View file

@ -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 })

View file

@ -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;

View file

@ -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;

View 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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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