Compare commits
4 commits
main
...
update-the
Author | SHA1 | Date | |
---|---|---|---|
![]() |
373063d631 | ||
![]() |
b1bc467da6 | ||
![]() |
a163b04d7a | ||
![]() |
135b1649c8 |
81 changed files with 1926 additions and 3499 deletions
|
@ -16,13 +16,10 @@
|
||||||
"schemaVersion": "3.11.7"
|
"schemaVersion": "3.11.7"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@material-ui/core": "^4.12.4",
|
|
||||||
"@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.37.3",
|
"@saleor/app-sdk": "0.37.3",
|
||||||
"@saleor/apps-shared": "workspace:*",
|
"@saleor/apps-shared": "workspace:*",
|
||||||
"@saleor/macaw-ui": "^0.7.2",
|
"@saleor/macaw-ui": "0.8.0-pre.79",
|
||||||
"@sendgrid/client": "^7.7.0",
|
"@sendgrid/client": "^7.7.0",
|
||||||
"@sendgrid/mail": "^7.7.0",
|
"@sendgrid/mail": "^7.7.0",
|
||||||
"@tanstack/react-query": "^4.24.4",
|
"@tanstack/react-query": "^4.24.4",
|
||||||
|
|
17
apps/emails-and-messages/src/components/basic-layout.tsx
Normal file
17
apps/emails-and-messages/src/components/basic-layout.tsx
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import { Box } from "@saleor/macaw-ui/next";
|
||||||
|
import { Breadcrumbs } from "./breadcrumbs";
|
||||||
|
|
||||||
|
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 items={breadcrumbs} />}
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
19
apps/emails-and-messages/src/components/box-footer.tsx
Normal file
19
apps/emails-and-messages/src/components/box-footer.tsx
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import { Box, BoxProps } from "@saleor/macaw-ui/next";
|
||||||
|
import { defaultPadding } from "./ui-defaults";
|
||||||
|
|
||||||
|
export const BoxFooter = (props: BoxProps) => {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
borderTopStyle="solid"
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor={"neutralDefault"}
|
||||||
|
padding={defaultPadding}
|
||||||
|
display={"flex"}
|
||||||
|
flexDirection={"row"}
|
||||||
|
justifyContent={"flex-end"}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
15
apps/emails-and-messages/src/components/box-with-border.tsx
Normal file
15
apps/emails-and-messages/src/components/box-with-border.tsx
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import { Box, BoxProps } from "@saleor/macaw-ui/next";
|
||||||
|
|
||||||
|
export const BoxWithBorder = (props: BoxProps) => {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
borderWidth={1}
|
||||||
|
borderStyle={"solid"}
|
||||||
|
borderColor={"neutralDefault"}
|
||||||
|
borderRadius={5}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
58
apps/emails-and-messages/src/components/breadcrumbs.tsx
Normal file
58
apps/emails-and-messages/src/components/breadcrumbs.tsx
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
import { Box, Text } from "@saleor/macaw-ui/next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { TextLink } from "./text-link";
|
||||||
|
|
||||||
|
interface BreadcrumbsProps {
|
||||||
|
items: Array<{ name: string; href?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays breadcrumbs for the current page.
|
||||||
|
* On desktop full path is visible. On mobile only last item is shown.
|
||||||
|
*/
|
||||||
|
export const Breadcrumbs = (props: BreadcrumbsProps) => {
|
||||||
|
if (props.items.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = [...props.items];
|
||||||
|
const lastItem = items.pop()!; // can enforce the type since array is at least one element long
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box display={"flex"} gap={6}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<Box key={`${item.name}`} display={{ mobile: "none", desktop: "flex" }} gap={6}>
|
||||||
|
{!item.href ? (
|
||||||
|
<Text
|
||||||
|
key={`${item.name}_name`}
|
||||||
|
variant="hero"
|
||||||
|
display={{ mobile: "none", desktop: "block" }}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<TextLink
|
||||||
|
key={`${item.name}_name`}
|
||||||
|
href={item.href}
|
||||||
|
variant="hero"
|
||||||
|
display={{ mobile: "none", desktop: "block" }}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</TextLink>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text
|
||||||
|
key={`${item.name}_separator`}
|
||||||
|
variant="hero"
|
||||||
|
display={{ mobile: "none", desktop: "block" }}
|
||||||
|
>
|
||||||
|
{">"}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
<Text key={`${lastItem.name}_name`} variant="hero" display="block">
|
||||||
|
{lastItem.name}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
53
apps/emails-and-messages/src/components/chip-text.tsx
Normal file
53
apps/emails-and-messages/src/components/chip-text.tsx
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
import { Text, Chip, ChipProps } from "@saleor/macaw-ui/next";
|
||||||
|
|
||||||
|
interface ChipTextProps {
|
||||||
|
variant?: "default" | "warning" | "error" | "success";
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChipText = ({ variant = "default", content }: ChipTextProps) => {
|
||||||
|
const commonProps: ChipProps = {
|
||||||
|
__maxWidth: "max-content",
|
||||||
|
display: "flex",
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderWidth: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: Choose colors for variants
|
||||||
|
switch (variant) {
|
||||||
|
case "default":
|
||||||
|
return (
|
||||||
|
<Chip {...commonProps} borderColor={"neutralSubdued"}>
|
||||||
|
<Text color="textNeutralDefault" size="small" variant="caption">
|
||||||
|
{content}
|
||||||
|
</Text>
|
||||||
|
</Chip>
|
||||||
|
);
|
||||||
|
case "warning":
|
||||||
|
return (
|
||||||
|
<Chip {...commonProps} borderColor={"brandHighlight"}>
|
||||||
|
<Text color="textNeutralDefault" size="small" variant="caption">
|
||||||
|
{content}
|
||||||
|
</Text>
|
||||||
|
</Chip>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "error":
|
||||||
|
return (
|
||||||
|
<Chip {...commonProps} borderColor={"criticalDefault"}>
|
||||||
|
<Text color="textNeutralDefault" size="small" variant="caption">
|
||||||
|
{content}
|
||||||
|
</Text>
|
||||||
|
</Chip>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "success":
|
||||||
|
return (
|
||||||
|
<Chip {...commonProps} borderColor={"neutralDefault"}>
|
||||||
|
<Text color="textNeutralDefault" size="small" variant="caption">
|
||||||
|
{content}
|
||||||
|
</Text>
|
||||||
|
</Chip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
|
@ -0,0 +1,3 @@
|
||||||
|
export const LoadingIndicator = () => {
|
||||||
|
return <>LOADING</>;
|
||||||
|
};
|
|
@ -0,0 +1,94 @@
|
||||||
|
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
||||||
|
import { BoxWithBorder } from "./box-with-border";
|
||||||
|
import { BoxFooter } from "./box-footer";
|
||||||
|
import { defaultPadding } from "./ui-defaults";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { SendgridConfiguration } from "../modules/sendgrid/configuration/sendgrid-config-schema";
|
||||||
|
import { TextLink } from "./text-link";
|
||||||
|
import { ChipText } from "./chip-text";
|
||||||
|
import Image from "next/image";
|
||||||
|
import sendgrid from "../public/sendgrid.png";
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MessagingProvidersSectionProps {
|
||||||
|
configurations: SendgridConfiguration[];
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MessagingProvidersBox = ({
|
||||||
|
configurations,
|
||||||
|
isLoading: loading,
|
||||||
|
}: MessagingProvidersSectionProps) => {
|
||||||
|
const { replace } = 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 = () => {
|
||||||
|
replace("/configuration/choose-provider");
|
||||||
|
};
|
||||||
|
|
||||||
|
const redirectToEditConfiguration = (configurationId: string) => {
|
||||||
|
replace(`/configuration/sendgrid/edit/${configurationId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
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) => (
|
||||||
|
<>
|
||||||
|
<Box display="flex" gap={defaultPadding}>
|
||||||
|
<Image alt="Sendgrid logo" src={sendgrid} height={20} width={20} />
|
||||||
|
<Text>Sendgrid</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Text>{configuration.name}</Text>
|
||||||
|
<ChipText
|
||||||
|
content={configuration.active ? "Active" : "Inactive"}
|
||||||
|
variant={configuration.active ? "success" : "error"}
|
||||||
|
/>
|
||||||
|
<Box display="flex" justifyContent={"flex-end"}>
|
||||||
|
<TextLink href={`/configuration/sendgrid/edit/${configuration.id}`}>Edit</TextLink>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
<BoxFooter>
|
||||||
|
<Button onClick={redirectToProvidersSelection}>Add provider</Button>
|
||||||
|
</BoxFooter>
|
||||||
|
</BoxWithBorder>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
14
apps/emails-and-messages/src/components/text-link.tsx
Normal file
14
apps/emails-and-messages/src/components/text-link.tsx
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import { TextProps, Text } from "@saleor/macaw-ui/next";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
interface TextLinkProps extends TextProps {
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TextLink = (props: TextLinkProps) => {
|
||||||
|
return (
|
||||||
|
<Text textDecoration={"underline"} variant="bodyStrong" {...props}>
|
||||||
|
<Link href={props.href}>{props.children}</Link>
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
};
|
1
apps/emails-and-messages/src/components/ui-defaults.ts
Normal file
1
apps/emails-and-messages/src/components/ui-defaults.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export const defaultPadding = 6;
|
27
apps/emails-and-messages/src/lib/is-available-in-channel.ts
Normal file
27
apps/emails-and-messages/src/lib/is-available-in-channel.ts
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import { SendgridConfigurationChannels } from "../modules/sendgrid/configuration/sendgrid-config-schema";
|
||||||
|
|
||||||
|
interface IsAvailableInChannelArgs {
|
||||||
|
channel: string;
|
||||||
|
channelConfiguration: SendgridConfigurationChannels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 channel in channelConfiguration.channels;
|
||||||
|
}
|
||||||
|
return !(channel in channelConfiguration.channels);
|
||||||
|
};
|
|
@ -1,33 +1,24 @@
|
||||||
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||||
import { useTheme } from "@saleor/macaw-ui";
|
import { useTheme } from "@saleor/macaw-ui/next";
|
||||||
import { memo, useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
/**
|
export function ThemeSynchronizer() {
|
||||||
* Macaw-ui stores its theme mode in memory and local storage. To synchronize App with Dashboard,
|
|
||||||
* Macaw must be informed about this change from AppBridge.
|
|
||||||
*
|
|
||||||
* If you are not using Macaw, you can remove this.
|
|
||||||
*/
|
|
||||||
function _ThemeSynchronizer() {
|
|
||||||
const { appBridgeState } = useAppBridge();
|
const { appBridgeState } = useAppBridge();
|
||||||
const { setTheme, themeType } = useTheme();
|
const { setTheme } = useTheme();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!setTheme || !appBridgeState?.theme) {
|
if (!setTheme || !appBridgeState?.theme) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (themeType !== appBridgeState?.theme) {
|
if (appBridgeState.theme === "light") {
|
||||||
setTheme(appBridgeState.theme);
|
setTheme("defaultLight");
|
||||||
/**
|
|
||||||
* Hack to fix macaw, which is going into infinite loop on light mode (probably de-sync local storage with react state)
|
|
||||||
* TODO Fix me when Macaw 2.0 is shipped
|
|
||||||
*/
|
|
||||||
window.localStorage.setItem("macaw-ui-theme", appBridgeState.theme);
|
|
||||||
}
|
}
|
||||||
}, [appBridgeState?.theme, setTheme, themeType]);
|
|
||||||
|
if (appBridgeState.theme === "dark") {
|
||||||
|
setTheme("defaultDark");
|
||||||
|
}
|
||||||
|
}, [appBridgeState?.theme, setTheme]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ThemeSynchronizer = memo(_ThemeSynchronizer);
|
|
||||||
|
|
|
@ -1,33 +0,0 @@
|
||||||
import { AppConfig, AppConfigurationPerChannel } from "./app-config";
|
|
||||||
|
|
||||||
export const getDefaultEmptyAppConfiguration = (): AppConfigurationPerChannel => ({
|
|
||||||
active: false,
|
|
||||||
mjmlConfigurationId: undefined,
|
|
||||||
sendgridConfigurationId: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const getChannelAppConfiguration =
|
|
||||||
(appConfig: AppConfig | null | undefined) => (channelSlug: string) => {
|
|
||||||
try {
|
|
||||||
return appConfig?.configurationsPerChannel[channelSlug] ?? null;
|
|
||||||
} catch (e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const setChannelAppConfiguration =
|
|
||||||
(appConfig: AppConfig | null | undefined) =>
|
|
||||||
(channelSlug: string) =>
|
|
||||||
(appConfiguration: AppConfigurationPerChannel) => {
|
|
||||||
const appConfigNormalized = structuredClone(appConfig) ?? { configurationsPerChannel: {} };
|
|
||||||
|
|
||||||
appConfigNormalized.configurationsPerChannel[channelSlug] ??= getDefaultEmptyAppConfiguration();
|
|
||||||
appConfigNormalized.configurationsPerChannel[channelSlug] = appConfiguration;
|
|
||||||
|
|
||||||
return appConfigNormalized;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AppConfigContainer = {
|
|
||||||
getChannelAppConfiguration,
|
|
||||||
setChannelAppConfiguration,
|
|
||||||
};
|
|
|
@ -1,20 +0,0 @@
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
export const appConfigInputSchema = z.object({
|
|
||||||
configurationsPerChannel: z.record(
|
|
||||||
z.object({
|
|
||||||
active: z.boolean(),
|
|
||||||
mjmlConfigurationId: z.string().optional(),
|
|
||||||
sendgridConfigurationId: z.string().optional(),
|
|
||||||
})
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const appChannelConfigurationInputSchema = z.object({
|
|
||||||
channel: z.string(),
|
|
||||||
configuration: z.object({
|
|
||||||
active: z.boolean(),
|
|
||||||
mjmlConfigurationId: z.string().optional(),
|
|
||||||
sendgridConfigurationId: z.string().optional(),
|
|
||||||
}),
|
|
||||||
});
|
|
|
@ -1,11 +0,0 @@
|
||||||
export interface AppConfigurationPerChannel {
|
|
||||||
active: boolean;
|
|
||||||
mjmlConfigurationId?: string;
|
|
||||||
sendgridConfigurationId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AppConfigurationsChannelMap = Record<string, AppConfigurationPerChannel>;
|
|
||||||
|
|
||||||
export type AppConfig = {
|
|
||||||
configurationsPerChannel: AppConfigurationsChannelMap;
|
|
||||||
};
|
|
|
@ -1,66 +0,0 @@
|
||||||
import { logger as pinoLogger } from "../../lib/logger";
|
|
||||||
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 = pinoLogger.child({ 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 = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
|
|
||||||
logger.debug("Set channel configuration called");
|
|
||||||
|
|
||||||
await ctx.configurationService.setChannelConfiguration(input);
|
|
||||||
}),
|
|
||||||
fetch: protectedWithConfigurationService.query(async ({ ctx, input }) => {
|
|
||||||
const logger = pinoLogger.child({ 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 = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
|
|
||||||
|
|
||||||
logger.debug(input, "appConfigurationRouter.setAndReplace called with input");
|
|
||||||
|
|
||||||
await ctx.configurationService.setConfigurationRoot(input);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}),
|
|
||||||
});
|
|
|
@ -1,35 +0,0 @@
|
||||||
import { AppConfig } from "./app-config";
|
|
||||||
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
|
|
||||||
|
|
||||||
export interface AppConfigurator {
|
|
||||||
setConfig(config: AppConfig): Promise<void>;
|
|
||||||
getConfig(): Promise<AppConfig | undefined>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PrivateMetadataAppConfigurator implements AppConfigurator {
|
|
||||||
private metadataKey = "app-config";
|
|
||||||
|
|
||||||
constructor(private metadataManager: SettingsManager, private saleorApiUrl: string) {}
|
|
||||||
|
|
||||||
getConfig(): Promise<AppConfig | undefined> {
|
|
||||||
return this.metadataManager.get(this.metadataKey, this.saleorApiUrl).then((data) => {
|
|
||||||
if (!data) {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return JSON.parse(data);
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error("Invalid metadata value, cant be parsed");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setConfig(config: AppConfig): Promise<void> {
|
|
||||||
return this.metadataManager.set({
|
|
||||||
key: this.metadataKey,
|
|
||||||
value: JSON.stringify(config),
|
|
||||||
domain: this.saleorApiUrl,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,22 +0,0 @@
|
||||||
import { AppConfig } from "./app-config";
|
|
||||||
import { AppConfigContainer, getDefaultEmptyAppConfiguration } from "./app-config-container";
|
|
||||||
import { ChannelFragment, ShopInfoFragment } from "../../../generated/graphql";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TODO Test
|
|
||||||
*/
|
|
||||||
export const FallbackAppConfig = {
|
|
||||||
createFallbackConfigFromExistingShopAndChannels(
|
|
||||||
channels: ChannelFragment[],
|
|
||||||
shopAppConfiguration: ShopInfoFragment | null
|
|
||||||
) {
|
|
||||||
return (channels ?? []).reduce<AppConfig>(
|
|
||||||
(state, channel) => {
|
|
||||||
return AppConfigContainer.setChannelAppConfiguration(state)(channel.slug)(
|
|
||||||
getDefaultEmptyAppConfiguration()
|
|
||||||
);
|
|
||||||
},
|
|
||||||
{ configurationsPerChannel: {} }
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
|
@ -1,91 +0,0 @@
|
||||||
import { PrivateMetadataAppConfigurator } from "./app-configurator";
|
|
||||||
import { Client } from "urql";
|
|
||||||
import { logger as pinoLogger } from "../../lib/logger";
|
|
||||||
import { AppConfig, AppConfigurationPerChannel } from "./app-config";
|
|
||||||
import { getDefaultEmptyAppConfiguration } from "./app-config-container";
|
|
||||||
import { createSettingsManager } from "../../lib/metadata-manager";
|
|
||||||
|
|
||||||
const logger = pinoLogger.child({
|
|
||||||
service: "AppConfigurationService",
|
|
||||||
});
|
|
||||||
|
|
||||||
export class AppConfigurationService {
|
|
||||||
private configurationData?: AppConfig;
|
|
||||||
private metadataConfigurator: PrivateMetadataAppConfigurator;
|
|
||||||
|
|
||||||
constructor(args: { apiClient: Client; saleorApiUrl: string; initialData?: AppConfig }) {
|
|
||||||
this.metadataConfigurator = new PrivateMetadataAppConfigurator(
|
|
||||||
createSettingsManager(args.apiClient),
|
|
||||||
args.saleorApiUrl
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch configuration from Saleor API and cache it
|
|
||||||
private async pullConfiguration() {
|
|
||||||
logger.debug("Fetch configuration from Saleor API");
|
|
||||||
|
|
||||||
const config = await this.metadataConfigurator.getConfig();
|
|
||||||
this.configurationData = config;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Push configuration to Saleor API
|
|
||||||
private async pushConfiguration() {
|
|
||||||
logger.debug("Push configuration to Saleor API");
|
|
||||||
|
|
||||||
await this.metadataConfigurator.setConfig(this.configurationData!);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getConfiguration() {
|
|
||||||
logger.debug("Get configuration");
|
|
||||||
|
|
||||||
if (!this.configurationData) {
|
|
||||||
logger.debug("No configuration found in cache. Will fetch it from Saleor API");
|
|
||||||
await this.pullConfiguration();
|
|
||||||
}
|
|
||||||
|
|
||||||
const savedAppConfig = this.configurationData ?? null;
|
|
||||||
|
|
||||||
logger.debug(savedAppConfig, "Retrieved app config from Metadata. Will return it");
|
|
||||||
|
|
||||||
if (savedAppConfig) {
|
|
||||||
return savedAppConfig;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Saves configuration to Saleor API and cache it
|
|
||||||
async setConfigurationRoot(config: AppConfig) {
|
|
||||||
logger.debug("Set configuration");
|
|
||||||
|
|
||||||
this.configurationData = config;
|
|
||||||
await this.pushConfiguration();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns channel configuration if existing. Otherwise returns default empty one
|
|
||||||
async getChannelConfiguration(channel: string) {
|
|
||||||
logger.debug("Get channel configuration");
|
|
||||||
const configurations = await this.getConfiguration();
|
|
||||||
if (!configurations) {
|
|
||||||
return getDefaultEmptyAppConfiguration();
|
|
||||||
}
|
|
||||||
|
|
||||||
const channelConfiguration = configurations.configurationsPerChannel[channel];
|
|
||||||
return channelConfiguration || getDefaultEmptyAppConfiguration();
|
|
||||||
}
|
|
||||||
|
|
||||||
async setChannelConfiguration({
|
|
||||||
channel,
|
|
||||||
configuration,
|
|
||||||
}: {
|
|
||||||
channel: string;
|
|
||||||
configuration: AppConfigurationPerChannel;
|
|
||||||
}) {
|
|
||||||
logger.debug("Set channel configuration");
|
|
||||||
let configurations = await this.getConfiguration();
|
|
||||||
if (!configurations) {
|
|
||||||
configurations = { configurationsPerChannel: {} };
|
|
||||||
}
|
|
||||||
|
|
||||||
configurations.configurationsPerChannel[channel] = configuration;
|
|
||||||
await this.setConfigurationRoot(configurations);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,181 +0,0 @@
|
||||||
import { AppConfigurationPerChannel } from "../app-config";
|
|
||||||
import { Controller, useForm } from "react-hook-form";
|
|
||||||
import { FormControl, InputLabel, Link, MenuItem, Select, Typography } from "@material-ui/core";
|
|
||||||
import { Button, makeStyles, SwitchSelector, SwitchSelectorButton } from "@saleor/macaw-ui";
|
|
||||||
import React, { useEffect } from "react";
|
|
||||||
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { mjmlUrls } from "../../mjml/urls";
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => ({
|
|
||||||
field: {
|
|
||||||
marginBottom: 20,
|
|
||||||
},
|
|
||||||
channelName: {
|
|
||||||
cursor: "pointer",
|
|
||||||
borderBottom: `2px solid ${theme.palette.secondary.main}`,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
type AppConfigurationFormProps = {
|
|
||||||
channelSlug: string;
|
|
||||||
channelName: string;
|
|
||||||
channelID: string;
|
|
||||||
mjmlConfigurationChoices: { label: string; value: string }[];
|
|
||||||
sendgridConfigurationChoices: { label: string; value: string }[];
|
|
||||||
onSubmit(data: AppConfigurationPerChannel): Promise<void>;
|
|
||||||
initialData?: AppConfigurationPerChannel | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AppConfigurationForm = (props: AppConfigurationFormProps) => {
|
|
||||||
const styles = useStyles();
|
|
||||||
const { appBridge } = useAppBridge();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { handleSubmit, getValues, setValue, control, reset } = useForm<AppConfigurationPerChannel>(
|
|
||||||
{
|
|
||||||
defaultValues: props.initialData ?? undefined,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
reset(props.initialData || undefined);
|
|
||||||
}, [props.initialData, reset]);
|
|
||||||
|
|
||||||
const handleChannelNameClick = () => {
|
|
||||||
appBridge?.dispatch(
|
|
||||||
actions.Redirect({
|
|
||||||
to: `/channels/${props.channelID}`,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isNoSendgridConfigurations = !props.sendgridConfigurationChoices.length;
|
|
||||||
const isNoMjmlConfigurations = !props.mjmlConfigurationChoices.length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit((data, event) => {
|
|
||||||
props.onSubmit(data);
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Typography variant="h2" paragraph>
|
|
||||||
Configure
|
|
||||||
<span onClick={handleChannelNameClick} className={styles.channelName}>
|
|
||||||
{` ${props.channelName} `}
|
|
||||||
</span>
|
|
||||||
channel:
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="active"
|
|
||||||
render={({ field: { value, name, onChange } }) => (
|
|
||||||
<div className={styles.field}>
|
|
||||||
{/* TODO: fix types in the MacawUI */}
|
|
||||||
{/* @ts-ignore: MacawUI use wrong type for */}
|
|
||||||
<SwitchSelector key={name} className={styles.field}>
|
|
||||||
{[
|
|
||||||
{ label: "Active", value: true },
|
|
||||||
{ label: "Disabled", value: false },
|
|
||||||
].map((button) => (
|
|
||||||
// @ts-ignore: MacawUI use wrong type for SwitchSelectorButton
|
|
||||||
<SwitchSelectorButton
|
|
||||||
value={button.value.toString()}
|
|
||||||
onClick={() => onChange(button.value)}
|
|
||||||
activeTab={value?.toString() || "false"}
|
|
||||||
key={button.label}
|
|
||||||
>
|
|
||||||
{button.label}
|
|
||||||
</SwitchSelectorButton>
|
|
||||||
))}
|
|
||||||
</SwitchSelector>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="mjmlConfigurationId"
|
|
||||||
render={({ field: { value, onChange } }) => {
|
|
||||||
return (
|
|
||||||
<FormControl disabled={isNoMjmlConfigurations} className={styles.field} fullWidth>
|
|
||||||
<InputLabel>MJML Configuration</InputLabel>
|
|
||||||
<Select
|
|
||||||
variant="outlined"
|
|
||||||
value={value}
|
|
||||||
onChange={(event, val) => {
|
|
||||||
onChange(event.target.value);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MenuItem key="none" value={undefined}>
|
|
||||||
No configuration
|
|
||||||
</MenuItem>
|
|
||||||
{props.mjmlConfigurationChoices.map((choice) => (
|
|
||||||
<MenuItem key={choice.value} value={choice.value}>
|
|
||||||
{choice.label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
{isNoMjmlConfigurations && (
|
|
||||||
<Link
|
|
||||||
href="#"
|
|
||||||
onClick={() => {
|
|
||||||
router.push(mjmlUrls.configuration());
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="caption" color="textSecondary">
|
|
||||||
Currently theres no MJML configuration available. Click here to create one.
|
|
||||||
</Typography>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</FormControl>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="sendgridConfigurationId"
|
|
||||||
render={({ field: { value, onChange } }) => {
|
|
||||||
return (
|
|
||||||
<FormControl disabled={isNoSendgridConfigurations} className={styles.field} fullWidth>
|
|
||||||
<InputLabel>Sendgrid Configuration</InputLabel>
|
|
||||||
<Select
|
|
||||||
variant="outlined"
|
|
||||||
value={value}
|
|
||||||
onChange={(event, val) => {
|
|
||||||
onChange(event.target.value);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MenuItem key="none" value={undefined}>
|
|
||||||
No configuration
|
|
||||||
</MenuItem>
|
|
||||||
{props.sendgridConfigurationChoices.map((choice) => (
|
|
||||||
<MenuItem key={choice.value} value={choice.value}>
|
|
||||||
{choice.label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
{isNoSendgridConfigurations && (
|
|
||||||
<Link
|
|
||||||
href="#"
|
|
||||||
onClick={() => {
|
|
||||||
router.push("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="caption" color="textSecondary">
|
|
||||||
Currently theres no Sendgrid configuration available. Click here to create one.
|
|
||||||
</Typography>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</FormControl>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button type="submit" fullWidth variant="primary">
|
|
||||||
Save configuration
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,146 +0,0 @@
|
||||||
import React, { useMemo, useState } from "react";
|
|
||||||
import { EditIcon, IconButton, makeStyles } from "@saleor/macaw-ui";
|
|
||||||
import { AppConfigurationForm } from "./app-configuration-form";
|
|
||||||
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
|
||||||
import { AppColumnsLayout } from "../../ui/app-columns-layout";
|
|
||||||
import { trpcClient } from "../../trpc/trpc-client";
|
|
||||||
import { SideMenu } from "./side-menu";
|
|
||||||
import { LoadingIndicator } from "../../ui/loading-indicator";
|
|
||||||
import { Instructions } from "./instructions";
|
|
||||||
import { useDashboardNotification } from "@saleor/apps-shared";
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => {
|
|
||||||
return {
|
|
||||||
formContainer: {
|
|
||||||
top: 0,
|
|
||||||
},
|
|
||||||
configurationColumn: {
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 20,
|
|
||||||
maxWidth: 700,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export const ChannelsConfigurationTab = () => {
|
|
||||||
const styles = useStyles();
|
|
||||||
const { appBridge } = useAppBridge();
|
|
||||||
const [activeChannelSlug, setActiveChannelSlug] = useState<string | null>(null);
|
|
||||||
const { notifySuccess } = useDashboardNotification();
|
|
||||||
|
|
||||||
const { data: channelsData, isLoading: isChannelsDataLoading } =
|
|
||||||
trpcClient.channels.fetch.useQuery(undefined, {
|
|
||||||
onSuccess: (data) => {
|
|
||||||
if (data?.length) {
|
|
||||||
setActiveChannelSlug(data[0].slug);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: configurationData,
|
|
||||||
refetch: refetchConfig,
|
|
||||||
isLoading: isConfigurationDataLoading,
|
|
||||||
} = trpcClient.appConfiguration.getChannelConfiguration.useQuery(
|
|
||||||
{
|
|
||||||
channelSlug: activeChannelSlug!,
|
|
||||||
},
|
|
||||||
{ enabled: !!activeChannelSlug }
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: mjmlConfigurations, isLoading: isMjmlQueryLoading } =
|
|
||||||
trpcClient.mjmlConfiguration.getConfigurations.useQuery({});
|
|
||||||
|
|
||||||
const mjmlConfigurationsListData = useMemo(() => {
|
|
||||||
return (
|
|
||||||
mjmlConfigurations?.map((configuration) => ({
|
|
||||||
value: configuration.id,
|
|
||||||
label: configuration.configurationName,
|
|
||||||
})) ?? []
|
|
||||||
);
|
|
||||||
}, [mjmlConfigurations]);
|
|
||||||
|
|
||||||
const { data: sendgridConfigurations, isLoading: isSendgridQueryLoading } =
|
|
||||||
trpcClient.sendgridConfiguration.getConfigurations.useQuery({});
|
|
||||||
|
|
||||||
const sendgridConfigurationsListData = useMemo(() => {
|
|
||||||
return (
|
|
||||||
sendgridConfigurations?.map((configuration) => ({
|
|
||||||
value: configuration.id,
|
|
||||||
label: configuration.configurationName,
|
|
||||||
})) ?? []
|
|
||||||
);
|
|
||||||
}, [sendgridConfigurations]);
|
|
||||||
|
|
||||||
const { mutate: mutateAppChannelConfiguration, error: saveError } =
|
|
||||||
trpcClient.appConfiguration.setChannelConfiguration.useMutation({
|
|
||||||
onSuccess() {
|
|
||||||
refetchConfig();
|
|
||||||
|
|
||||||
notifySuccess("Success", "Saved app configuration");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const activeChannel = channelsData?.find((c) => c.slug === activeChannelSlug);
|
|
||||||
|
|
||||||
if (isChannelsDataLoading) {
|
|
||||||
return <LoadingIndicator />;
|
|
||||||
}
|
|
||||||
if (!channelsData?.length) {
|
|
||||||
return <div>NO CHANNELS</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isFormDataLoading =
|
|
||||||
isConfigurationDataLoading || isMjmlQueryLoading || isSendgridQueryLoading;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppColumnsLayout>
|
|
||||||
<SideMenu
|
|
||||||
title="Channels"
|
|
||||||
selectedItemId={activeChannel?.slug}
|
|
||||||
headerToolbar={
|
|
||||||
<IconButton
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => {
|
|
||||||
appBridge?.dispatch(
|
|
||||||
actions.Redirect({
|
|
||||||
to: `/channels/`,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<EditIcon />
|
|
||||||
</IconButton>
|
|
||||||
}
|
|
||||||
onClick={(id) => setActiveChannelSlug(id)}
|
|
||||||
items={channelsData.map((c) => ({ label: c.name, id: c.slug })) || []}
|
|
||||||
/>
|
|
||||||
<div className={styles.configurationColumn}>
|
|
||||||
{!activeChannel || isFormDataLoading ? (
|
|
||||||
<LoadingIndicator />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<AppConfigurationForm
|
|
||||||
channelID={activeChannel.id}
|
|
||||||
key={activeChannelSlug}
|
|
||||||
channelSlug={activeChannel.slug}
|
|
||||||
mjmlConfigurationChoices={mjmlConfigurationsListData}
|
|
||||||
sendgridConfigurationChoices={sendgridConfigurationsListData}
|
|
||||||
onSubmit={async (data) => {
|
|
||||||
mutateAppChannelConfiguration({
|
|
||||||
channel: activeChannel.slug,
|
|
||||||
configuration: data,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
initialData={configurationData}
|
|
||||||
channelName={activeChannel?.name ?? activeChannelSlug}
|
|
||||||
/>
|
|
||||||
{saveError && <span>{saveError.message}</span>}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Instructions />
|
|
||||||
</AppColumnsLayout>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,72 +0,0 @@
|
||||||
import {
|
|
||||||
makeStyles,
|
|
||||||
OffsettedList,
|
|
||||||
OffsettedListBody,
|
|
||||||
OffsettedListHeader,
|
|
||||||
OffsettedListItem,
|
|
||||||
OffsettedListItemCell,
|
|
||||||
} from "@saleor/macaw-ui";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import { Typography } from "@material-ui/core";
|
|
||||||
import React from "react";
|
|
||||||
import { ChannelFragment } from "../../../../generated/graphql";
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => {
|
|
||||||
return {
|
|
||||||
listItem: {
|
|
||||||
cursor: "pointer",
|
|
||||||
height: "auto !important",
|
|
||||||
},
|
|
||||||
listItemActive: {
|
|
||||||
background: "#f4f4f4",
|
|
||||||
borderRadius: 4,
|
|
||||||
overflow: "hidden",
|
|
||||||
},
|
|
||||||
channelSlug: {
|
|
||||||
fontFamily: "monospace",
|
|
||||||
opacity: 0.8,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
channels: ChannelFragment[];
|
|
||||||
activeChannelSlug: string;
|
|
||||||
onChannelClick(channelSlug: string): void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ChannelsList = ({ channels, activeChannelSlug, onChannelClick }: Props) => {
|
|
||||||
const styles = useStyles();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<OffsettedList gridTemplate={["1fr"]}>
|
|
||||||
<OffsettedListHeader>
|
|
||||||
<Typography variant="h3" paragraph>
|
|
||||||
Available channels
|
|
||||||
</Typography>
|
|
||||||
</OffsettedListHeader>
|
|
||||||
<OffsettedListBody>
|
|
||||||
{channels.map((c) => {
|
|
||||||
return (
|
|
||||||
<OffsettedListItem
|
|
||||||
className={clsx(styles.listItem, {
|
|
||||||
[styles.listItemActive]: c.slug === activeChannelSlug,
|
|
||||||
})}
|
|
||||||
key={c.slug}
|
|
||||||
onClick={() => {
|
|
||||||
onChannelClick(c.slug);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<OffsettedListItemCell>
|
|
||||||
{c.name}
|
|
||||||
<Typography variant="caption" className={styles.channelSlug}>
|
|
||||||
{c.slug}
|
|
||||||
</Typography>
|
|
||||||
</OffsettedListItemCell>
|
|
||||||
</OffsettedListItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</OffsettedListBody>
|
|
||||||
</OffsettedList>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,87 +0,0 @@
|
||||||
import {
|
|
||||||
DeleteIcon,
|
|
||||||
IconButton,
|
|
||||||
makeStyles,
|
|
||||||
OffsettedList,
|
|
||||||
OffsettedListBody,
|
|
||||||
OffsettedListItem,
|
|
||||||
OffsettedListItemCell,
|
|
||||||
} from "@saleor/macaw-ui";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => {
|
|
||||||
return {
|
|
||||||
listItem: {
|
|
||||||
cursor: "pointer",
|
|
||||||
height: "auto !important",
|
|
||||||
},
|
|
||||||
listItemActive: {
|
|
||||||
background: "#f4f4f4",
|
|
||||||
borderRadius: 4,
|
|
||||||
overflow: "hidden",
|
|
||||||
},
|
|
||||||
channelSlug: {
|
|
||||||
fontFamily: "monospace",
|
|
||||||
opacity: 0.8,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
type ListItem = {
|
|
||||||
label: string;
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
listItems: ListItem[];
|
|
||||||
activeItemId?: string;
|
|
||||||
onItemClick(itemId?: string): void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ConfigurationsList = ({ listItems, activeItemId, onItemClick }: Props) => {
|
|
||||||
const styles = useStyles();
|
|
||||||
return (
|
|
||||||
<OffsettedList gridTemplate={["1fr"]}>
|
|
||||||
<OffsettedListBody>
|
|
||||||
{listItems.map((c) => {
|
|
||||||
return (
|
|
||||||
<OffsettedListItem
|
|
||||||
className={clsx(styles.listItem, {
|
|
||||||
[styles.listItemActive]: c.id === activeItemId,
|
|
||||||
})}
|
|
||||||
key={c.id}
|
|
||||||
onClick={() => {
|
|
||||||
onItemClick(c.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<OffsettedListItemCell>
|
|
||||||
{c.label}
|
|
||||||
<IconButton
|
|
||||||
variant="secondary"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DeleteIcon />
|
|
||||||
</IconButton>
|
|
||||||
</OffsettedListItemCell>
|
|
||||||
</OffsettedListItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<OffsettedListItem
|
|
||||||
className={clsx(styles.listItem, {
|
|
||||||
[styles.listItemActive]: activeItemId === undefined,
|
|
||||||
})}
|
|
||||||
key="new"
|
|
||||||
onClick={() => {
|
|
||||||
onItemClick();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<OffsettedListItemCell>Create new</OffsettedListItemCell>
|
|
||||||
</OffsettedListItem>
|
|
||||||
</OffsettedListBody>
|
|
||||||
</OffsettedList>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,35 +0,0 @@
|
||||||
import { Paper, Typography } from "@material-ui/core";
|
|
||||||
import { makeStyles } from "@saleor/macaw-ui";
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => {
|
|
||||||
return {
|
|
||||||
instructionsContainer: {
|
|
||||||
padding: 15,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export const Instructions = () => {
|
|
||||||
const styles = useStyles();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper elevation={0} className={styles.instructionsContainer}>
|
|
||||||
<Typography paragraph variant="h4">
|
|
||||||
Welcome to Emails and Messages App!
|
|
||||||
</Typography>
|
|
||||||
<Typography paragraph>
|
|
||||||
The application will allow you to send emails and messages to your customers using different
|
|
||||||
services.
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Typography paragraph variant="h4">
|
|
||||||
How to configure the app
|
|
||||||
</Typography>
|
|
||||||
<Typography paragraph>
|
|
||||||
Start by creating a new configuration for provider of your choice. You can create multiple
|
|
||||||
configurations and then assign them to channels. Navigate to the relevant tab to configure
|
|
||||||
the provider.
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
||||||
|
import { BoxWithBorder } from "../../../components/box-with-border";
|
||||||
|
import { defaultPadding } from "../../../components/ui-defaults";
|
||||||
|
import { BoxFooter } from "../../../components/box-footer";
|
||||||
|
|
||||||
|
interface ProviderSelectionBoxProps {
|
||||||
|
providerName: string;
|
||||||
|
providerDescription: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProviderSelectionBox = (props: ProviderSelectionBoxProps) => {
|
||||||
|
return (
|
||||||
|
<BoxWithBorder display={"grid"} alignItems={"center"} justifyContent={"center"}>
|
||||||
|
<Box
|
||||||
|
padding={defaultPadding}
|
||||||
|
display={"grid"}
|
||||||
|
alignItems={"center"}
|
||||||
|
justifyContent={"center"}
|
||||||
|
>
|
||||||
|
<Text variant="heading">{props.providerName}</Text>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
padding={defaultPadding}
|
||||||
|
display={"grid"}
|
||||||
|
alignItems={"center"}
|
||||||
|
justifyContent={"center"}
|
||||||
|
>
|
||||||
|
<Text>{props.providerDescription}</Text>
|
||||||
|
</Box>
|
||||||
|
<BoxFooter justifyContent={"center"} alignItems={"center"}>
|
||||||
|
<Button onClick={props.onClick}>Choose</Button>
|
||||||
|
</BoxFooter>
|
||||||
|
</BoxWithBorder>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,109 +0,0 @@
|
||||||
import { Card, CardContent, CardHeader, Divider } from "@material-ui/core";
|
|
||||||
("@material-ui/icons");
|
|
||||||
import { DeleteIcon, IconButton, List, ListItem, ListItemCell } from "@saleor/macaw-ui";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
import { makeStyles } from "@saleor/macaw-ui";
|
|
||||||
import { Skeleton } from "@material-ui/lab";
|
|
||||||
|
|
||||||
export const useStyles = makeStyles((theme) => ({
|
|
||||||
menu: {
|
|
||||||
height: "fit-content",
|
|
||||||
},
|
|
||||||
clickable: {
|
|
||||||
cursor: "pointer",
|
|
||||||
},
|
|
||||||
selected: {
|
|
||||||
"&&&&::before": {
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
width: "4px",
|
|
||||||
height: "100%",
|
|
||||||
backgroundColor: theme.palette.saleor.active[1],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
spaceBetween: {
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
tableRow: {
|
|
||||||
minHeight: "48px",
|
|
||||||
"&::after": {
|
|
||||||
display: "none",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
greyText: {
|
|
||||||
color: theme.palette.text.hint,
|
|
||||||
},
|
|
||||||
link: {
|
|
||||||
all: "inherit",
|
|
||||||
display: "contents",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
interface SideMenuProps {
|
|
||||||
title: string;
|
|
||||||
noItemsText?: string;
|
|
||||||
items: { id: string; label: string }[];
|
|
||||||
selectedItemId?: string;
|
|
||||||
headerToolbar?: React.ReactNode;
|
|
||||||
onDelete?: (itemId: string) => void;
|
|
||||||
onClick: (itemId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SideMenu: React.FC<SideMenuProps> = ({
|
|
||||||
title,
|
|
||||||
items,
|
|
||||||
headerToolbar,
|
|
||||||
selectedItemId,
|
|
||||||
noItemsText,
|
|
||||||
onDelete,
|
|
||||||
onClick,
|
|
||||||
}) => {
|
|
||||||
const classes = useStyles();
|
|
||||||
|
|
||||||
const isNoItems = !items || !items.length;
|
|
||||||
return (
|
|
||||||
<Card className={classes.menu}>
|
|
||||||
<CardHeader title={title} action={headerToolbar} />
|
|
||||||
{isNoItems ? (
|
|
||||||
!!noItemsText && <CardContent className={classes.greyText}>{noItemsText}</CardContent>
|
|
||||||
) : (
|
|
||||||
<List gridTemplate={["1fr"]}>
|
|
||||||
{items.map((item) => (
|
|
||||||
<React.Fragment key={item.id}>
|
|
||||||
<Divider />
|
|
||||||
<ListItem
|
|
||||||
className={clsx(classes.clickable, classes.tableRow, {
|
|
||||||
[classes.selected]: item.id === selectedItemId,
|
|
||||||
})}
|
|
||||||
onClick={() => onClick(item.id)}
|
|
||||||
>
|
|
||||||
<ListItemCell>
|
|
||||||
<div className={classes.spaceBetween}>
|
|
||||||
{item.label}
|
|
||||||
{!!onDelete && (
|
|
||||||
<IconButton
|
|
||||||
variant="secondary"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
onDelete(item.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DeleteIcon />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ListItemCell>
|
|
||||||
</ListItem>
|
|
||||||
</React.Fragment>
|
|
||||||
)) ?? <Skeleton />}
|
|
||||||
<Divider />
|
|
||||||
</List>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,7 +1,6 @@
|
||||||
import { AuthData } from "@saleor/app-sdk/APL";
|
import { AuthData } from "@saleor/app-sdk/APL";
|
||||||
import { Client } from "urql";
|
import { Client } from "urql";
|
||||||
import { logger as pinoLogger } from "../../lib/logger";
|
import { logger as pinoLogger } from "../../lib/logger";
|
||||||
import { AppConfigurationService } from "../app-configuration/get-app-configuration.service";
|
|
||||||
import { MjmlConfigurationService } from "../mjml/configuration/get-mjml-configuration.service";
|
import { MjmlConfigurationService } from "../mjml/configuration/get-mjml-configuration.service";
|
||||||
import { sendMjml } from "../mjml/send-mjml";
|
import { sendMjml } from "../mjml/send-mjml";
|
||||||
import { SendgridConfigurationService } from "../sendgrid/configuration/get-sendgrid-configuration.service";
|
import { SendgridConfigurationService } from "../sendgrid/configuration/get-sendgrid-configuration.service";
|
||||||
|
@ -31,73 +30,53 @@ export const sendEventMessages = async ({
|
||||||
|
|
||||||
logger.debug("Function called");
|
logger.debug("Function called");
|
||||||
|
|
||||||
const appConfigurationService = new AppConfigurationService({
|
const mjmlConfigurationService = new MjmlConfigurationService({
|
||||||
apiClient: client,
|
apiClient: client,
|
||||||
saleorApiUrl: authData.saleorApiUrl,
|
saleorApiUrl: authData.saleorApiUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
const channelAppConfiguration = await appConfigurationService.getChannelConfiguration(channel);
|
const availableMjmlConfigurations = await mjmlConfigurationService.getConfigurations({
|
||||||
|
active: true,
|
||||||
|
availableInChannel: channel,
|
||||||
|
});
|
||||||
|
|
||||||
if (!channelAppConfiguration) {
|
for (const mjmlConfiguration of availableMjmlConfigurations) {
|
||||||
logger.warn("App has no configuration for this channel");
|
const mjmlStatus = await sendMjml({
|
||||||
return;
|
event,
|
||||||
}
|
payload,
|
||||||
logger.debug("Channel has assigned app configuration");
|
recipientEmail,
|
||||||
|
mjmlConfiguration,
|
||||||
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 (mjmlStatus?.errors.length) {
|
||||||
id: channelAppConfiguration.mjmlConfigurationId,
|
logger.error("MJML errors");
|
||||||
});
|
logger.error(mjmlStatus?.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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { messageEventTypes } from "../../event-handlers/message-event-types";
|
||||||
import { MjmlConfig as MjmlConfigurationRoot, MjmlConfiguration } from "./mjml-config";
|
import { MjmlConfig as MjmlConfigurationRoot, MjmlConfiguration } from "./mjml-config";
|
||||||
import { defaultMjmlTemplates, defaultMjmlSubjectTemplates } from "../default-templates";
|
import { defaultMjmlTemplates, defaultMjmlSubjectTemplates } from "../default-templates";
|
||||||
import { generateRandomId } from "../../../lib/generate-random-id";
|
import { generateRandomId } from "../../../lib/generate-random-id";
|
||||||
|
import { isAvailableInChannel } from "../../../lib/is-available-in-channel";
|
||||||
|
|
||||||
export const getDefaultEventsConfiguration = (): MjmlConfiguration["events"] =>
|
export const getDefaultEventsConfiguration = (): MjmlConfiguration["events"] =>
|
||||||
messageEventTypes.map((eventType) => ({
|
messageEventTypes.map((eventType) => ({
|
||||||
|
@ -24,6 +25,10 @@ export const getDefaultEmptyConfiguration = (): MjmlConfiguration => {
|
||||||
smtpPassword: "",
|
smtpPassword: "",
|
||||||
encryption: "NONE",
|
encryption: "NONE",
|
||||||
events: getDefaultEventsConfiguration(),
|
events: getDefaultEventsConfiguration(),
|
||||||
|
channels: {
|
||||||
|
excludedFrom: [],
|
||||||
|
restrictedTo: [],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return defaultConfig;
|
return defaultConfig;
|
||||||
|
@ -45,6 +50,7 @@ const getConfiguration =
|
||||||
|
|
||||||
export interface FilterConfigurationsArgs {
|
export interface FilterConfigurationsArgs {
|
||||||
ids?: string[];
|
ids?: string[];
|
||||||
|
availableInChannel?: string;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -57,14 +63,28 @@ const getConfigurations =
|
||||||
|
|
||||||
let filtered = mjmlConfigRoot.configurations;
|
let filtered = mjmlConfigRoot.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!,
|
||||||
|
restrictedToChannels: c.channels.restrictedTo,
|
||||||
|
excludedChannels: c.channels.excludedFrom,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return filtered;
|
return filtered;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -79,6 +99,7 @@ const createConfiguration =
|
||||||
id: generateRandomId(),
|
id: generateRandomId(),
|
||||||
events: getDefaultEventsConfiguration(),
|
events: getDefaultEventsConfiguration(),
|
||||||
};
|
};
|
||||||
|
|
||||||
mjmlConfigNormalized.configurations.push(newConfiguration);
|
mjmlConfigNormalized.configurations.push(newConfiguration);
|
||||||
return mjmlConfigNormalized;
|
return mjmlConfigNormalized;
|
||||||
};
|
};
|
||||||
|
|
|
@ -19,6 +19,10 @@ export const mjmlConfigurationBaseObjectSchema = z.object({
|
||||||
smtpUser: z.string(),
|
smtpUser: z.string(),
|
||||||
smtpPassword: z.string(),
|
smtpPassword: z.string(),
|
||||||
encryption: z.enum(smtpEncryptionTypes),
|
encryption: z.enum(smtpEncryptionTypes),
|
||||||
|
channels: z.object({
|
||||||
|
excludedFrom: z.array(z.string()),
|
||||||
|
restrictedTo: z.array(z.string()),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const mjmlCreateConfigurationSchema = mjmlConfigurationBaseObjectSchema;
|
export const mjmlCreateConfigurationSchema = mjmlConfigurationBaseObjectSchema;
|
||||||
|
|
|
@ -15,6 +15,10 @@ export interface MjmlConfiguration {
|
||||||
id: string;
|
id: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
configurationName: string;
|
configurationName: string;
|
||||||
|
channels: {
|
||||||
|
excludedFrom: string[];
|
||||||
|
restrictedTo: string[];
|
||||||
|
};
|
||||||
senderName: string;
|
senderName: string;
|
||||||
senderEmail: string;
|
senderEmail: string;
|
||||||
smtpHost: string;
|
smtpHost: string;
|
||||||
|
|
|
@ -1,303 +0,0 @@
|
||||||
import { Controller, useForm } from "react-hook-form";
|
|
||||||
import { Divider, TextField, TextFieldProps, Typography } from "@material-ui/core";
|
|
||||||
import { Button, makeStyles, SwitchSelector, SwitchSelectorButton } from "@saleor/macaw-ui";
|
|
||||||
import React, { useEffect } from "react";
|
|
||||||
import { MjmlConfiguration, smtpEncryptionTypes } from "../mjml-config";
|
|
||||||
import { trpcClient } from "../../../trpc/trpc-client";
|
|
||||||
import { useAppBridge, actions } from "@saleor/app-sdk/app-bridge";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useDashboardNotification } from "@saleor/apps-shared";
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => ({
|
|
||||||
field: {
|
|
||||||
marginBottom: 20,
|
|
||||||
},
|
|
||||||
editor: {
|
|
||||||
marginBottom: 20,
|
|
||||||
},
|
|
||||||
preview: {
|
|
||||||
marginBottom: 20,
|
|
||||||
},
|
|
||||||
sectionHeader: {
|
|
||||||
marginTop: 20,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
onConfigurationSaved: () => void;
|
|
||||||
initialData: MjmlConfiguration;
|
|
||||||
configurationId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MjmlConfigurationForm = (props: Props) => {
|
|
||||||
const styles = useStyles();
|
|
||||||
const { notifySuccess, notifyError } = useDashboardNotification();
|
|
||||||
|
|
||||||
const { handleSubmit, control, reset, setError } = useForm<MjmlConfiguration>({
|
|
||||||
defaultValues: props.initialData,
|
|
||||||
});
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const { mutate: createOrUpdateConfiguration } =
|
|
||||||
trpcClient.mjmlConfiguration.updateOrCreateConfiguration.useMutation({
|
|
||||||
onSuccess: async (data, variables) => {
|
|
||||||
await queryClient.cancelQueries({ queryKey: ["mjmlConfiguration", "getConfigurations"] });
|
|
||||||
|
|
||||||
// Optimistically update to the new value
|
|
||||||
queryClient.setQueryData<Array<MjmlConfiguration>>(
|
|
||||||
["mjmlConfiguration", "getConfigurations", undefined],
|
|
||||||
(old) => {
|
|
||||||
if (old) {
|
|
||||||
const index = old.findIndex((c) => c.id === data.id);
|
|
||||||
// If thats an update, replace the old one
|
|
||||||
if (index !== -1) {
|
|
||||||
old[index] = data;
|
|
||||||
return [...old];
|
|
||||||
} else {
|
|
||||||
return [...old, data];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return [data];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Trigger refetch to make sure we have a fresh data
|
|
||||||
props.onConfigurationSaved();
|
|
||||||
notifySuccess("Configuration saved");
|
|
||||||
},
|
|
||||||
onError(error) {
|
|
||||||
let isFieldErrorSet = false;
|
|
||||||
const fieldErrors = error.data?.zodError?.fieldErrors || {};
|
|
||||||
for (const fieldName in fieldErrors) {
|
|
||||||
for (const message of fieldErrors[fieldName] || []) {
|
|
||||||
isFieldErrorSet = true;
|
|
||||||
setError(fieldName as keyof MjmlConfiguration, {
|
|
||||||
type: "manual",
|
|
||||||
message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const formErrors = error.data?.zodError?.formErrors || [];
|
|
||||||
const formErrorMessage = formErrors.length ? formErrors.join("\n") : undefined;
|
|
||||||
notifyError(
|
|
||||||
"Could not save the configuration",
|
|
||||||
isFieldErrorSet ? "Submitted form contain errors" : "Error saving configuration",
|
|
||||||
formErrorMessage
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// when the configuration tab is changed, initialData change and form has to be updated
|
|
||||||
useEffect(() => {
|
|
||||||
reset(props.initialData);
|
|
||||||
}, [props.initialData, props.configurationId, reset]);
|
|
||||||
|
|
||||||
const CommonFieldProps: TextFieldProps = {
|
|
||||||
className: styles.field,
|
|
||||||
fullWidth: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const isNewConfiguration = !props.configurationId;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit((data, event) => {
|
|
||||||
createOrUpdateConfiguration({
|
|
||||||
...data,
|
|
||||||
});
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{isNewConfiguration ? (
|
|
||||||
<Typography variant="h2" paragraph>
|
|
||||||
Create a new configuration
|
|
||||||
</Typography>
|
|
||||||
) : (
|
|
||||||
<Typography variant="h2" paragraph>
|
|
||||||
Configuration
|
|
||||||
<strong>{` ${props.initialData.configurationName} `}</strong>
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
name="configurationName"
|
|
||||||
control={control}
|
|
||||||
render={({ field: { onChange, value }, fieldState: { error }, formState: { errors } }) => (
|
|
||||||
<TextField
|
|
||||||
label="Configuration name"
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
error={!!error}
|
|
||||||
helperText={
|
|
||||||
error?.message || "Name of the configuration, for example 'Production' or 'Test'"
|
|
||||||
}
|
|
||||||
{...CommonFieldProps}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="active"
|
|
||||||
render={({ field: { value, name, onChange } }) => (
|
|
||||||
<div className={styles.field}>
|
|
||||||
{/* TODO: fix types in the MacawUI */}
|
|
||||||
{/* @ts-ignore: MacawUI use wrong type for */}
|
|
||||||
<SwitchSelector key={name} className={styles.field}>
|
|
||||||
{[
|
|
||||||
{ label: "Active", value: true },
|
|
||||||
{ label: "Disabled", value: false },
|
|
||||||
].map((button) => (
|
|
||||||
// @ts-ignore: MacawUI use wrong type for SwitchSelectorButton
|
|
||||||
<SwitchSelectorButton
|
|
||||||
value={button.value.toString()}
|
|
||||||
onClick={() => onChange(button.value)}
|
|
||||||
activeTab={value.toString()}
|
|
||||||
key={button.label}
|
|
||||||
>
|
|
||||||
{button.label}
|
|
||||||
</SwitchSelectorButton>
|
|
||||||
))}
|
|
||||||
</SwitchSelector>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<Typography variant="h3" paragraph className={styles.sectionHeader}>
|
|
||||||
Sender details
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
name="senderName"
|
|
||||||
control={control}
|
|
||||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
|
||||||
<TextField
|
|
||||||
label="Sender name"
|
|
||||||
error={!!error}
|
|
||||||
helperText={error?.message || "Name which will be presented as author of the email"}
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
{...CommonFieldProps}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
name="senderEmail"
|
|
||||||
control={control}
|
|
||||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
|
||||||
<>
|
|
||||||
<TextField
|
|
||||||
label="Sender email"
|
|
||||||
value={value}
|
|
||||||
helperText={error?.message || "Email which will be presented as author of the email"}
|
|
||||||
error={!!error}
|
|
||||||
onChange={onChange}
|
|
||||||
{...CommonFieldProps}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<Typography variant="h3" paragraph className={styles.sectionHeader}>
|
|
||||||
SMTP server configuration
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
name="smtpHost"
|
|
||||||
control={control}
|
|
||||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
|
||||||
<TextField
|
|
||||||
label="SMTP server host"
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
helperText={
|
|
||||||
error?.message ||
|
|
||||||
"Address of the SMTP server, without the protocol. For example 'smtp.example.com'"
|
|
||||||
}
|
|
||||||
error={!!error}
|
|
||||||
{...CommonFieldProps}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
name="smtpPort"
|
|
||||||
control={control}
|
|
||||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
|
||||||
<TextField
|
|
||||||
label="SMTP server port"
|
|
||||||
helperText={error?.message}
|
|
||||||
error={!!error}
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
{...CommonFieldProps}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
name="smtpUser"
|
|
||||||
control={control}
|
|
||||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
|
||||||
<TextField
|
|
||||||
label="SMTP server user"
|
|
||||||
value={value}
|
|
||||||
helperText={error?.message}
|
|
||||||
error={!!error}
|
|
||||||
onChange={onChange}
|
|
||||||
{...CommonFieldProps}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
name="smtpPassword"
|
|
||||||
control={control}
|
|
||||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
|
||||||
<TextField
|
|
||||||
label="SMTP server password"
|
|
||||||
value={value}
|
|
||||||
helperText={error?.message}
|
|
||||||
error={!!error}
|
|
||||||
onChange={onChange}
|
|
||||||
{...CommonFieldProps}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="encryption"
|
|
||||||
render={({ field: { value, name, onChange } }) => (
|
|
||||||
<div className={styles.field}>
|
|
||||||
{/* TODO: fix types in the MacawUI */}
|
|
||||||
{/* @ts-ignore: MacawUI use wrong type for SwitchSelector */}
|
|
||||||
<SwitchSelector key={name}>
|
|
||||||
{smtpEncryptionTypes.map((encryptionType) => (
|
|
||||||
// @ts-ignore: MacawUI use wrong type for SwitchSelectorButton
|
|
||||||
<SwitchSelectorButton
|
|
||||||
value={encryptionType}
|
|
||||||
onClick={() => onChange(encryptionType)}
|
|
||||||
activeTab={value}
|
|
||||||
key={encryptionType}
|
|
||||||
>
|
|
||||||
{encryptionType === "NONE" ? "No encryption" : encryptionType}
|
|
||||||
</SwitchSelectorButton>
|
|
||||||
))}
|
|
||||||
</SwitchSelector>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button type="submit" fullWidth variant="primary">
|
|
||||||
Save configuration
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,160 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
import { IconButton, makeStyles } from "@saleor/macaw-ui";
|
|
||||||
import { AppColumnsLayout } from "../../../ui/app-columns-layout";
|
|
||||||
import { trpcClient } from "../../../trpc/trpc-client";
|
|
||||||
import { MjmlConfigurationForm } from "./mjml-configuration-form";
|
|
||||||
import { getDefaultEmptyConfiguration } from "../mjml-config-container";
|
|
||||||
import { NextRouter, useRouter } from "next/router";
|
|
||||||
import { mjmlUrls } from "../../urls";
|
|
||||||
import { MjmlTemplatesCard } from "./mjml-templates-card";
|
|
||||||
import { SideMenu } from "../../../app-configuration/ui/side-menu";
|
|
||||||
import { MjmlConfiguration } from "../mjml-config";
|
|
||||||
import { LoadingIndicator } from "../../../ui/loading-indicator";
|
|
||||||
import { Add } from "@material-ui/icons";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { MjmlInstructions } from "./mjml-instructions";
|
|
||||||
import { useDashboardNotification } from "@saleor/apps-shared";
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => {
|
|
||||||
return {
|
|
||||||
formContainer: {
|
|
||||||
top: 0,
|
|
||||||
},
|
|
||||||
instructionsContainer: {
|
|
||||||
padding: 15,
|
|
||||||
},
|
|
||||||
configurationColumn: {
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 20,
|
|
||||||
maxWidth: 600,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
interface MjmlConfigurationTabProps {
|
|
||||||
configurationId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const navigateToFirstConfiguration = (router: NextRouter, configurations?: MjmlConfiguration[]) => {
|
|
||||||
if (!configurations || !configurations?.length) {
|
|
||||||
router.replace(mjmlUrls.configuration());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const firstConfigurationId = configurations[0]?.id;
|
|
||||||
if (firstConfigurationId) {
|
|
||||||
router.replace(mjmlUrls.configuration(firstConfigurationId));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MjmlConfigurationTab = ({ configurationId }: MjmlConfigurationTabProps) => {
|
|
||||||
const styles = useStyles();
|
|
||||||
const { notifyError, notifySuccess } = useDashboardNotification();
|
|
||||||
const router = useRouter();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: configurations,
|
|
||||||
refetch: refetchConfigurations,
|
|
||||||
isLoading: configurationsIsLoading,
|
|
||||||
isFetching: configurationsIsFetching,
|
|
||||||
isRefetching: configurationsIsRefetching,
|
|
||||||
} = trpcClient.mjmlConfiguration.getConfigurations.useQuery(undefined, {
|
|
||||||
onSuccess(data) {
|
|
||||||
if (!configurationId) {
|
|
||||||
console.log("no conf id! navigate to first");
|
|
||||||
navigateToFirstConfiguration(router, data);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutate: deleteConfiguration } =
|
|
||||||
trpcClient.mjmlConfiguration.deleteConfiguration.useMutation({
|
|
||||||
onError: (error) => {
|
|
||||||
notifyError("Could not remove the configuration", error.message);
|
|
||||||
},
|
|
||||||
onSuccess: async (_data, variables) => {
|
|
||||||
await queryClient.cancelQueries({ queryKey: ["mjmlConfiguration", "getConfigurations"] });
|
|
||||||
// remove value from the cache after the success
|
|
||||||
queryClient.setQueryData<Array<MjmlConfiguration>>(
|
|
||||||
["mjmlConfiguration", "getConfigurations"],
|
|
||||||
(old) => {
|
|
||||||
if (old) {
|
|
||||||
const index = old.findIndex((c) => c.id === variables.id);
|
|
||||||
if (index !== -1) {
|
|
||||||
delete old[index];
|
|
||||||
return [...old];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// if we just deleted the configuration that was selected
|
|
||||||
// we have to update the URL
|
|
||||||
if (variables.id === configurationId) {
|
|
||||||
router.replace(mjmlUrls.configuration());
|
|
||||||
}
|
|
||||||
|
|
||||||
refetchConfigurations();
|
|
||||||
notifySuccess("Success", "Removed successfully");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (configurationsIsLoading || configurationsIsFetching) {
|
|
||||||
return <LoadingIndicator />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const configuration = configurations?.find((c) => c.id === configurationId?.toString());
|
|
||||||
|
|
||||||
if (configurationId && !configuration) {
|
|
||||||
return <div>Configuration not found</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppColumnsLayout>
|
|
||||||
<SideMenu
|
|
||||||
title="Configurations"
|
|
||||||
selectedItemId={configurationId}
|
|
||||||
headerToolbar={
|
|
||||||
<IconButton
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => {
|
|
||||||
router.replace(mjmlUrls.configuration());
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Add />
|
|
||||||
</IconButton>
|
|
||||||
}
|
|
||||||
onClick={(id) => router.replace(mjmlUrls.configuration(id))}
|
|
||||||
onDelete={(id) => {
|
|
||||||
deleteConfiguration({ id });
|
|
||||||
}}
|
|
||||||
items={configurations?.map((c) => ({ label: c.configurationName, id: c.id })) || []}
|
|
||||||
/>
|
|
||||||
<div className={styles.configurationColumn}>
|
|
||||||
{configurationsIsLoading || configurationsIsFetching ? (
|
|
||||||
<LoadingIndicator />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<MjmlConfigurationForm
|
|
||||||
onConfigurationSaved={() => refetchConfigurations()}
|
|
||||||
initialData={configuration || getDefaultEmptyConfiguration()}
|
|
||||||
configurationId={configurationId}
|
|
||||||
/>
|
|
||||||
{!!configurationId && !!configuration && (
|
|
||||||
<MjmlTemplatesCard
|
|
||||||
configurationId={configurationId}
|
|
||||||
configuration={configuration}
|
|
||||||
onEventChanged={() => {
|
|
||||||
refetchConfigurations();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<MjmlInstructions />
|
|
||||||
</AppColumnsLayout>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,271 +0,0 @@
|
||||||
import { Controller, useForm } from "react-hook-form";
|
|
||||||
import { CircularProgress, Grid, TextField, TextFieldProps, Typography } from "@material-ui/core";
|
|
||||||
import {
|
|
||||||
BackSmallIcon,
|
|
||||||
Button,
|
|
||||||
IconButton,
|
|
||||||
makeStyles,
|
|
||||||
SwitchSelector,
|
|
||||||
SwitchSelectorButton,
|
|
||||||
} from "@saleor/macaw-ui";
|
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import { MjmlEventConfiguration } from "../mjml-config";
|
|
||||||
import { CodeEditor } from "../../../ui/code-editor";
|
|
||||||
import { MjmlPreview } from "./mjml-preview";
|
|
||||||
import {
|
|
||||||
MessageEventTypes,
|
|
||||||
messageEventTypesLabels,
|
|
||||||
} from "../../../event-handlers/message-event-types";
|
|
||||||
import { trpcClient } from "../../../trpc/trpc-client";
|
|
||||||
import { useDebounce } from "usehooks-ts";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { mjmlUrls } from "../../urls";
|
|
||||||
import { useAppBridge, actions } from "@saleor/app-sdk/app-bridge";
|
|
||||||
import { examplePayloads } from "../../../event-handlers/default-payloads";
|
|
||||||
import { useDashboardNotification } from "@saleor/apps-shared";
|
|
||||||
|
|
||||||
const PREVIEW_DEBOUNCE_DELAY = 500;
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => ({
|
|
||||||
viewContainer: {
|
|
||||||
padding: theme.spacing(2),
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "flex-start",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing(2),
|
|
||||||
marginBottom: theme.spacing(2),
|
|
||||||
margin: "0 auto",
|
|
||||||
},
|
|
||||||
previewHeader: {
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing(1),
|
|
||||||
marginTop: theme.spacing(2),
|
|
||||||
marginBottom: theme.spacing(2),
|
|
||||||
},
|
|
||||||
|
|
||||||
field: {
|
|
||||||
marginBottom: theme.spacing(3),
|
|
||||||
},
|
|
||||||
editor: {
|
|
||||||
marginBottom: theme.spacing(3),
|
|
||||||
},
|
|
||||||
preview: {
|
|
||||||
marginBottom: theme.spacing(3),
|
|
||||||
},
|
|
||||||
form: {
|
|
||||||
maxWidth: 800,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
type EventConfigurationFormProps = {
|
|
||||||
initialData: MjmlEventConfiguration;
|
|
||||||
configurationId: string;
|
|
||||||
eventType: MessageEventTypes;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const EventConfigurationForm = ({
|
|
||||||
initialData,
|
|
||||||
configurationId,
|
|
||||||
eventType,
|
|
||||||
}: EventConfigurationFormProps) => {
|
|
||||||
const { notifySuccess, notifyError } = useDashboardNotification();
|
|
||||||
const router = useRouter();
|
|
||||||
const { appBridge } = useAppBridge();
|
|
||||||
const { handleSubmit, control, getValues, setError } = useForm<MjmlEventConfiguration>({
|
|
||||||
defaultValues: initialData,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [lastValidRenderedTemplate, setLastValidRenderedTemplate] = useState("");
|
|
||||||
|
|
||||||
const [lastValidRenderedSubject, setLastValidRenderedSubject] = useState("");
|
|
||||||
|
|
||||||
const [payload, setPayload] = useState<string>(
|
|
||||||
JSON.stringify(examplePayloads[eventType], undefined, 2)
|
|
||||||
);
|
|
||||||
|
|
||||||
const { template, subject } = getValues();
|
|
||||||
const debouncedMutationVariables = useDebounce(
|
|
||||||
{ template, subject, payload },
|
|
||||||
PREVIEW_DEBOUNCE_DELAY
|
|
||||||
);
|
|
||||||
|
|
||||||
const styles = useStyles();
|
|
||||||
|
|
||||||
const CommonFieldProps: TextFieldProps = {
|
|
||||||
className: styles.field,
|
|
||||||
fullWidth: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { mutate: fetchTemplatePreview, isLoading: isFetchingTemplatePreview } =
|
|
||||||
trpcClient.mjmlConfiguration.renderTemplate.useMutation({
|
|
||||||
onSuccess: (data) => {
|
|
||||||
if (data.renderedEmailBody) {
|
|
||||||
setLastValidRenderedTemplate(data.renderedEmailBody);
|
|
||||||
}
|
|
||||||
if (data.renderedSubject) {
|
|
||||||
setLastValidRenderedSubject(data.renderedSubject);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutate: updateEventConfiguration } =
|
|
||||||
trpcClient.mjmlConfiguration.updateEventConfiguration.useMutation({
|
|
||||||
onSuccess: (data) => {
|
|
||||||
notifySuccess("Success", "Configuration saved");
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
let isFieldErrorSet = false;
|
|
||||||
const fieldErrors = error.data?.zodError?.fieldErrors || {};
|
|
||||||
for (const fieldName in fieldErrors) {
|
|
||||||
for (const message of fieldErrors[fieldName] || []) {
|
|
||||||
isFieldErrorSet = true;
|
|
||||||
setError(fieldName as keyof MjmlEventConfiguration, {
|
|
||||||
type: "manual",
|
|
||||||
message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const formErrors = error.data?.zodError?.formErrors || [];
|
|
||||||
const formErrorMessage = formErrors.length ? formErrors.join("\n") : undefined;
|
|
||||||
|
|
||||||
notifyError(
|
|
||||||
"Could not save the configuration",
|
|
||||||
isFieldErrorSet ? "Submitted form contain errors" : "Error saving configuration",
|
|
||||||
formErrorMessage
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
template: debouncedTemplate,
|
|
||||||
subject: debouncedSubject,
|
|
||||||
payload: debouncedPayload,
|
|
||||||
} = debouncedMutationVariables;
|
|
||||||
useEffect(() => {
|
|
||||||
fetchTemplatePreview({
|
|
||||||
template: debouncedTemplate,
|
|
||||||
subject: debouncedSubject,
|
|
||||||
payload: debouncedPayload,
|
|
||||||
});
|
|
||||||
}, [debouncedPayload, debouncedSubject, debouncedTemplate, fetchTemplatePreview]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.viewContainer}>
|
|
||||||
<div className={styles.header}>
|
|
||||||
<IconButton
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => {
|
|
||||||
router.push(mjmlUrls.configuration(configurationId));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<BackSmallIcon />
|
|
||||||
</IconButton>
|
|
||||||
<Typography variant="h2">
|
|
||||||
{messageEventTypesLabels[eventType]} event configuration
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
<Grid container spacing={2}>
|
|
||||||
<Grid item xs={12} lg={7}>
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit((data, event) => {
|
|
||||||
updateEventConfiguration({ ...data, configurationId });
|
|
||||||
})}
|
|
||||||
className={styles.form}
|
|
||||||
>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="active"
|
|
||||||
render={({ field: { value, name, onChange } }) => (
|
|
||||||
<div className={styles.field}>
|
|
||||||
{/* TODO: fix types in the MacawUI */}
|
|
||||||
{/* @ts-ignore: MacawUI use wrong type for */}
|
|
||||||
<SwitchSelector key={name} className={styles.field}>
|
|
||||||
{[
|
|
||||||
{ label: "Active", value: true },
|
|
||||||
{ label: "Disabled", value: false },
|
|
||||||
].map((button) => (
|
|
||||||
// @ts-ignore: MacawUI use wrong type for SwitchSelectorButton
|
|
||||||
<SwitchSelectorButton
|
|
||||||
value={button.value.toString()}
|
|
||||||
onClick={() => onChange(button.value)}
|
|
||||||
activeTab={value.toString()}
|
|
||||||
key={button.label}
|
|
||||||
>
|
|
||||||
{button.label}
|
|
||||||
</SwitchSelectorButton>
|
|
||||||
))}
|
|
||||||
</SwitchSelector>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
name="subject"
|
|
||||||
control={control}
|
|
||||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
|
||||||
<TextField
|
|
||||||
label="Email subject"
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
error={!!error}
|
|
||||||
helperText={
|
|
||||||
!error
|
|
||||||
? "You can use variables like {{ order.number }} or {{ order.userEmail }}"
|
|
||||||
: error.message
|
|
||||||
}
|
|
||||||
{...CommonFieldProps}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="template"
|
|
||||||
render={({ field: { value, onChange } }) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={styles.editor}>
|
|
||||||
<CodeEditor
|
|
||||||
initialTemplate={value}
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
language="xml"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button type="submit" fullWidth variant="primary">
|
|
||||||
Save configuration
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={12} lg={5}>
|
|
||||||
<div>
|
|
||||||
<div className={styles.previewHeader}>
|
|
||||||
<Typography variant="h2">Preview</Typography>
|
|
||||||
{isFetchingTemplatePreview && <CircularProgress size="3rem" color="primary" />}
|
|
||||||
</div>
|
|
||||||
<Typography variant="h3" paragraph>
|
|
||||||
Subject: {lastValidRenderedSubject}
|
|
||||||
</Typography>
|
|
||||||
<div className={styles.preview}>
|
|
||||||
<MjmlPreview value={lastValidRenderedTemplate} />
|
|
||||||
</div>
|
|
||||||
<CodeEditor
|
|
||||||
initialTemplate={payload}
|
|
||||||
value={payload}
|
|
||||||
onChange={setPayload}
|
|
||||||
language="json"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,53 +0,0 @@
|
||||||
import { Link, Paper, Typography } from "@material-ui/core";
|
|
||||||
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
|
||||||
import { makeStyles } from "@saleor/macaw-ui";
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => {
|
|
||||||
return {
|
|
||||||
instructionsContainer: {
|
|
||||||
padding: 15,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export const MjmlInstructions = () => {
|
|
||||||
const styles = useStyles();
|
|
||||||
|
|
||||||
const { appBridge } = useAppBridge();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper elevation={0} className={styles.instructionsContainer}>
|
|
||||||
<Typography paragraph variant="h4">
|
|
||||||
MJML Provider
|
|
||||||
</Typography>
|
|
||||||
<Typography paragraph>
|
|
||||||
You can use this provider to send emails using MJML as a template language. The emails are
|
|
||||||
then sent using the SMTP.
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Typography paragraph>
|
|
||||||
<Link
|
|
||||||
href="https://mjml.io/"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
appBridge?.dispatch(
|
|
||||||
actions.Redirect({
|
|
||||||
to: "https://mjml.io/",
|
|
||||||
newContext: true,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Visit the MJML Homepage
|
|
||||||
</Link>
|
|
||||||
</Typography>
|
|
||||||
<Typography paragraph variant="h4">
|
|
||||||
How to configure
|
|
||||||
</Typography>
|
|
||||||
<Typography paragraph>
|
|
||||||
Create a new configuration and fill in the required fields. After the configuration is
|
|
||||||
saved, you will be able to modify the email templates.
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,18 +0,0 @@
|
||||||
import { Card } from "@material-ui/core";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
value?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MjmlPreview = ({ value }: Props) => {
|
|
||||||
return (
|
|
||||||
<Card style={{ padding: "2rem", width: "100%" }}>
|
|
||||||
{value?.length ? (
|
|
||||||
<div dangerouslySetInnerHTML={{ __html: value }} />
|
|
||||||
) : (
|
|
||||||
<p>No template preview</p>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,128 +0,0 @@
|
||||||
import { Divider, Paper, Typography } from "@material-ui/core";
|
|
||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
EditIcon,
|
|
||||||
IconButton,
|
|
||||||
List,
|
|
||||||
ListHeader,
|
|
||||||
ListItem,
|
|
||||||
ListItemCell,
|
|
||||||
makeStyles,
|
|
||||||
SwitchSelector,
|
|
||||||
SwitchSelectorButton,
|
|
||||||
} from "@saleor/macaw-ui";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { mjmlUrls } from "../../urls";
|
|
||||||
import { messageEventTypesLabels } from "../../../event-handlers/message-event-types";
|
|
||||||
import { MjmlConfiguration } from "../mjml-config";
|
|
||||||
import { trpcClient } from "../../../trpc/trpc-client";
|
|
||||||
import { useDashboardNotification } from "@saleor/apps-shared";
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => {
|
|
||||||
return {
|
|
||||||
spaceBetween: {
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
rowActions: {
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "flex-end",
|
|
||||||
gap: theme.spacing(1),
|
|
||||||
},
|
|
||||||
tableRow: {
|
|
||||||
minHeight: "48px",
|
|
||||||
"&::after": {
|
|
||||||
display: "none",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
interface MjmlTemplatesCardProps {
|
|
||||||
configurationId: string;
|
|
||||||
configuration: MjmlConfiguration;
|
|
||||||
onEventChanged: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MjmlTemplatesCard = ({
|
|
||||||
configurationId,
|
|
||||||
configuration,
|
|
||||||
onEventChanged,
|
|
||||||
}: MjmlTemplatesCardProps) => {
|
|
||||||
const classes = useStyles();
|
|
||||||
const router = useRouter();
|
|
||||||
const { notifySuccess } = useDashboardNotification();
|
|
||||||
|
|
||||||
const { mutate: updateEventConfiguration } =
|
|
||||||
trpcClient.mjmlConfiguration.updateEventConfiguration.useMutation({
|
|
||||||
onSuccess(data, variables) {
|
|
||||||
onEventChanged();
|
|
||||||
|
|
||||||
notifySuccess(variables.active ? "Event enabled" : "Event disabled");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper elevation={0}>
|
|
||||||
<ListHeader>
|
|
||||||
<ListItem className={classes.tableRow}>
|
|
||||||
<ListItemCell>Supported events and templates</ListItemCell>
|
|
||||||
</ListItem>
|
|
||||||
</ListHeader>
|
|
||||||
<List gridTemplate={["1fr"]}>
|
|
||||||
<Divider />
|
|
||||||
{configuration.events.map((eventConfiguration) => (
|
|
||||||
<React.Fragment key={eventConfiguration.eventType}>
|
|
||||||
<ListItem className={classes.tableRow}>
|
|
||||||
<ListItemCell>
|
|
||||||
<div className={classes.spaceBetween}>
|
|
||||||
<Typography>{messageEventTypesLabels[eventConfiguration.eventType]}</Typography>
|
|
||||||
<div className={classes.rowActions}>
|
|
||||||
{/* TODO: fix types in the MacawUI */}
|
|
||||||
{/* @ts-ignore: MacawUI use wrong type for */}
|
|
||||||
<SwitchSelector key={eventConfiguration.eventType}>
|
|
||||||
{[
|
|
||||||
{ label: "Active", value: true },
|
|
||||||
{ label: "Disabled", value: false },
|
|
||||||
].map((button) => (
|
|
||||||
// @ts-ignore: MacawUI use wrong type for SwitchSelectorButton
|
|
||||||
<SwitchSelectorButton
|
|
||||||
value={button.value.toString()}
|
|
||||||
onClick={() => {
|
|
||||||
updateEventConfiguration({
|
|
||||||
configurationId,
|
|
||||||
...eventConfiguration,
|
|
||||||
active: button.value,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
activeTab={eventConfiguration.active.toString()}
|
|
||||||
key={button.label}
|
|
||||||
>
|
|
||||||
{button.label}
|
|
||||||
</SwitchSelectorButton>
|
|
||||||
))}
|
|
||||||
</SwitchSelector>
|
|
||||||
<IconButton
|
|
||||||
variant="secondary"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
router.push(
|
|
||||||
mjmlUrls.eventConfiguration(configurationId, eventConfiguration.eventType)
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<EditIcon />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ListItemCell>
|
|
||||||
</ListItem>
|
|
||||||
<Divider />
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { SendgridConfigurator, PrivateMetadataSendgridConfigurator } from "./sendgrid-configurator";
|
import { SendgridConfigurator, PrivateMetadataSendgridConfigurator } from "./sendgrid-configurator";
|
||||||
import { Client } from "urql";
|
import { Client } from "urql";
|
||||||
import { logger as pinoLogger } from "../../../lib/logger";
|
import { logger as pinoLogger } from "../../../lib/logger";
|
||||||
import { SendgridConfig, SendgridConfiguration } from "./sendgrid-config";
|
import { SendgridConfig, SendgridConfiguration } from "./sendgrid-config-schema";
|
||||||
import { FilterConfigurationsArgs, SendgridConfigContainer } from "./sendgrid-config-container";
|
import { FilterConfigurationsArgs, SendgridConfigContainer } from "./sendgrid-config-container";
|
||||||
import { createSettingsManager } from "../../../lib/metadata-manager";
|
import { createSettingsManager } from "../../../lib/metadata-manager";
|
||||||
|
|
||||||
|
|
|
@ -1,28 +1,27 @@
|
||||||
import { generateRandomId } from "../../../lib/generate-random-id";
|
import { generateRandomId } from "../../../lib/generate-random-id";
|
||||||
|
import { isAvailableInChannel } from "../../../lib/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,28 @@ 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!,
|
||||||
|
restrictedToChannels: c.channels.restrictedTo,
|
||||||
|
excludedChannels: c.channels.excludedFrom,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return filtered;
|
return filtered;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -77,6 +91,7 @@ const createConfiguration =
|
||||||
id: generateRandomId(),
|
id: generateRandomId(),
|
||||||
events: getDefaultEventsConfiguration(),
|
events: getDefaultEventsConfiguration(),
|
||||||
};
|
};
|
||||||
|
|
||||||
sendgridConfigNormalized.configurations.push(newConfiguration);
|
sendgridConfigNormalized.configurations.push(newConfiguration);
|
||||||
return sendgridConfigNormalized;
|
return sendgridConfigNormalized;
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,37 +1,26 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { messageEventTypes } from "../../event-handlers/message-event-types";
|
import { messageEventTypes } from "../../event-handlers/message-event-types";
|
||||||
|
import {
|
||||||
|
sendgridConfigurationChannelsSchema,
|
||||||
|
sendgridConfigurationEventSchema,
|
||||||
|
sendgridConfigurationSchema,
|
||||||
|
} from "./sendgrid-config-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 = sendgridConfigurationChannelsSchema.merge(
|
||||||
|
sendgridConfigurationSchema.pick({
|
||||||
|
id: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export type SendgridUpdateChannels = z.infer<typeof sendgridUpdateChannelsSchema>;
|
||||||
|
|
||||||
|
export const sendgridUpdateEventSchema = sendgridConfigurationEventSchema.merge(
|
||||||
|
sendgridConfigurationSchema.pick({
|
||||||
|
id: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export type SendgridUpdateEvent = z.infer<typeof sendgridUpdateEventSchema>;
|
||||||
|
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
import { messageEventTypes } from "../../event-handlers/message-event-types";
|
||||||
|
|
||||||
|
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 sendgridConfigurationChannelsSchema = z.object({
|
||||||
|
override: z.boolean().default(false),
|
||||||
|
channels: z.array(z.string()).default([]),
|
||||||
|
mode: z.enum(["exclude", "restrict"]).default("restrict"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SendgridConfigurationChannels = z.infer<typeof sendgridConfigurationChannelsSchema>;
|
||||||
|
|
||||||
|
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(),
|
||||||
|
channels: sendgridConfigurationChannelsSchema,
|
||||||
|
events: z.array(sendgridConfigurationEventSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SendgridConfiguration = z.infer<typeof sendgridConfigurationSchema>;
|
||||||
|
|
||||||
|
export const sendgridConfigSchema = z.object({
|
||||||
|
configurations: z.array(sendgridConfigurationSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SendgridConfig = z.infer<typeof sendgridConfigSchema>;
|
|
@ -1,22 +0,0 @@
|
||||||
import { MessageEventTypes } from "../../event-handlers/message-event-types";
|
|
||||||
|
|
||||||
export interface SendgridEventConfiguration {
|
|
||||||
active: boolean;
|
|
||||||
eventType: MessageEventTypes;
|
|
||||||
template: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SendgridConfiguration {
|
|
||||||
id: string;
|
|
||||||
active: boolean;
|
|
||||||
configurationName: string;
|
|
||||||
sandboxMode: boolean;
|
|
||||||
senderName?: string;
|
|
||||||
senderEmail?: string;
|
|
||||||
apiKey: string;
|
|
||||||
events: SendgridEventConfiguration[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SendgridConfig = {
|
|
||||||
configurations: SendgridConfiguration[];
|
|
||||||
};
|
|
|
@ -1,17 +1,21 @@
|
||||||
import { logger as pinoLogger } from "../../../lib/logger";
|
import { logger as pinoLogger } from "../../../lib/logger";
|
||||||
import {
|
import {
|
||||||
sendgridCreateConfigurationSchema,
|
sendgridConfigurationIdInputSchema,
|
||||||
sendgridDeleteConfigurationInputSchema,
|
sendgridCreateConfigurationInputSchema,
|
||||||
sendgridGetConfigurationInputSchema,
|
|
||||||
sendgridGetConfigurationsInputSchema,
|
sendgridGetConfigurationsInputSchema,
|
||||||
sendgridGetEventConfigurationInputSchema,
|
sendgridGetEventConfigurationInputSchema,
|
||||||
|
sendgridUpdateApiConnectionSchema,
|
||||||
|
sendgridUpdateBasicInformationSchema,
|
||||||
|
sendgridUpdateChannelsSchema,
|
||||||
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";
|
||||||
|
|
||||||
// Allow access only for the dashboard users and attaches the
|
// Allow access only for the dashboard users and attaches the
|
||||||
// configuration service to the context
|
// configuration service to the context
|
||||||
|
@ -35,7 +39,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 = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
|
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
|
||||||
logger.debug(input, "sendgridConfigurationRouter.get called");
|
logger.debug(input, "sendgridConfigurationRouter.get called");
|
||||||
|
@ -51,15 +55,19 @@ 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 = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
|
const logger = pinoLogger.child({ 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 = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
|
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
|
||||||
logger.debug(input, "sendgridConfigurationRouter.delete called");
|
logger.debug(input, "sendgridConfigurationRouter.delete called");
|
||||||
|
@ -73,33 +81,33 @@ export const sendgridConfigurationRouter = router({
|
||||||
await ctx.configurationService.deleteConfiguration(input);
|
await ctx.configurationService.deleteConfiguration(input);
|
||||||
return null;
|
return null;
|
||||||
}),
|
}),
|
||||||
updateOrCreateConfiguration: protectedWithConfigurationService
|
// updateOrCreateConfiguration: protectedWithConfigurationService
|
||||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
// .meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||||
.input(sendgridUpdateOrCreateConfigurationSchema)
|
// .input(sendgridUpdateOrCreateConfigurationSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
// .mutation(async ({ ctx, input }) => {
|
||||||
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
|
// const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
|
||||||
logger.debug(input, "sendgridConfigurationRouter.update or create called");
|
// logger.debug(input, "sendgridConfigurationRouter.update or create called");
|
||||||
|
|
||||||
const { id } = input;
|
// const { id } = input;
|
||||||
if (!id) {
|
// if (!id) {
|
||||||
return await ctx.configurationService.createConfiguration(input);
|
// return await ctx.configurationService.createConfiguration(input);
|
||||||
} else {
|
// } else {
|
||||||
const existingConfiguration = await ctx.configurationService.getConfiguration({ id });
|
// const existingConfiguration = await ctx.configurationService.getConfiguration({ id });
|
||||||
if (!existingConfiguration) {
|
// if (!existingConfiguration) {
|
||||||
throw new TRPCError({
|
// throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
// code: "BAD_REQUEST",
|
||||||
message: "Configuration not found",
|
// message: "Configuration not found",
|
||||||
});
|
// });
|
||||||
}
|
// }
|
||||||
const configuration = {
|
// const configuration = {
|
||||||
id,
|
// id,
|
||||||
...input,
|
// ...input,
|
||||||
events: existingConfiguration.events,
|
// events: existingConfiguration.events,
|
||||||
};
|
// };
|
||||||
await ctx.configurationService.updateConfiguration(configuration);
|
// await ctx.configurationService.updateConfiguration(configuration);
|
||||||
return configuration;
|
// return configuration;
|
||||||
}
|
// }
|
||||||
}),
|
// }),
|
||||||
getEventConfiguration: protectedWithConfigurationService
|
getEventConfiguration: protectedWithConfigurationService
|
||||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||||
.input(sendgridGetEventConfigurationInputSchema)
|
.input(sendgridGetEventConfigurationInputSchema)
|
||||||
|
@ -153,6 +161,112 @@ 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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await ctx.configurationService.updateConfiguration({ ...configuration, ...input });
|
||||||
|
return configuration;
|
||||||
|
}),
|
||||||
|
updateChannels: protectedWithConfigurationService
|
||||||
|
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||||
|
.input(sendgridUpdateChannelsSchema)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const configuration = await ctx.configurationService.getConfiguration({
|
||||||
|
id: input.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!configuration) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Configuration not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
configuration.channels = {
|
||||||
|
override: input.override,
|
||||||
|
channels: input.channels,
|
||||||
|
mode: input.mode,
|
||||||
|
};
|
||||||
|
await ctx.configurationService.updateConfiguration(configuration);
|
||||||
|
return configuration;
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateEvent: protectedWithConfigurationService
|
||||||
|
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||||
|
.input(sendgridUpdateEventSchema)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const configuration = await ctx.configurationService.getConfiguration({
|
||||||
|
id: input.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!configuration) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Configuration not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const event = configuration.events.find((e) => e.eventType === input.eventType);
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Configuration event not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
event.template = input.template;
|
||||||
|
event.active = input.active;
|
||||||
|
|
||||||
await ctx.configurationService.updateConfiguration(configuration);
|
await ctx.configurationService.updateConfiguration(configuration);
|
||||||
return configuration;
|
return configuration;
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { SendgridConfig } from "./sendgrid-config";
|
import { SendgridConfig } from "./sendgrid-config-schema";
|
||||||
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
|
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
|
||||||
|
|
||||||
export interface SendgridConfigurator {
|
export interface SendgridConfigurator {
|
||||||
|
|
|
@ -1,342 +0,0 @@
|
||||||
import { Controller, useForm } from "react-hook-form";
|
|
||||||
import {
|
|
||||||
Divider,
|
|
||||||
FormControl,
|
|
||||||
InputLabel,
|
|
||||||
MenuItem,
|
|
||||||
Select,
|
|
||||||
TextField,
|
|
||||||
TextFieldProps,
|
|
||||||
Typography,
|
|
||||||
} from "@material-ui/core";
|
|
||||||
import { Button, makeStyles, SwitchSelector, SwitchSelectorButton } from "@saleor/macaw-ui";
|
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import { SendgridConfiguration } from "../sendgrid-config";
|
|
||||||
import { trpcClient } from "../../../trpc/trpc-client";
|
|
||||||
import { useAppBridge, actions } from "@saleor/app-sdk/app-bridge";
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { fetchSenders } from "../../sendgrid-api";
|
|
||||||
import { useDashboardNotification } from "@saleor/apps-shared";
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => ({
|
|
||||||
field: {
|
|
||||||
marginBottom: 20,
|
|
||||||
},
|
|
||||||
editor: {
|
|
||||||
marginBottom: 20,
|
|
||||||
},
|
|
||||||
preview: {
|
|
||||||
marginBottom: 20,
|
|
||||||
},
|
|
||||||
sectionHeader: {
|
|
||||||
marginTop: 20,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
onConfigurationSaved: () => void;
|
|
||||||
initialData: SendgridConfiguration;
|
|
||||||
configurationId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SendgridConfigurationForm = (props: Props) => {
|
|
||||||
const styles = useStyles();
|
|
||||||
const { appBridge } = useAppBridge();
|
|
||||||
const { notifySuccess, notifyError } = useDashboardNotification();
|
|
||||||
const [senderId, setSenderId] = useState<string | undefined>(undefined);
|
|
||||||
|
|
||||||
const { handleSubmit, control, reset, setError, setValue } = useForm<SendgridConfiguration>({
|
|
||||||
defaultValues: props.initialData,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: sendersChoices, isLoading: isSendersChoicesLoading } = useQuery({
|
|
||||||
queryKey: ["sendgridSenders"],
|
|
||||||
queryFn: fetchSenders({ apiKey: props.initialData.apiKey }),
|
|
||||||
enabled: !!props.initialData.apiKey?.length,
|
|
||||||
onSuccess(data) {
|
|
||||||
// we are not keeping senders ID in the database, so we need to find the ID of the sender
|
|
||||||
// configuration contains nickname and email set up in the Sendgrid account
|
|
||||||
if (data.length) {
|
|
||||||
const sender = data?.find((sender) => sender.from_email === props.initialData.senderEmail);
|
|
||||||
if (sender?.value) {
|
|
||||||
setSenderId(sender?.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const { mutate: createOrUpdateConfiguration } =
|
|
||||||
trpcClient.sendgridConfiguration.updateOrCreateConfiguration.useMutation({
|
|
||||||
onSuccess: async (data, variables) => {
|
|
||||||
await queryClient.cancelQueries({
|
|
||||||
queryKey: ["sendgridConfiguration", "getConfigurations"],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Optimistically update to the new value
|
|
||||||
queryClient.setQueryData<Array<SendgridConfiguration>>(
|
|
||||||
["sendgridConfiguration", "getConfigurations", undefined],
|
|
||||||
(old) => {
|
|
||||||
if (old) {
|
|
||||||
const index = old.findIndex((c) => c.id === data.id);
|
|
||||||
// If thats an update, replace the old one
|
|
||||||
if (index !== -1) {
|
|
||||||
old[index] = data;
|
|
||||||
return [...old];
|
|
||||||
} else {
|
|
||||||
return [...old, data];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return [data];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Trigger refetch to make sure we have a fresh data
|
|
||||||
props.onConfigurationSaved();
|
|
||||||
notifySuccess("Configuration saved");
|
|
||||||
},
|
|
||||||
onError(error) {
|
|
||||||
let isFieldErrorSet = false;
|
|
||||||
const fieldErrors = error.data?.zodError?.fieldErrors || {};
|
|
||||||
for (const fieldName in fieldErrors) {
|
|
||||||
for (const message of fieldErrors[fieldName] || []) {
|
|
||||||
isFieldErrorSet = true;
|
|
||||||
setError(fieldName as keyof SendgridConfiguration, {
|
|
||||||
type: "manual",
|
|
||||||
message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const formErrors = error.data?.zodError?.formErrors || [];
|
|
||||||
const formErrorMessage = formErrors.length ? formErrors.join("\n") : undefined;
|
|
||||||
|
|
||||||
notifyError(
|
|
||||||
"Could not save the configuration",
|
|
||||||
isFieldErrorSet ? "Submitted form contain errors" : "Error saving configuration",
|
|
||||||
formErrorMessage
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// when the configuration tab is changed, initialData change and form has to be updated
|
|
||||||
useEffect(() => {
|
|
||||||
reset(props.initialData);
|
|
||||||
}, [props.initialData, props.configurationId, reset]);
|
|
||||||
|
|
||||||
// fill sender email and name when sender is changed
|
|
||||||
useEffect(() => {
|
|
||||||
const sender = sendersChoices?.find((choice) => choice.value === senderId);
|
|
||||||
if (sender) {
|
|
||||||
setValue("senderName", sender.nickname);
|
|
||||||
setValue("senderEmail", sender.from_email);
|
|
||||||
} else {
|
|
||||||
setValue("senderName", undefined);
|
|
||||||
setValue("senderEmail", undefined);
|
|
||||||
}
|
|
||||||
}, [senderId, sendersChoices]);
|
|
||||||
|
|
||||||
const CommonFieldProps: TextFieldProps = {
|
|
||||||
className: styles.field,
|
|
||||||
fullWidth: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const isNewConfiguration = !props.configurationId;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit((data, event) => {
|
|
||||||
createOrUpdateConfiguration({
|
|
||||||
...data,
|
|
||||||
});
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{isNewConfiguration ? (
|
|
||||||
<Typography variant="h2" paragraph>
|
|
||||||
Create a new configuration
|
|
||||||
</Typography>
|
|
||||||
) : (
|
|
||||||
<Typography variant="h2" paragraph>
|
|
||||||
Configuration
|
|
||||||
<strong>{` ${props.initialData.configurationName} `}</strong>
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
name="configurationName"
|
|
||||||
control={control}
|
|
||||||
render={({ field: { onChange, value }, fieldState: { error }, formState: { errors } }) => (
|
|
||||||
<TextField
|
|
||||||
label="Configuration name"
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
error={!!error}
|
|
||||||
helperText={
|
|
||||||
error?.message || "Name of the configuration, for example 'Production' or 'Test'"
|
|
||||||
}
|
|
||||||
{...CommonFieldProps}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="active"
|
|
||||||
render={({ field: { value, name, onChange } }) => (
|
|
||||||
<div className={styles.field}>
|
|
||||||
{/* TODO: fix types in the MacawUI */}
|
|
||||||
{/* @ts-ignore: MacawUI use wrong type for */}
|
|
||||||
<SwitchSelector key={name} className={styles.field}>
|
|
||||||
{[
|
|
||||||
{ label: "Active", value: true },
|
|
||||||
{ label: "Disabled", value: false },
|
|
||||||
].map((button) => (
|
|
||||||
// @ts-ignore: MacawUI use wrong type for SwitchSelectorButton
|
|
||||||
<SwitchSelectorButton
|
|
||||||
value={button.value.toString()}
|
|
||||||
onClick={() => onChange(button.value)}
|
|
||||||
activeTab={value.toString()}
|
|
||||||
key={button.label}
|
|
||||||
>
|
|
||||||
{button.label}
|
|
||||||
</SwitchSelectorButton>
|
|
||||||
))}
|
|
||||||
</SwitchSelector>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<Typography variant="h3" paragraph className={styles.sectionHeader}>
|
|
||||||
API configuration
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
name="apiKey"
|
|
||||||
control={control}
|
|
||||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
|
||||||
<TextField
|
|
||||||
label="Sendgrid API key"
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
helperText={
|
|
||||||
error?.message ||
|
|
||||||
"The API key can be generated at Sendgrid dashboard, in the Settings / API Keys section"
|
|
||||||
}
|
|
||||||
error={!!error}
|
|
||||||
{...CommonFieldProps}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="sandboxMode"
|
|
||||||
render={({ field: { value, name, onChange } }) => (
|
|
||||||
<div className={styles.field}>
|
|
||||||
{/* TODO: fix types in the MacawUI */}
|
|
||||||
{/* @ts-ignore: MacawUI use wrong type for */}
|
|
||||||
<SwitchSelector key={name} className={styles.field}>
|
|
||||||
{[
|
|
||||||
{ label: "Live", value: false },
|
|
||||||
{ label: "Sandbox", value: true },
|
|
||||||
].map((button) => (
|
|
||||||
// @ts-ignore: MacawUI use wrong type for SwitchSelectorButton
|
|
||||||
<SwitchSelectorButton
|
|
||||||
value={button.value.toString()}
|
|
||||||
onClick={() => onChange(button.value)}
|
|
||||||
activeTab={value.toString()}
|
|
||||||
key={button.label}
|
|
||||||
>
|
|
||||||
{button.label}
|
|
||||||
</SwitchSelectorButton>
|
|
||||||
))}
|
|
||||||
</SwitchSelector>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{/* Sender can be chosen after the API key is saved in the configuration */}
|
|
||||||
{!isNewConfiguration && (
|
|
||||||
<>
|
|
||||||
<Typography variant="h3" paragraph className={styles.sectionHeader}>
|
|
||||||
Sender details
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<FormControl className={styles.field} fullWidth>
|
|
||||||
<InputLabel>Sender</InputLabel>
|
|
||||||
<Select
|
|
||||||
variant="outlined"
|
|
||||||
value={senderId}
|
|
||||||
disabled={isSendersChoicesLoading}
|
|
||||||
onChange={(event, val) => {
|
|
||||||
if (val) {
|
|
||||||
const node = val as React.ReactElement;
|
|
||||||
setSenderId(node.props.value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSenderId(undefined);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MenuItem key="none" value={undefined}>
|
|
||||||
No sender
|
|
||||||
</MenuItem>
|
|
||||||
{!!sendersChoices &&
|
|
||||||
sendersChoices.map((choice) => (
|
|
||||||
<MenuItem key={choice.value} value={choice.value}>
|
|
||||||
{choice.label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
{!sendersChoices?.length && (
|
|
||||||
<Typography variant="caption" color="textSecondary">
|
|
||||||
Please set up and verify senders in your Sendgrid dashboard.
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
name="senderName"
|
|
||||||
control={control}
|
|
||||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
|
||||||
<TextField
|
|
||||||
label="Sender name"
|
|
||||||
disabled={true}
|
|
||||||
error={!!error}
|
|
||||||
helperText={error?.message}
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
{...CommonFieldProps}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
name="senderEmail"
|
|
||||||
control={control}
|
|
||||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
|
||||||
<>
|
|
||||||
<TextField
|
|
||||||
label="Sender email"
|
|
||||||
value={value}
|
|
||||||
disabled={true}
|
|
||||||
helperText={error?.message}
|
|
||||||
error={!!error}
|
|
||||||
onChange={onChange}
|
|
||||||
{...CommonFieldProps}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Button type="submit" fullWidth variant="primary">
|
|
||||||
Save configuration
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,166 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
import { IconButton, makeStyles } from "@saleor/macaw-ui";
|
|
||||||
import { AppColumnsLayout } from "../../../ui/app-columns-layout";
|
|
||||||
import { trpcClient } from "../../../trpc/trpc-client";
|
|
||||||
import { SendgridConfigurationForm } from "./sendgrid-configuration-form";
|
|
||||||
import { getDefaultEmptyConfiguration } from "../sendgrid-config-container";
|
|
||||||
import { NextRouter, useRouter } from "next/router";
|
|
||||||
import { SideMenu } from "../../../app-configuration/ui/side-menu";
|
|
||||||
import { SendgridConfiguration } from "../sendgrid-config";
|
|
||||||
import { LoadingIndicator } from "../../../ui/loading-indicator";
|
|
||||||
import { Add } from "@material-ui/icons";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { sendgridUrls } from "../../urls";
|
|
||||||
import { SendgridTemplatesCard } from "./sendgrid-templates-card";
|
|
||||||
import { SendgridInstructions } from "./sendgrid-instructions";
|
|
||||||
import { useDashboardNotification } from "@saleor/apps-shared";
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => {
|
|
||||||
return {
|
|
||||||
formContainer: {
|
|
||||||
top: 0,
|
|
||||||
},
|
|
||||||
instructionsContainer: {
|
|
||||||
padding: 15,
|
|
||||||
},
|
|
||||||
configurationColumn: {
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 20,
|
|
||||||
maxWidth: 700,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
interface SendgridConfigurationTabProps {
|
|
||||||
configurationId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const navigateToFirstConfiguration = (
|
|
||||||
router: NextRouter,
|
|
||||||
configurations?: SendgridConfiguration[]
|
|
||||||
) => {
|
|
||||||
if (!configurations || !configurations?.length) {
|
|
||||||
router.replace(sendgridUrls.configuration());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const firstConfigurationId = configurations[0]?.id;
|
|
||||||
if (firstConfigurationId) {
|
|
||||||
router.replace(sendgridUrls.configuration(firstConfigurationId));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SendgridConfigurationTab = ({ configurationId }: SendgridConfigurationTabProps) => {
|
|
||||||
const styles = useStyles();
|
|
||||||
const router = useRouter();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { notifySuccess, notifyError } = useDashboardNotification();
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: configurations,
|
|
||||||
refetch: refetchConfigurations,
|
|
||||||
isLoading: configurationsIsLoading,
|
|
||||||
isFetching: configurationsIsFetching,
|
|
||||||
isRefetching: configurationsIsRefetching,
|
|
||||||
} = trpcClient.sendgridConfiguration.getConfigurations.useQuery(undefined, {
|
|
||||||
onSuccess(data) {
|
|
||||||
if (!configurationId) {
|
|
||||||
console.log("no conf id! navigate to first");
|
|
||||||
navigateToFirstConfiguration(router, data);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutate: deleteConfiguration } =
|
|
||||||
trpcClient.sendgridConfiguration.deleteConfiguration.useMutation({
|
|
||||||
onError: (error) => {
|
|
||||||
notifyError("Could not remove the configuration", error.message);
|
|
||||||
},
|
|
||||||
onSuccess: async (_data, variables) => {
|
|
||||||
await queryClient.cancelQueries({
|
|
||||||
queryKey: ["sendgridConfiguration", "getConfigurations"],
|
|
||||||
});
|
|
||||||
// remove value from the cache after the success
|
|
||||||
queryClient.setQueryData<Array<SendgridConfiguration>>(
|
|
||||||
["sendgridConfiguration", "getConfigurations"],
|
|
||||||
(old) => {
|
|
||||||
if (old) {
|
|
||||||
const index = old.findIndex((c) => c.id === variables.id);
|
|
||||||
if (index !== -1) {
|
|
||||||
delete old[index];
|
|
||||||
return [...old];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// if we just deleted the configuration that was selected
|
|
||||||
// we have to update the URL
|
|
||||||
if (variables.id === configurationId) {
|
|
||||||
router.replace(sendgridUrls.configuration());
|
|
||||||
}
|
|
||||||
|
|
||||||
refetchConfigurations();
|
|
||||||
|
|
||||||
notifySuccess("Success", "Removed successfully");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (configurationsIsLoading || configurationsIsFetching) {
|
|
||||||
return <LoadingIndicator />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const configuration = configurations?.find((c) => c.id === configurationId?.toString());
|
|
||||||
|
|
||||||
if (configurationId && !configuration) {
|
|
||||||
return <div>Configuration not found</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppColumnsLayout>
|
|
||||||
<SideMenu
|
|
||||||
title="Configurations"
|
|
||||||
selectedItemId={configurationId}
|
|
||||||
headerToolbar={
|
|
||||||
<IconButton
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => {
|
|
||||||
router.replace(sendgridUrls.configuration());
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Add />
|
|
||||||
</IconButton>
|
|
||||||
}
|
|
||||||
onClick={(id) => router.replace(sendgridUrls.configuration(id))}
|
|
||||||
onDelete={(id) => {
|
|
||||||
deleteConfiguration({ id });
|
|
||||||
}}
|
|
||||||
items={configurations?.map((c) => ({ label: c.configurationName, id: c.id })) || []}
|
|
||||||
/>
|
|
||||||
<div className={styles.configurationColumn}>
|
|
||||||
{configurationsIsLoading || configurationsIsFetching ? (
|
|
||||||
<LoadingIndicator />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<SendgridConfigurationForm
|
|
||||||
onConfigurationSaved={() => refetchConfigurations()}
|
|
||||||
initialData={configuration || getDefaultEmptyConfiguration()}
|
|
||||||
configurationId={configurationId}
|
|
||||||
/>
|
|
||||||
{!!configurationId && !!configuration && (
|
|
||||||
<SendgridTemplatesCard
|
|
||||||
configurationId={configurationId}
|
|
||||||
configuration={configuration}
|
|
||||||
onEventChanged={() => {
|
|
||||||
refetchConfigurations();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<SendgridInstructions />
|
|
||||||
</AppColumnsLayout>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,219 +0,0 @@
|
||||||
import { Controller, useForm } from "react-hook-form";
|
|
||||||
import {
|
|
||||||
FormControl,
|
|
||||||
Grid,
|
|
||||||
InputLabel,
|
|
||||||
MenuItem,
|
|
||||||
Select,
|
|
||||||
TextFieldProps,
|
|
||||||
Typography,
|
|
||||||
} from "@material-ui/core";
|
|
||||||
import {
|
|
||||||
BackSmallIcon,
|
|
||||||
Button,
|
|
||||||
IconButton,
|
|
||||||
makeStyles,
|
|
||||||
SwitchSelector,
|
|
||||||
SwitchSelectorButton,
|
|
||||||
} from "@saleor/macaw-ui";
|
|
||||||
import React from "react";
|
|
||||||
import { SendgridConfiguration, SendgridEventConfiguration } from "../sendgrid-config";
|
|
||||||
import {
|
|
||||||
MessageEventTypes,
|
|
||||||
messageEventTypesLabels,
|
|
||||||
} from "../../../event-handlers/message-event-types";
|
|
||||||
import { trpcClient } from "../../../trpc/trpc-client";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { sendgridUrls } from "../../urls";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { fetchTemplates } from "../../sendgrid-api";
|
|
||||||
import { useDashboardNotification } from "@saleor/apps-shared";
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => ({
|
|
||||||
viewContainer: {
|
|
||||||
padding: theme.spacing(2),
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "flex-start",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing(2),
|
|
||||||
marginBottom: theme.spacing(2),
|
|
||||||
margin: "0 auto",
|
|
||||||
},
|
|
||||||
previewHeader: {
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing(1),
|
|
||||||
marginTop: theme.spacing(2),
|
|
||||||
marginBottom: theme.spacing(2),
|
|
||||||
},
|
|
||||||
|
|
||||||
field: {
|
|
||||||
marginBottom: theme.spacing(3),
|
|
||||||
},
|
|
||||||
editor: {
|
|
||||||
marginBottom: theme.spacing(3),
|
|
||||||
},
|
|
||||||
preview: {
|
|
||||||
marginBottom: theme.spacing(3),
|
|
||||||
},
|
|
||||||
form: {
|
|
||||||
maxWidth: 800,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
type EventConfigurationFormProps = {
|
|
||||||
initialData: SendgridEventConfiguration;
|
|
||||||
configurationId: string;
|
|
||||||
eventType: MessageEventTypes;
|
|
||||||
configuration: SendgridConfiguration;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const EventConfigurationForm = ({
|
|
||||||
initialData,
|
|
||||||
configurationId,
|
|
||||||
eventType,
|
|
||||||
configuration,
|
|
||||||
}: EventConfigurationFormProps) => {
|
|
||||||
const router = useRouter();
|
|
||||||
const { notifySuccess, notifyError } = useDashboardNotification();
|
|
||||||
|
|
||||||
const { handleSubmit, control, getValues, setError } = useForm<SendgridEventConfiguration>({
|
|
||||||
defaultValues: initialData,
|
|
||||||
});
|
|
||||||
|
|
||||||
const styles = useStyles();
|
|
||||||
|
|
||||||
const { data: templateChoices, isLoading: isTemplateChoicesLoading } = useQuery({
|
|
||||||
queryKey: ["sendgridTemplates"],
|
|
||||||
queryFn: fetchTemplates({ apiKey: configuration.apiKey }),
|
|
||||||
enabled: !!configuration.apiKey?.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
const CommonFieldProps: TextFieldProps = {
|
|
||||||
className: styles.field,
|
|
||||||
fullWidth: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { mutate: updateEventConfiguration } =
|
|
||||||
trpcClient.sendgridConfiguration.updateEventConfiguration.useMutation({
|
|
||||||
onSuccess: (data) => {
|
|
||||||
notifySuccess("Configuration saved");
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
let isFieldErrorSet = false;
|
|
||||||
const fieldErrors = error.data?.zodError?.fieldErrors || {};
|
|
||||||
for (const fieldName in fieldErrors) {
|
|
||||||
for (const message of fieldErrors[fieldName] || []) {
|
|
||||||
isFieldErrorSet = true;
|
|
||||||
setError(fieldName as keyof SendgridEventConfiguration, {
|
|
||||||
type: "manual",
|
|
||||||
message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const formErrors = error.data?.zodError?.formErrors || [];
|
|
||||||
const formErrorMessage = formErrors.length ? formErrors.join("\n") : undefined;
|
|
||||||
notifyError(
|
|
||||||
"Could not save the configuration",
|
|
||||||
isFieldErrorSet ? "Submitted form contain errors" : "Error saving configuration",
|
|
||||||
formErrorMessage
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.viewContainer}>
|
|
||||||
<div className={styles.header}>
|
|
||||||
<IconButton
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => {
|
|
||||||
router.push(sendgridUrls.configuration(configurationId));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<BackSmallIcon />
|
|
||||||
</IconButton>
|
|
||||||
<Typography variant="h2">
|
|
||||||
{messageEventTypesLabels[eventType]} event configuration
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
<Grid container spacing={2}>
|
|
||||||
<Grid item xs={12} lg={7}>
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit((data, event) => {
|
|
||||||
updateEventConfiguration({ ...data, configurationId });
|
|
||||||
})}
|
|
||||||
className={styles.form}
|
|
||||||
>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="active"
|
|
||||||
render={({ field: { value, name, onChange } }) => (
|
|
||||||
<div className={styles.field}>
|
|
||||||
{/* TODO: fix types in the MacawUI */}
|
|
||||||
{/* @ts-ignore: MacawUI use wrong type for */}
|
|
||||||
<SwitchSelector key={name} className={styles.field}>
|
|
||||||
{[
|
|
||||||
{ label: "Active", value: true },
|
|
||||||
{ label: "Disabled", value: false },
|
|
||||||
].map((button) => (
|
|
||||||
// @ts-ignore: MacawUI use wrong type for SwitchSelectorButton
|
|
||||||
<SwitchSelectorButton
|
|
||||||
value={button.value.toString()}
|
|
||||||
onClick={() => onChange(button.value)}
|
|
||||||
activeTab={value.toString()}
|
|
||||||
key={button.label}
|
|
||||||
>
|
|
||||||
{button.label}
|
|
||||||
</SwitchSelectorButton>
|
|
||||||
))}
|
|
||||||
</SwitchSelector>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="template"
|
|
||||||
render={({ field: { value, onChange } }) => {
|
|
||||||
return (
|
|
||||||
<FormControl className={styles.field} fullWidth>
|
|
||||||
<InputLabel>Template</InputLabel>
|
|
||||||
<Select
|
|
||||||
variant="outlined"
|
|
||||||
value={value}
|
|
||||||
onChange={(event, val) => {
|
|
||||||
onChange(event.target.value);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MenuItem key="none" value={undefined}>
|
|
||||||
No template
|
|
||||||
</MenuItem>
|
|
||||||
{!!templateChoices &&
|
|
||||||
templateChoices.map((choice) => (
|
|
||||||
<MenuItem key={choice.value} value={choice.value}>
|
|
||||||
{choice.label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
{!templateChoices?.length && (
|
|
||||||
<Typography variant="caption" color="textSecondary">
|
|
||||||
No templates found in your account. Visit Sendgrid dashboard and create one.
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</FormControl>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button type="submit" fullWidth variant="primary">
|
|
||||||
Save configuration
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,103 +0,0 @@
|
||||||
import { Link, Paper, Typography } from "@material-ui/core";
|
|
||||||
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
|
||||||
import { makeStyles } from "@saleor/macaw-ui";
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => {
|
|
||||||
return {
|
|
||||||
instructionsContainer: {
|
|
||||||
padding: 15,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export const SendgridInstructions = () => {
|
|
||||||
const styles = useStyles();
|
|
||||||
|
|
||||||
const { appBridge } = useAppBridge();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper elevation={0} className={styles.instructionsContainer}>
|
|
||||||
<Typography paragraph variant="h4">
|
|
||||||
Sendgrid Provider
|
|
||||||
</Typography>
|
|
||||||
<Typography paragraph>
|
|
||||||
The integration uses dynamic email templates to send the messages to your customers.
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Typography paragraph>
|
|
||||||
<Link
|
|
||||||
href="https://sendgrid.com/"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
appBridge?.dispatch(
|
|
||||||
actions.Redirect({
|
|
||||||
to: "https://sendgrid.com/",
|
|
||||||
newContext: true,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Visit the Sendgrid Homepage
|
|
||||||
</Link>
|
|
||||||
</Typography>
|
|
||||||
<Typography paragraph variant="h4">
|
|
||||||
How to configure
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Typography paragraph>
|
|
||||||
Before configuring the app, make sure you have a Sendgrid account set up. To proceed you
|
|
||||||
will need:
|
|
||||||
<br />
|
|
||||||
<Link
|
|
||||||
href="https://app.sendgrid.com/settings/api_keys"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
appBridge?.dispatch(
|
|
||||||
actions.Redirect({
|
|
||||||
to: "https://app.sendgrid.com/settings/api_keys",
|
|
||||||
newContext: true,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
API key which can be generated in the Sendgrid dashboard
|
|
||||||
</Link>
|
|
||||||
<br />
|
|
||||||
<Link
|
|
||||||
href="https://app.sendgrid.com/settings/sender_auth"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
appBridge?.dispatch(
|
|
||||||
actions.Redirect({
|
|
||||||
to: "https://app.sendgrid.com/settings/sender_auth",
|
|
||||||
newContext: true,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Verified sender account
|
|
||||||
</Link>
|
|
||||||
<br />
|
|
||||||
<Link
|
|
||||||
href="https://mc.sendgrid.com/dynamic-templates"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
appBridge?.dispatch(
|
|
||||||
actions.Redirect({
|
|
||||||
to: "https://mc.sendgrid.com/dynamic-templates",
|
|
||||||
newContext: true,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Created dynamic email templates
|
|
||||||
</Link>
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Typography paragraph>
|
|
||||||
Create a new configuration and fill in the required fields. After the configuration is
|
|
||||||
saved, you will be able to assign the email template to each of the events.
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,131 +0,0 @@
|
||||||
import { Divider, Paper, Typography } from "@material-ui/core";
|
|
||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
EditIcon,
|
|
||||||
IconButton,
|
|
||||||
List,
|
|
||||||
ListHeader,
|
|
||||||
ListItem,
|
|
||||||
ListItemCell,
|
|
||||||
makeStyles,
|
|
||||||
SwitchSelector,
|
|
||||||
SwitchSelectorButton,
|
|
||||||
} from "@saleor/macaw-ui";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { messageEventTypesLabels } from "../../../event-handlers/message-event-types";
|
|
||||||
import { trpcClient } from "../../../trpc/trpc-client";
|
|
||||||
import { useAppBridge, actions } from "@saleor/app-sdk/app-bridge";
|
|
||||||
import { SendgridConfiguration } from "../sendgrid-config";
|
|
||||||
import { sendgridUrls } from "../../urls";
|
|
||||||
import { useDashboardNotification } from "@saleor/apps-shared";
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => {
|
|
||||||
return {
|
|
||||||
spaceBetween: {
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
rowActions: {
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "flex-end",
|
|
||||||
gap: theme.spacing(1),
|
|
||||||
},
|
|
||||||
tableRow: {
|
|
||||||
minHeight: "48px",
|
|
||||||
"&::after": {
|
|
||||||
display: "none",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
interface SendgridTemplatesCardProps {
|
|
||||||
configurationId: string;
|
|
||||||
configuration: SendgridConfiguration;
|
|
||||||
onEventChanged: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SendgridTemplatesCard = ({
|
|
||||||
configurationId,
|
|
||||||
configuration,
|
|
||||||
onEventChanged,
|
|
||||||
}: SendgridTemplatesCardProps) => {
|
|
||||||
const classes = useStyles();
|
|
||||||
const router = useRouter();
|
|
||||||
const { notifySuccess } = useDashboardNotification();
|
|
||||||
|
|
||||||
const { mutate: updateEventConfiguration } =
|
|
||||||
trpcClient.sendgridConfiguration.updateEventConfiguration.useMutation({
|
|
||||||
onSuccess(_data, variables) {
|
|
||||||
onEventChanged();
|
|
||||||
notifySuccess(variables.active ? "Event enabled" : "Event disabled");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper elevation={0}>
|
|
||||||
<ListHeader>
|
|
||||||
<ListItem className={classes.tableRow}>
|
|
||||||
<ListItemCell>Supported events and templates</ListItemCell>
|
|
||||||
</ListItem>
|
|
||||||
</ListHeader>
|
|
||||||
<List gridTemplate={["1fr"]}>
|
|
||||||
<Divider />
|
|
||||||
{configuration.events.map((eventConfiguration) => (
|
|
||||||
<React.Fragment key={eventConfiguration.eventType}>
|
|
||||||
<ListItem className={classes.tableRow}>
|
|
||||||
<ListItemCell>
|
|
||||||
<div className={classes.spaceBetween}>
|
|
||||||
<Typography>{messageEventTypesLabels[eventConfiguration.eventType]}</Typography>
|
|
||||||
<div className={classes.rowActions}>
|
|
||||||
{/* TODO: fix types in the MacawUI */}
|
|
||||||
{/* @ts-ignore: MacawUI use wrong type for */}
|
|
||||||
<SwitchSelector key={eventConfiguration.eventType}>
|
|
||||||
{[
|
|
||||||
{ label: "Active", value: true },
|
|
||||||
{ label: "Disabled", value: false },
|
|
||||||
].map((button) => (
|
|
||||||
// @ts-ignore: MacawUI use wrong type for SwitchSelectorButton
|
|
||||||
<SwitchSelectorButton
|
|
||||||
value={button.value.toString()}
|
|
||||||
onClick={() => {
|
|
||||||
updateEventConfiguration({
|
|
||||||
configurationId,
|
|
||||||
...eventConfiguration,
|
|
||||||
active: button.value,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
activeTab={eventConfiguration.active.toString()}
|
|
||||||
key={button.label}
|
|
||||||
>
|
|
||||||
{button.label}
|
|
||||||
</SwitchSelectorButton>
|
|
||||||
))}
|
|
||||||
</SwitchSelector>
|
|
||||||
<IconButton
|
|
||||||
variant="secondary"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
router.push(
|
|
||||||
sendgridUrls.eventConfiguration(
|
|
||||||
configurationId,
|
|
||||||
eventConfiguration.eventType
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<EditIcon />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ListItemCell>
|
|
||||||
</ListItem>
|
|
||||||
<Divider />
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,31 +0,0 @@
|
||||||
import { MenuItem, Select } from "@material-ui/core";
|
|
||||||
|
|
||||||
interface TemplateSelectionFieldProps {
|
|
||||||
templateChoices?: { label: string; value: string }[];
|
|
||||||
value?: string;
|
|
||||||
onChange: (value: unknown) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TemplateSelectionField = ({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
templateChoices,
|
|
||||||
}: TemplateSelectionFieldProps) => {
|
|
||||||
return (
|
|
||||||
<Select
|
|
||||||
variant="outlined"
|
|
||||||
value={value}
|
|
||||||
onChange={(event, val) => onChange(event.target.value)}
|
|
||||||
>
|
|
||||||
{!templateChoices?.length ? (
|
|
||||||
<MenuItem value="">No templates available</MenuItem>
|
|
||||||
) : (
|
|
||||||
templateChoices.map((choice) => (
|
|
||||||
<MenuItem key={choice.value} value={choice.value}>
|
|
||||||
{choice.label}
|
|
||||||
</MenuItem>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Select>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { logger as pinoLogger } from "../../lib/logger";
|
import { logger as pinoLogger } from "../../lib/logger";
|
||||||
import { SendgridConfiguration } from "./configuration/sendgrid-config";
|
import { SendgridConfiguration } from "./configuration/sendgrid-config-schema";
|
||||||
import { MailService } from "@sendgrid/mail";
|
import { MailService } from "@sendgrid/mail";
|
||||||
import { MessageEventTypes } from "../event-handlers/message-event-types";
|
import { MessageEventTypes } from "../event-handlers/message-event-types";
|
||||||
|
|
||||||
|
|
|
@ -30,7 +30,7 @@ export const fetchTemplates =
|
||||||
};
|
};
|
||||||
const templates =
|
const templates =
|
||||||
resJson.result?.map((r) => ({
|
resJson.result?.map((r) => ({
|
||||||
value: r.id,
|
value: r.id.toString(),
|
||||||
label: r.name,
|
label: r.name,
|
||||||
})) || [];
|
})) || [];
|
||||||
return templates;
|
return templates;
|
||||||
|
@ -65,7 +65,7 @@ export const fetchSenders =
|
||||||
};
|
};
|
||||||
const senders =
|
const senders =
|
||||||
resJson.results?.map((r) => ({
|
resJson.results?.map((r) => ({
|
||||||
value: r.id,
|
value: r.id.toString(),
|
||||||
label: `${r.nickname} (${r.from_email})`,
|
label: `${r.nickname} (${r.from_email})`,
|
||||||
nickname: r.nickname,
|
nickname: r.nickname,
|
||||||
from_email: r.from_email,
|
from_email: r.from_email,
|
||||||
|
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { SendgridConfiguration } from "../configuration/sendgrid-config-schema";
|
||||||
|
import { BoxWithBorder } from "../../../components/box-with-border";
|
||||||
|
import { Box, Button, Input, 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 } from "../configuration/sendgrid-config-input-schema";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { BoxFooter } from "../../../components/box-footer";
|
||||||
|
import { SectionWithDescription } from "../../../components/section-with-description";
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const trpcContext = trpcClient.useContext();
|
||||||
|
const { mutate } = trpcClient.sendgridConfiguration.updateApiConnection.useMutation({
|
||||||
|
onSuccess: async (data, variables) => {
|
||||||
|
notifySuccess("Configuration saved");
|
||||||
|
trpcContext.sendgridConfiguration.invalidate();
|
||||||
|
},
|
||||||
|
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 SendgridUpdateApiConnection, {
|
||||||
|
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 (
|
||||||
|
<SectionWithDescription title="API Connection">
|
||||||
|
<BoxWithBorder>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit((data, event) => {
|
||||||
|
mutate({
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Box padding={defaultPadding} display={"flex"} flexDirection={"column"} gap={10}>
|
||||||
|
<Controller
|
||||||
|
name="apiKey"
|
||||||
|
control={control}
|
||||||
|
render={({
|
||||||
|
field: { onChange, value },
|
||||||
|
fieldState: { error },
|
||||||
|
formState: { errors },
|
||||||
|
}) => (
|
||||||
|
<Input
|
||||||
|
label="API Key"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
error={!!error}
|
||||||
|
helperText={
|
||||||
|
error?.message ||
|
||||||
|
"Name of the configuration, for example 'Production' or 'Test'"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" {...register("sandboxMode")} />
|
||||||
|
<Text paddingLeft={defaultPadding}>Sandbox mode</Text>
|
||||||
|
</label>
|
||||||
|
</Box>
|
||||||
|
<BoxFooter>
|
||||||
|
<Button type="submit">Save provider</Button>
|
||||||
|
</BoxFooter>
|
||||||
|
</form>
|
||||||
|
</BoxWithBorder>
|
||||||
|
</SectionWithDescription>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,110 @@
|
||||||
|
import { SendgridConfiguration } from "../configuration/sendgrid-config-schema";
|
||||||
|
import { BoxWithBorder } from "../../../components/box-with-border";
|
||||||
|
import { Box, Button, Input, RadioGroup, 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 { z } from "zod";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { BoxFooter } from "../../../components/box-footer";
|
||||||
|
import { SectionWithDescription } from "../../../components/section-with-description";
|
||||||
|
|
||||||
|
interface BasicInformationSectionProps {
|
||||||
|
configuration: SendgridConfiguration;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BasicInformationSection = ({ configuration }: BasicInformationSectionProps) => {
|
||||||
|
const { notifySuccess, notifyError } = useDashboardNotification();
|
||||||
|
const { handleSubmit, control, setError, register } = useForm<SendgridUpdateBasicInformation>({
|
||||||
|
defaultValues: {
|
||||||
|
id: configuration.id,
|
||||||
|
name: configuration.name,
|
||||||
|
active: configuration.active,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const trpcContext = trpcClient.useContext();
|
||||||
|
const { mutate } = trpcClient.sendgridConfiguration.updateBasicInformation.useMutation({
|
||||||
|
onSuccess: async (data, variables) => {
|
||||||
|
notifySuccess("Configuration saved");
|
||||||
|
trpcContext.sendgridConfiguration.invalidate();
|
||||||
|
},
|
||||||
|
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 z.infer<typeof sendgridUpdateBasicInformationSchema>, {
|
||||||
|
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 (
|
||||||
|
<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}>
|
||||||
|
<Controller
|
||||||
|
name="name"
|
||||||
|
control={control}
|
||||||
|
render={({
|
||||||
|
field: { onChange, value },
|
||||||
|
fieldState: { error },
|
||||||
|
formState: { errors },
|
||||||
|
}) => (
|
||||||
|
<Input
|
||||||
|
label="Configuration name"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
error={!!error}
|
||||||
|
helperText={
|
||||||
|
error?.message ||
|
||||||
|
"Name of the configuration, for example 'Production' or 'Test'"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" placeholder="Enabled" {...register("active")} />
|
||||||
|
<Text paddingLeft={defaultPadding}>Active</Text>
|
||||||
|
</label>
|
||||||
|
</Box>
|
||||||
|
<BoxFooter>
|
||||||
|
<Button type="submit">Save provider</Button>
|
||||||
|
</BoxFooter>
|
||||||
|
</form>
|
||||||
|
</BoxWithBorder>
|
||||||
|
</SectionWithDescription>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,198 @@
|
||||||
|
import {
|
||||||
|
SendgridConfiguration,
|
||||||
|
SendgridConfigurationChannels,
|
||||||
|
} from "../configuration/sendgrid-config-schema";
|
||||||
|
import { BoxWithBorder } from "../../../components/box-with-border";
|
||||||
|
import { Box, Button, ProductsIcons, Switch, TableEditIcon, 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 { SendgridUpdateChannels } from "../configuration/sendgrid-config-input-schema";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { BoxFooter } from "../../../components/box-footer";
|
||||||
|
import { SectionWithDescription } from "../../../components/section-with-description";
|
||||||
|
|
||||||
|
interface ChannelsSectionProps {
|
||||||
|
configuration: SendgridConfiguration;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OverrideMessageArgs {
|
||||||
|
availableChannels: string[];
|
||||||
|
channelConfiguration: SendgridConfigurationChannels;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Move to a separate component
|
||||||
|
const overrideMessage = ({
|
||||||
|
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>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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Text>
|
||||||
|
Configuration will be used with channels:{" "}
|
||||||
|
<Text variant="bodyStrong">{channels.join(", ")}</Text>.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ChannelsSection = ({ configuration }: ChannelsSectionProps) => {
|
||||||
|
const { notifySuccess, notifyError } = useDashboardNotification();
|
||||||
|
|
||||||
|
const { handleSubmit, control, setError, setValue, getValues, register } =
|
||||||
|
useForm<SendgridUpdateChannels>({
|
||||||
|
defaultValues: {
|
||||||
|
id: configuration.id,
|
||||||
|
...configuration.channels,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: channels } = trpcClient.channels.fetch.useQuery();
|
||||||
|
|
||||||
|
const trpcContext = trpcClient.useContext();
|
||||||
|
const { mutate } = trpcClient.sendgridConfiguration.updateChannels.useMutation({
|
||||||
|
onSuccess: async (data, variables) => {
|
||||||
|
notifySuccess("Configuration saved");
|
||||||
|
trpcContext.sendgridConfiguration.invalidate();
|
||||||
|
},
|
||||||
|
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 SendgridUpdateChannels, {
|
||||||
|
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 (
|
||||||
|
<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) => {
|
||||||
|
mutate({
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<BoxWithBorder>
|
||||||
|
<Box
|
||||||
|
padding={defaultPadding}
|
||||||
|
display={"flex"}
|
||||||
|
flexDirection={"column"}
|
||||||
|
gap={defaultPadding}
|
||||||
|
>
|
||||||
|
<Box display={"flex"} flexDirection={"column"} gap={defaultPadding}>
|
||||||
|
<Text variant="heading">Current behaviour</Text>
|
||||||
|
{overrideMessage({
|
||||||
|
availableChannels: channels?.map((channel) => channel.slug) || [],
|
||||||
|
channelConfiguration: configuration.channels,
|
||||||
|
})}
|
||||||
|
<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, value },
|
||||||
|
fieldState: { error },
|
||||||
|
formState: { errors },
|
||||||
|
}) => (
|
||||||
|
<Switch
|
||||||
|
defaultValue={configuration.channels.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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{channels?.map((channel) => (
|
||||||
|
<label key={channel.slug}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
defaultChecked={!!getValues("channels").includes(channel.slug)}
|
||||||
|
onChange={(event) => {
|
||||||
|
if (event.target.checked) {
|
||||||
|
setValue("channels", [...getValues("channels"), channel.slug]);
|
||||||
|
} else {
|
||||||
|
setValue(
|
||||||
|
"channels",
|
||||||
|
getValues("channels").filter((slug) => slug !== channel.slug)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text paddingLeft={defaultPadding}>{channel.name}</Text>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<BoxFooter>
|
||||||
|
<Button type="submit">Save provider</Button>
|
||||||
|
</BoxFooter>
|
||||||
|
</BoxWithBorder>
|
||||||
|
</form>
|
||||||
|
</SectionWithDescription>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,89 @@
|
||||||
|
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";
|
||||||
|
|
||||||
|
interface DangerousSectionProps {
|
||||||
|
configuration: SendgridConfiguration;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DangerousSection = ({ configuration }: DangerousSectionProps) => {
|
||||||
|
const { notifySuccess, notifyError } = useDashboardNotification();
|
||||||
|
const { replace } = useRouter();
|
||||||
|
const { handleSubmit, setError } = useForm<SendgridGetConfigurationIdInput>({
|
||||||
|
defaultValues: {
|
||||||
|
id: configuration.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate } = trpcClient.sendgridConfiguration.deleteConfiguration.useMutation({
|
||||||
|
onSuccess: async (data, variables) => {
|
||||||
|
notifySuccess("Configuration saved");
|
||||||
|
replace("/configuration");
|
||||||
|
},
|
||||||
|
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 SendgridGetConfigurationIdInput, {
|
||||||
|
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 (
|
||||||
|
<SectionWithDescription title="Danger zone">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit((data, event) => {
|
||||||
|
mutate({
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<BoxWithBorder backgroundColor={"surfaceCriticalSubdued"} borderColor={"criticalSubdued"}>
|
||||||
|
<Box padding={defaultPadding}>
|
||||||
|
<Text variant="heading" display="block">
|
||||||
|
Remove provider
|
||||||
|
</Text>
|
||||||
|
<Text display="block">You can remove provider configuration.</Text>
|
||||||
|
<Text display="block">
|
||||||
|
This operation will remove all settings related to this configuration. Data will be
|
||||||
|
permanently removed from the App.{" "}
|
||||||
|
</Text>
|
||||||
|
<Text display="block">This operation cant be undone.</Text>
|
||||||
|
<Text display="block">You still can create new configuration.</Text>
|
||||||
|
</Box>
|
||||||
|
<BoxFooter borderColor={"criticalSubdued"}>
|
||||||
|
<Button
|
||||||
|
color={"textNeutralSubdued"}
|
||||||
|
backgroundColor={"interactiveCriticalDefault"}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Remove provider
|
||||||
|
</Button>
|
||||||
|
</BoxFooter>
|
||||||
|
</BoxWithBorder>
|
||||||
|
</form>
|
||||||
|
</SectionWithDescription>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,145 @@
|
||||||
|
import {
|
||||||
|
SendgridConfiguration,
|
||||||
|
SendgridEventConfiguration,
|
||||||
|
} from "../configuration/sendgrid-config-schema";
|
||||||
|
import { BoxWithBorder } from "../../../components/box-with-border";
|
||||||
|
import { Box, Button, Combobox, 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 } from "../configuration/sendgrid-config-input-schema";
|
||||||
|
import { Controller, 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";
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const trpcContext = trpcClient.useContext();
|
||||||
|
const { mutate } = trpcClient.sendgridConfiguration.updateEvent.useMutation({
|
||||||
|
onSuccess: async (data, variables) => {
|
||||||
|
notifySuccess("Configuration saved");
|
||||||
|
trpcContext.sendgridConfiguration.invalidate();
|
||||||
|
},
|
||||||
|
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 SendgridUpdateEvent, {
|
||||||
|
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 (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit((data, event) => {
|
||||||
|
mutate({
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<BoxWithBorder>
|
||||||
|
<Box
|
||||||
|
padding={defaultPadding}
|
||||||
|
display={"flex"}
|
||||||
|
flexDirection={"column"}
|
||||||
|
gap={defaultPadding}
|
||||||
|
>
|
||||||
|
<Text variant="heading">{event.eventType}</Text>
|
||||||
|
{!templatesChoices ? (
|
||||||
|
<Combobox label="Template" disabled options={[]} />
|
||||||
|
) : (
|
||||||
|
<Controller
|
||||||
|
name="template"
|
||||||
|
control={control}
|
||||||
|
render={({
|
||||||
|
field: { onChange, value },
|
||||||
|
fieldState: { error },
|
||||||
|
formState: { errors },
|
||||||
|
}) => (
|
||||||
|
<Combobox
|
||||||
|
label="Template"
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(event?.value)}
|
||||||
|
options={templatesChoices.map((sender) => ({
|
||||||
|
label: sender.label,
|
||||||
|
value: sender.value,
|
||||||
|
}))}
|
||||||
|
error={!!error}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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 EventsSectionProps {
|
||||||
|
configuration: SendgridConfiguration;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EventsSection = ({ configuration }: EventsSectionProps) => {
|
||||||
|
return (
|
||||||
|
<SectionWithDescription
|
||||||
|
title="Events"
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
<Text display="block">
|
||||||
|
Provide unique name for your configuration - you can create more than one. For example -
|
||||||
|
production and development.
|
||||||
|
</Text>
|
||||||
|
<Text display="block">Then, pass your API Key. Obtain it here.</Text>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Box display="flex" flexDirection={"column"} gap={defaultPadding}>
|
||||||
|
{configuration.events.map((event) => (
|
||||||
|
<EventBox key={event.eventType} configuration={configuration} event={event} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</SectionWithDescription>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,107 @@
|
||||||
|
import { SendgridConfiguration } from "../configuration/sendgrid-config-schema";
|
||||||
|
import { BoxWithBorder } from "../../../components/box-with-border";
|
||||||
|
import { Box, Button, Combobox } 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 } from "../configuration/sendgrid-config-input-schema";
|
||||||
|
import { Controller, 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";
|
||||||
|
|
||||||
|
interface SenderSectionProps {
|
||||||
|
configuration: SendgridConfiguration;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SenderSection = ({ configuration }: SenderSectionProps) => {
|
||||||
|
const { notifySuccess, notifyError } = useDashboardNotification();
|
||||||
|
|
||||||
|
const { data: sendersChoices, isLoading: isSendersChoicesLoading } = 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const trpcContext = trpcClient.useContext();
|
||||||
|
const { mutate } = trpcClient.sendgridConfiguration.updateSender.useMutation({
|
||||||
|
onSuccess: async (data, variables) => {
|
||||||
|
notifySuccess("Configuration saved");
|
||||||
|
trpcContext.sendgridConfiguration.invalidate();
|
||||||
|
},
|
||||||
|
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 SendgridUpdateSender, {
|
||||||
|
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 (
|
||||||
|
<SectionWithDescription title="Sender">
|
||||||
|
<BoxWithBorder>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit((data, event) => {
|
||||||
|
mutate({
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Box padding={defaultPadding} display={"flex"} flexDirection={"column"} gap={10}>
|
||||||
|
{sendersChoices?.length ? (
|
||||||
|
<Controller
|
||||||
|
name="sender"
|
||||||
|
control={control}
|
||||||
|
render={({
|
||||||
|
field: { onChange, value },
|
||||||
|
fieldState: { error },
|
||||||
|
formState: { errors },
|
||||||
|
}) => (
|
||||||
|
<Combobox
|
||||||
|
label="Sender"
|
||||||
|
value={value}
|
||||||
|
defaultValue={configuration.sender}
|
||||||
|
onChange={(event) => onChange(event?.value)}
|
||||||
|
options={sendersChoices.map((sender) => ({
|
||||||
|
label: sender.label,
|
||||||
|
value: sender.value,
|
||||||
|
}))}
|
||||||
|
error={!!error}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Combobox label="Sender" options={[]} disabled={true} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<BoxFooter>
|
||||||
|
<Button type="submit">Save provider</Button>
|
||||||
|
</BoxFooter>
|
||||||
|
</form>
|
||||||
|
</BoxWithBorder>
|
||||||
|
</SectionWithDescription>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,12 +1,10 @@
|
||||||
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 { mjmlConfigurationRouter } from "../mjml/configuration/mjml-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,
|
|
||||||
mjmlConfiguration: mjmlConfigurationRouter,
|
mjmlConfiguration: mjmlConfigurationRouter,
|
||||||
sendgridConfiguration: sendgridConfigurationRouter,
|
sendgridConfiguration: sendgridConfigurationRouter,
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,20 +0,0 @@
|
||||||
import { makeStyles } from "@saleor/macaw-ui";
|
|
||||||
import { PropsWithChildren } from "react";
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => ({
|
|
||||||
root: {
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "280px auto 400px",
|
|
||||||
alignItems: "start",
|
|
||||||
gap: theme.spacing(3),
|
|
||||||
padding: "20px 0",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
type AppColumnsLayoutProps = PropsWithChildren<{}>;
|
|
||||||
|
|
||||||
export const AppColumnsLayout = ({ children }: AppColumnsLayoutProps) => {
|
|
||||||
const styles = useStyles();
|
|
||||||
|
|
||||||
return <div className={styles.root}>{children}</div>;
|
|
||||||
};
|
|
|
@ -1,42 +0,0 @@
|
||||||
import React, { useCallback, useRef } from "react";
|
|
||||||
|
|
||||||
import Editor from "@monaco-editor/react";
|
|
||||||
import { useTheme } from "@saleor/macaw-ui";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
onChange(value: string): void;
|
|
||||||
initialTemplate: string;
|
|
||||||
value: string;
|
|
||||||
language: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CodeEditor = ({ initialTemplate, onChange, value, language }: Props) => {
|
|
||||||
const { themeType } = useTheme();
|
|
||||||
const editorRef = useRef(null);
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
function handleEditorDidMount(editor, monaco) {
|
|
||||||
editorRef.current = editor;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleOnChange = useCallback(
|
|
||||||
(value?: string) => {
|
|
||||||
onChange(value ?? "");
|
|
||||||
},
|
|
||||||
[value]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Editor
|
|
||||||
height="600px"
|
|
||||||
value={value}
|
|
||||||
theme={themeType === "dark" ? "vs-dark" : "vs-light"}
|
|
||||||
defaultLanguage={language}
|
|
||||||
defaultValue={initialTemplate}
|
|
||||||
onMount={handleEditorDidMount}
|
|
||||||
onChange={handleOnChange}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,50 +0,0 @@
|
||||||
import React, { PropsWithChildren } from "react";
|
|
||||||
import { makeStyles, PageTab, PageTabs } from "@saleor/macaw-ui";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => ({
|
|
||||||
appContainer: {
|
|
||||||
marginTop: theme.spacing(3),
|
|
||||||
marginLeft: theme.spacing(3),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
type Props = PropsWithChildren<{}>;
|
|
||||||
|
|
||||||
export const ConfigurationPageBaseLayout = ({ children }: Props) => {
|
|
||||||
const styles = useStyles();
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const tabs = [
|
|
||||||
{
|
|
||||||
key: "channels",
|
|
||||||
label: "Channels",
|
|
||||||
url: "/configuration/channels",
|
|
||||||
},
|
|
||||||
{ key: "mjml", label: "MJML", url: "/configuration/mjml" },
|
|
||||||
{
|
|
||||||
key: "sendgrid",
|
|
||||||
label: "Sendgrid",
|
|
||||||
url: "/configuration/sendgrid",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const activePath = tabs.find((tab) => router.pathname.startsWith(tab.url))?.key;
|
|
||||||
|
|
||||||
const navigateToTab = (value: string) => {
|
|
||||||
const redirectionUrl = tabs.find((tab) => tab.key === value)?.url;
|
|
||||||
if (redirectionUrl) {
|
|
||||||
router.push(redirectionUrl);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div className={styles.appContainer}>
|
|
||||||
<PageTabs value={activePath} onChange={navigateToTab}>
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<PageTab key={tab.key} value={tab.key} label={tab.label} />
|
|
||||||
))}
|
|
||||||
</PageTabs>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,22 +0,0 @@
|
||||||
import { CircularProgress } from "@material-ui/core";
|
|
||||||
import { makeStyles } from "@saleor/macaw-ui";
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => {
|
|
||||||
return {
|
|
||||||
loaderContainer: {
|
|
||||||
margin: "50px auto",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export const LoadingIndicator = () => {
|
|
||||||
const styles = useStyles();
|
|
||||||
return (
|
|
||||||
<div className={styles.loaderContainer}>
|
|
||||||
<CircularProgress color="primary" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,6 +1,4 @@
|
||||||
import { AlertBase, Button } from "@saleor/macaw-ui";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Typography } from "@material-ui/core";
|
|
||||||
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||||
import { appName } from "./const";
|
import { appName } from "./const";
|
||||||
|
|
||||||
|
@ -10,26 +8,7 @@ export const NotReadyPage = () => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>{appName}</h1>
|
<h1>{appName}</h1>
|
||||||
<AlertBase variant="error">
|
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -27,16 +27,16 @@ 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");
|
// FIXME:
|
||||||
|
// 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 })
|
||||||
);
|
// );
|
||||||
await registerNotifyWebhook({
|
// await registerNotifyWebhook({
|
||||||
client: client,
|
// client: client,
|
||||||
baseUrl: baseUrl,
|
// baseUrl: baseUrl,
|
||||||
});
|
// });
|
||||||
logger.debug("Webhook registered");
|
// logger.debug("Webhook registered");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,24 +0,0 @@
|
||||||
import { NextPage } from "next";
|
|
||||||
import React, { useEffect } from "react";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { trpcClient } from "../../../modules/trpc/trpc-client";
|
|
||||||
import { ConfigurationPageBaseLayout } from "../../../modules/ui/configuration-page-base-layout";
|
|
||||||
import { ChannelsConfigurationTab } from "../../../modules/app-configuration/ui/channels-configuration-tab";
|
|
||||||
|
|
||||||
const ChannelsConfigurationPage: NextPage = () => {
|
|
||||||
const channels = trpcClient.channels.fetch.useQuery();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (router && channels.isSuccess && channels.data.length === 0) {
|
|
||||||
router.push("/not-ready");
|
|
||||||
}
|
|
||||||
}, [channels.data, channels.isSuccess, router]);
|
|
||||||
return (
|
|
||||||
<ConfigurationPageBaseLayout>
|
|
||||||
<ChannelsConfigurationTab />
|
|
||||||
</ConfigurationPageBaseLayout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ChannelsConfigurationPage;
|
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { Box, Text } from "@saleor/macaw-ui/next";
|
||||||
|
import { NextPage } from "next";
|
||||||
|
import { Breadcrumbs } from "../../components/breadcrumbs";
|
||||||
|
import { SectionWithDescription } from "../../components/section-with-description";
|
||||||
|
import { ProviderSelectionBox } from "../../modules/app-configuration/ui/provider-selection-box";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
const ChooseProviderPage: NextPage = () => {
|
||||||
|
const { replace } = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box padding={10} display={"grid"} gap={13}>
|
||||||
|
<Breadcrumbs items={[{ name: "Configuration", href: "/" }, { 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={() => replace("/configuration/sendgrid/new")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProviderSelectionBox
|
||||||
|
providerName="SMTP & MJML"
|
||||||
|
providerDescription="Provide your own SMTP credentials and map Saleor event to custom MJML templates."
|
||||||
|
onClick={() => replace("/configuration/mjml/new")}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</SectionWithDescription>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChooseProviderPage;
|
36
apps/emails-and-messages/src/pages/configuration/index.tsx
Normal file
36
apps/emails-and-messages/src/pages/configuration/index.tsx
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
import { Box, Text } from "@saleor/macaw-ui/next";
|
||||||
|
import { NextPage } from "next";
|
||||||
|
import { Breadcrumbs } from "../../components/breadcrumbs";
|
||||||
|
import { SectionWithDescription } from "../../components/section-with-description";
|
||||||
|
import { MessagingProvidersBox } from "../../components/messaging-providers-box";
|
||||||
|
import { trpcClient } from "../../modules/trpc/trpc-client";
|
||||||
|
|
||||||
|
const ConfigurationPage: NextPage = () => {
|
||||||
|
const { data, isLoading } = trpcClient.sendgridConfiguration.getConfigurations.useQuery();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box padding={10} display={"grid"} gap={13}>
|
||||||
|
<Breadcrumbs items={[{ name: "Configuration", href: "/" }]} />
|
||||||
|
<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>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConfigurationPage;
|
|
@ -1,19 +0,0 @@
|
||||||
import { NextPage } from "next";
|
|
||||||
import React from "react";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { ConfigurationPageBaseLayout } from "../../../modules/ui/configuration-page-base-layout";
|
|
||||||
import { MjmlConfigurationTab } from "../../../modules/mjml/configuration/ui/mjml-configuration-tab";
|
|
||||||
|
|
||||||
const MjmlConfigurationPage: NextPage = () => {
|
|
||||||
const router = useRouter();
|
|
||||||
const configurationId = router.query.configurationId
|
|
||||||
? router.query.configurationId[0] // optional routes are passed as an array
|
|
||||||
: undefined;
|
|
||||||
return (
|
|
||||||
<ConfigurationPageBaseLayout>
|
|
||||||
<MjmlConfigurationTab configurationId={configurationId} />
|
|
||||||
</ConfigurationPageBaseLayout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MjmlConfigurationPage;
|
|
|
@ -1,69 +0,0 @@
|
||||||
import { NextPage } from "next";
|
|
||||||
import React from "react";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { trpcClient } from "../../../../../modules/trpc/trpc-client";
|
|
||||||
|
|
||||||
import { parseMessageEventType } from "../../../../../modules/event-handlers/parse-message-event-type";
|
|
||||||
import { ConfigurationPageBaseLayout } from "../../../../../modules/ui/configuration-page-base-layout";
|
|
||||||
import { EventConfigurationForm } from "../../../../../modules/mjml/configuration/ui/mjml-event-configuration-form";
|
|
||||||
import { LoadingIndicator } from "../../../../../modules/ui/loading-indicator";
|
|
||||||
|
|
||||||
const EventConfigurationPage: NextPage = () => {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const configurationId = router.query.configurationId as string;
|
|
||||||
const eventTypeFromQuery = router.query.eventType as string | undefined;
|
|
||||||
const eventType = parseMessageEventType(eventTypeFromQuery);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: configuration,
|
|
||||||
isError,
|
|
||||||
isFetched,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
} = trpcClient.mjmlConfiguration.getEventConfiguration.useQuery(
|
|
||||||
{
|
|
||||||
configurationId,
|
|
||||||
// if event type is not valid, it calling the query will not be enabled
|
|
||||||
// so we can safely cast it
|
|
||||||
eventType: eventType!,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: !!configurationId && !!eventType,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// TODO: better error messages
|
|
||||||
if (!eventType || !configurationId) {
|
|
||||||
return <>Error: no event type or configuration id</>;
|
|
||||||
}
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<ConfigurationPageBaseLayout>
|
|
||||||
<LoadingIndicator />
|
|
||||||
</ConfigurationPageBaseLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
Error: could not load the config: fetched: {isFetched} is error {isError}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!configuration) {
|
|
||||||
return <>Error: no configuration with given id</>;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<ConfigurationPageBaseLayout>
|
|
||||||
<EventConfigurationForm
|
|
||||||
initialData={configuration}
|
|
||||||
configurationId={configurationId}
|
|
||||||
eventType={eventType}
|
|
||||||
/>
|
|
||||||
</ConfigurationPageBaseLayout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EventConfigurationPage;
|
|
|
@ -1,19 +0,0 @@
|
||||||
import { NextPage } from "next";
|
|
||||||
import React from "react";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { ConfigurationPageBaseLayout } from "../../../modules/ui/configuration-page-base-layout";
|
|
||||||
import { SendgridConfigurationTab } from "../../../modules/sendgrid/configuration/ui/sendgrid-configuration-tab";
|
|
||||||
|
|
||||||
const SendgridConfigurationPage: NextPage = () => {
|
|
||||||
const router = useRouter();
|
|
||||||
const configurationId = router.query.configurationId
|
|
||||||
? router.query.configurationId[0] // optional routes are passed as an array
|
|
||||||
: undefined;
|
|
||||||
return (
|
|
||||||
<ConfigurationPageBaseLayout>
|
|
||||||
<SendgridConfigurationTab configurationId={configurationId} />
|
|
||||||
</ConfigurationPageBaseLayout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SendgridConfigurationPage;
|
|
|
@ -1,77 +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 { LoadingIndicator } from "../../../../../modules/ui/loading-indicator";
|
|
||||||
import { EventConfigurationForm } from "../../../../../modules/sendgrid/configuration/ui/sendgrid-event-configuration-form";
|
|
||||||
|
|
||||||
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: eventConfiguration,
|
|
||||||
isError,
|
|
||||||
isFetched,
|
|
||||||
isLoading,
|
|
||||||
} = trpcClient.sendgridConfiguration.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,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: configuration } = trpcClient.sendgridConfiguration.getConfiguration.useQuery(
|
|
||||||
{
|
|
||||||
id: configurationId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: !!configurationId,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
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 (!eventConfiguration || !configuration) {
|
|
||||||
return <>Error: no configuration with given id</>;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<ConfigurationPageBaseLayout>
|
|
||||||
<EventConfigurationForm
|
|
||||||
initialData={eventConfiguration}
|
|
||||||
configurationId={configurationId}
|
|
||||||
configuration={configuration}
|
|
||||||
eventType={eventType}
|
|
||||||
/>
|
|
||||||
</ConfigurationPageBaseLayout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EventConfigurationPage;
|
|
|
@ -0,0 +1,96 @@
|
||||||
|
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 { BasicInformationSection } from "../../../../modules/sendgrid/ui/basic-information-section";
|
||||||
|
import { DangerousSection } from "../../../../modules/sendgrid/ui/dangrous-section";
|
||||||
|
import { ApiConnectionSection } from "../../../../modules/sendgrid/ui/api-connection-section";
|
||||||
|
import { SenderSection } from "../../../../modules/sendgrid/ui/sender-section";
|
||||||
|
import { EventsSection } from "../../../../modules/sendgrid/ui/events-section";
|
||||||
|
import { useDashboardNotification } from "@saleor/apps-shared";
|
||||||
|
import { BasicLayout } from "../../../../components/basic-layout";
|
||||||
|
import { ChannelsSection } from "../../../../modules/sendgrid/ui/channels-section";
|
||||||
|
|
||||||
|
const LoadingView = () => {
|
||||||
|
return (
|
||||||
|
<BasicLayout
|
||||||
|
breadcrumbs={[
|
||||||
|
{ name: "Configuration", href: "/" },
|
||||||
|
{ name: "Sendgrid provider" },
|
||||||
|
{ name: "..." },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text variant="hero">Loading...</Text>
|
||||||
|
</BasicLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const NotFoundView = () => {
|
||||||
|
return (
|
||||||
|
<BasicLayout
|
||||||
|
breadcrumbs={[
|
||||||
|
{ name: "Configuration", href: "/" },
|
||||||
|
{ 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 { id } = router.query;
|
||||||
|
const { data: configuration, isLoading } =
|
||||||
|
trpcClient.sendgridConfiguration.getConfiguration.useQuery(
|
||||||
|
{
|
||||||
|
id: id as string,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!id,
|
||||||
|
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: "/configuration" },
|
||||||
|
{ name: "Sendgrid provider" },
|
||||||
|
{ name: configuration.name },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Box display={"grid"} gridTemplateColumns={{ desktop: 3, mobile: 1 }}>
|
||||||
|
<Box>
|
||||||
|
<Text>Connect Sendgrid with Saleor.</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<BasicInformationSection configuration={configuration} />
|
||||||
|
<ApiConnectionSection configuration={configuration} />
|
||||||
|
<SenderSection configuration={configuration} />
|
||||||
|
<EventsSection configuration={configuration} />
|
||||||
|
<ChannelsSection configuration={configuration} />
|
||||||
|
<DangerousSection configuration={configuration} />
|
||||||
|
</BasicLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditSendgridConfigurationPage;
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { Box, Button, Input, Text } from "@saleor/macaw-ui/next";
|
||||||
|
import { NextPage } from "next";
|
||||||
|
import { SectionWithDescription } from "../../../components/section-with-description";
|
||||||
|
import { BoxWithBorder } from "../../../components/box-with-border";
|
||||||
|
import { defaultPadding } from "../../../components/ui-defaults";
|
||||||
|
import { BoxFooter } from "../../../components/box-footer";
|
||||||
|
import { trpcClient } from "../../../modules/trpc/trpc-client";
|
||||||
|
import { useDashboardNotification } from "@saleor/apps-shared/src/use-dashboard-notification";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { SendgridCreateConfigurationInput } from "../../../modules/sendgrid/configuration/sendgrid-config-input-schema";
|
||||||
|
import { BasicLayout } from "../../../components/basic-layout";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
const NewSendgridConfigurationPage: NextPage = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { notifySuccess, notifyError } = useDashboardNotification();
|
||||||
|
|
||||||
|
const { handleSubmit, control, setError } = useForm<SendgridCreateConfigurationInput>();
|
||||||
|
|
||||||
|
const { mutate: createConfiguration } =
|
||||||
|
trpcClient.sendgridConfiguration.createConfiguration.useMutation({
|
||||||
|
onSuccess: async (data, variables) => {
|
||||||
|
notifySuccess("Configuration saved");
|
||||||
|
router.push(`/configuration/sendgrid/edit/${data.id}`);
|
||||||
|
},
|
||||||
|
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 SendgridCreateConfigurationInput, {
|
||||||
|
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 (
|
||||||
|
<BasicLayout
|
||||||
|
breadcrumbs={[
|
||||||
|
{ name: "Configuration", href: "/" },
|
||||||
|
{ name: "Add provider" },
|
||||||
|
{ name: "Sendgrid" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Box display={"grid"} gridTemplateColumns={{ desktop: 3, mobile: 1 }}>
|
||||||
|
<Box>
|
||||||
|
<Text>Connect Sendgrid with Saleor.</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<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) => {
|
||||||
|
createConfiguration({
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Box padding={defaultPadding} display={"flex"} flexDirection={"column"} gap={10}>
|
||||||
|
<Controller
|
||||||
|
name="name"
|
||||||
|
control={control}
|
||||||
|
render={({
|
||||||
|
field: { onChange, value },
|
||||||
|
fieldState: { error },
|
||||||
|
formState: { errors },
|
||||||
|
}) => (
|
||||||
|
<Input
|
||||||
|
label="Configuration name"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
error={!!error}
|
||||||
|
helperText={
|
||||||
|
error?.message ||
|
||||||
|
"Name of the configuration, for example 'Production' or 'Test'"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="apiKey"
|
||||||
|
control={control}
|
||||||
|
render={({
|
||||||
|
field: { onChange, value },
|
||||||
|
fieldState: { error },
|
||||||
|
formState: { errors },
|
||||||
|
}) => (
|
||||||
|
// TODO: add validation
|
||||||
|
<Input
|
||||||
|
label="API key"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
error={!!error}
|
||||||
|
helperText={
|
||||||
|
error?.message || "Your API key, ensure it has permission XYZ enabled"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<BoxFooter>
|
||||||
|
<Button type="submit">Save provider</Button>
|
||||||
|
</BoxFooter>
|
||||||
|
</form>
|
||||||
|
</BoxWithBorder>
|
||||||
|
</SectionWithDescription>
|
||||||
|
</BasicLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NewSendgridConfigurationPage;
|
|
@ -3,7 +3,6 @@ import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useIsMounted } from "usehooks-ts";
|
import { useIsMounted } from "usehooks-ts";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { LinearProgress } from "@material-ui/core";
|
|
||||||
import { isInIframe } from "../lib/is-in-iframe";
|
import { isInIframe } from "../lib/is-in-iframe";
|
||||||
import { appName } from "../const";
|
import { appName } from "../const";
|
||||||
|
|
||||||
|
@ -14,12 +13,12 @@ const IndexPage: NextPage = () => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isMounted() && appBridgeState?.ready) {
|
if (isMounted() && appBridgeState?.ready) {
|
||||||
replace("/configuration/channels");
|
replace("/configuration");
|
||||||
}
|
}
|
||||||
}, [isMounted, appBridgeState?.ready, replace]);
|
}, [isMounted, appBridgeState?.ready, replace]);
|
||||||
|
|
||||||
if (isInIframe()) {
|
if (isInIframe()) {
|
||||||
return <LinearProgress />;
|
return <p>Loading</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
BIN
apps/emails-and-messages/src/public/sendgrid.png
Normal file
BIN
apps/emails-and-messages/src/public/sendgrid.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 332 B |
9
apps/emails-and-messages/src/public/smtp.svg
Normal file
9
apps/emails-and-messages/src/public/smtp.svg
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
<svg width="20" height="19" viewBox="0 0 20 19" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M4 4.625C4.55228 4.625 5 4.17728 5 3.625C5 3.07272 4.55228 2.625 4 2.625C3.44772 2.625 3 3.07272 3 3.625C3 4.17728 3.44772 4.625 4 4.625Z" fill="#4C5C6D"/>
|
||||||
|
<path d="M9 3.625C9 4.17728 8.55229 4.625 8 4.625C7.44772 4.625 7 4.17728 7 3.625C7 3.07272 7.44772 2.625 8 2.625C8.55229 2.625 9 3.07272 9 3.625Z" fill="#4C5C6D"/>
|
||||||
|
<path d="M12 4.625C12.5523 4.625 13 4.17728 13 3.625C13 3.07272 12.5523 2.625 12 2.625C11.4477 2.625 11 3.07272 11 3.625C11 4.17728 11.4477 4.625 12 4.625Z" fill="#4C5C6D"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M2 0C1.10254 0 0.375 0.727538 0.375 1.625V5.625C0.375 6.52246 1.10254 7.25 2 7.25H18C18.8975 7.25 19.625 6.52246 19.625 5.625V1.625C19.625 0.727537 18.8975 0 18 0H2ZM1.625 1.625C1.625 1.41789 1.79289 1.25 2 1.25H18C18.2071 1.25 18.375 1.41789 18.375 1.625V5.625C18.375 5.83211 18.2071 6 18 6H2C1.79289 6 1.625 5.83211 1.625 5.625V1.625Z" fill="#4C5C6D"/>
|
||||||
|
<path d="M5.5 9C5.84518 9 6.125 9.27982 6.125 9.625C6.125 9.97018 5.84518 10.25 5.5 10.25H2.00001C1.7929 10.25 1.62501 10.4179 1.62501 10.625V14.625C1.62501 14.8321 1.7929 15 2.00001 15H5.5C5.84518 15 6.125 15.2798 6.125 15.625C6.125 15.9702 5.84518 16.25 5.5 16.25H2.00001C1.10254 16.25 0.375008 15.5225 0.375008 14.625V10.625C0.375008 9.72754 1.10254 9 2.00001 9H5.5Z" fill="#4C5C6D"/>
|
||||||
|
<path d="M5 12.625C5 13.1773 4.55228 13.625 4 13.625C3.44772 13.625 3 13.1773 3 12.625C3 12.0727 3.44772 11.625 4 11.625C4.55228 11.625 5 12.0727 5 12.625Z" fill="#4C5C6D"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.375 10.625C7.375 9.72754 8.10254 9 9 9H18C18.8975 9 19.625 9.72754 19.625 10.625V11.6053C19.6254 11.6183 19.6254 11.6313 19.625 11.6443V16.625C19.625 17.5225 18.8975 18.25 18 18.25H9C8.10254 18.25 7.375 17.5225 7.375 16.625V11.6251C7.375 11.6249 7.375 11.6253 7.375 11.6251V10.625ZM8.625 12.5957V16.625C8.625 16.8321 8.79289 17 9 17H18C18.2071 17 18.375 16.8321 18.375 16.625V12.5958L14.1726 14.506C13.7453 14.7002 13.255 14.7002 12.8277 14.506L8.625 12.5957ZM18.375 11.2228L13.6553 13.3681C13.5567 13.4129 13.4436 13.4129 13.345 13.3681L8.625 11.2226V10.625C8.625 10.4179 8.79289 10.25 9 10.25H18C18.2071 10.25 18.375 10.4179 18.375 10.625V11.2228Z" fill="#4C5C6D"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 2.2 KiB |
|
@ -1,21 +1,16 @@
|
||||||
body {
|
body {
|
||||||
font-family: Inter, -apple-system, "system-ui", "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell,
|
color: var(--mu-colors-foreground-text-neutral-plain);
|
||||||
"Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
|
padding: 0;
|
||||||
color: #111;
|
margin: 0;
|
||||||
|
background: var(--mu-colors-background-plain);
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
background: #f6f8fa;
|
|
||||||
border: 1px solid #eaeaea;
|
|
||||||
border-radius: 5px;
|
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-top: 10px;
|
letter-spacing: 0.1em;
|
||||||
padding: 0.75rem;
|
color: var(--mu-colors-foreground-text-neutral-subdued);
|
||||||
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
|
|
||||||
Bitstream Vera Sans Mono, Courier New, monospace;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
code::before {
|
a {
|
||||||
content: "$ ";
|
text-decoration: none;
|
||||||
opacity: 0.6;
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
query FetchProductDataForFeed($first:Int!, $after: String, $channel: String!){
|
query FetchProductDataForFeed($first:Int!, $after: String, $channel: String!, $language: String!){
|
||||||
productVariants(first:$first, after: $after, channel: $channel){
|
productVariants(first:$first, after: $after, channel: $channel){
|
||||||
pageInfo{
|
pageInfo{
|
||||||
hasNextPage
|
hasNextPage
|
||||||
|
|
116
pnpm-lock.yaml
116
pnpm-lock.yaml
|
@ -322,13 +322,10 @@ importers:
|
||||||
'@graphql-codegen/typescript-urql': ^3.7.3
|
'@graphql-codegen/typescript-urql': ^3.7.3
|
||||||
'@graphql-codegen/urql-introspection': 2.2.1
|
'@graphql-codegen/urql-introspection': 2.2.1
|
||||||
'@graphql-typed-document-node/core': ^3.1.2
|
'@graphql-typed-document-node/core': ^3.1.2
|
||||||
'@material-ui/core': ^4.12.4
|
|
||||||
'@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.37.3
|
'@saleor/app-sdk': 0.37.3
|
||||||
'@saleor/apps-shared': workspace:*
|
'@saleor/apps-shared': workspace:*
|
||||||
'@saleor/macaw-ui': ^0.7.2
|
'@saleor/macaw-ui': 0.8.0-pre.79
|
||||||
'@sendgrid/client': ^7.7.0
|
'@sendgrid/client': ^7.7.0
|
||||||
'@sendgrid/mail': ^7.7.0
|
'@sendgrid/mail': ^7.7.0
|
||||||
'@tanstack/react-query': ^4.24.4
|
'@tanstack/react-query': ^4.24.4
|
||||||
|
@ -375,13 +372,10 @@ importers:
|
||||||
vitest: ^0.30.1
|
vitest: ^0.30.1
|
||||||
zod: ^3.20.2
|
zod: ^3.20.2
|
||||||
dependencies:
|
dependencies:
|
||||||
'@material-ui/core': 4.12.4_5ndqzdd6t4rivxsukjv3i3ak2q
|
|
||||||
'@material-ui/icons': 4.11.3_x54wk6dsnsxe7g7vvfmytp77te
|
|
||||||
'@material-ui/lab': 4.0.0-alpha.61_x54wk6dsnsxe7g7vvfmytp77te
|
|
||||||
'@monaco-editor/react': 4.4.6_biqbaboplfbrettd7655fr4n2y
|
'@monaco-editor/react': 4.4.6_biqbaboplfbrettd7655fr4n2y
|
||||||
'@saleor/app-sdk': 0.37.3_yucv4tfv7v7nrkw2uguegj6e7e
|
'@saleor/app-sdk': 0.37.3_yucv4tfv7v7nrkw2uguegj6e7e
|
||||||
'@saleor/apps-shared': link:../../packages/shared
|
'@saleor/apps-shared': link:../../packages/shared
|
||||||
'@saleor/macaw-ui': 0.7.2_pmlnlm755hlzzzocw2qhf3a34e
|
'@saleor/macaw-ui': 0.8.0-pre.79_5ndqzdd6t4rivxsukjv3i3ak2q
|
||||||
'@sendgrid/client': 7.7.0
|
'@sendgrid/client': 7.7.0
|
||||||
'@sendgrid/mail': 7.7.0
|
'@sendgrid/mail': 7.7.0
|
||||||
'@tanstack/react-query': 4.24.4_biqbaboplfbrettd7655fr4n2y
|
'@tanstack/react-query': 4.24.4_biqbaboplfbrettd7655fr4n2y
|
||||||
|
@ -4320,6 +4314,34 @@ packages:
|
||||||
- '@types/react'
|
- '@types/react'
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/react-popover/1.0.5_5ndqzdd6t4rivxsukjv3i3ak2q:
|
||||||
|
resolution: {integrity: sha512-GRHZ8yD12MrN2NLobHPE8Rb5uHTxd9x372DE9PPNnBjpczAQHcZ5ne0KXG4xpf+RDdXSzdLv9ym6mYJCDTaUZg==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.20.13
|
||||||
|
'@radix-ui/primitive': 1.0.0
|
||||||
|
'@radix-ui/react-compose-refs': 1.0.0_react@18.2.0
|
||||||
|
'@radix-ui/react-context': 1.0.0_react@18.2.0
|
||||||
|
'@radix-ui/react-dismissable-layer': 1.0.3_biqbaboplfbrettd7655fr4n2y
|
||||||
|
'@radix-ui/react-focus-guards': 1.0.0_react@18.2.0
|
||||||
|
'@radix-ui/react-focus-scope': 1.0.2_biqbaboplfbrettd7655fr4n2y
|
||||||
|
'@radix-ui/react-id': 1.0.0_react@18.2.0
|
||||||
|
'@radix-ui/react-popper': 1.1.1_5ndqzdd6t4rivxsukjv3i3ak2q
|
||||||
|
'@radix-ui/react-portal': 1.0.2_biqbaboplfbrettd7655fr4n2y
|
||||||
|
'@radix-ui/react-presence': 1.0.0_biqbaboplfbrettd7655fr4n2y
|
||||||
|
'@radix-ui/react-primitive': 1.0.2_biqbaboplfbrettd7655fr4n2y
|
||||||
|
'@radix-ui/react-slot': 1.0.1_react@18.2.0
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.0.0_react@18.2.0
|
||||||
|
aria-hidden: 1.2.2_3stiutgnnbnfnf3uowm5cip22i
|
||||||
|
react: 18.2.0
|
||||||
|
react-dom: 18.2.0_react@18.2.0
|
||||||
|
react-remove-scroll: 2.5.5_3stiutgnnbnfnf3uowm5cip22i
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/react'
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@radix-ui/react-popper/1.1.1_5ndqzdd6t4rivxsukjv3i3ak2q:
|
/@radix-ui/react-popper/1.1.1_5ndqzdd6t4rivxsukjv3i3ak2q:
|
||||||
resolution: {integrity: sha512-keYDcdMPNMjSC8zTsZ8wezUMiWM9Yj14wtF3s0PTIs9srnEPC9Kt2Gny1T3T81mmSeyDjZxsD9N5WCwNNb712w==}
|
resolution: {integrity: sha512-keYDcdMPNMjSC8zTsZ8wezUMiWM9Yj14wtF3s0PTIs9srnEPC9Kt2Gny1T3T81mmSeyDjZxsD9N5WCwNNb712w==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
@ -4479,6 +4501,31 @@ packages:
|
||||||
react-dom: 18.2.0_react@18.2.0
|
react-dom: 18.2.0_react@18.2.0
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/react-tooltip/1.0.5_5ndqzdd6t4rivxsukjv3i3ak2q:
|
||||||
|
resolution: {integrity: sha512-cDKVcfzyO6PpckZekODJZDe5ZxZ2fCZlzKzTmPhe4mX9qTHRfLcKgqb0OKf22xLwDequ2tVleim+ZYx3rabD5w==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.20.13
|
||||||
|
'@radix-ui/primitive': 1.0.0
|
||||||
|
'@radix-ui/react-compose-refs': 1.0.0_react@18.2.0
|
||||||
|
'@radix-ui/react-context': 1.0.0_react@18.2.0
|
||||||
|
'@radix-ui/react-dismissable-layer': 1.0.3_biqbaboplfbrettd7655fr4n2y
|
||||||
|
'@radix-ui/react-id': 1.0.0_react@18.2.0
|
||||||
|
'@radix-ui/react-popper': 1.1.1_5ndqzdd6t4rivxsukjv3i3ak2q
|
||||||
|
'@radix-ui/react-portal': 1.0.2_biqbaboplfbrettd7655fr4n2y
|
||||||
|
'@radix-ui/react-presence': 1.0.0_biqbaboplfbrettd7655fr4n2y
|
||||||
|
'@radix-ui/react-primitive': 1.0.2_biqbaboplfbrettd7655fr4n2y
|
||||||
|
'@radix-ui/react-slot': 1.0.1_react@18.2.0
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.0.0_react@18.2.0
|
||||||
|
'@radix-ui/react-visually-hidden': 1.0.2_biqbaboplfbrettd7655fr4n2y
|
||||||
|
react: 18.2.0
|
||||||
|
react-dom: 18.2.0_react@18.2.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/react'
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@radix-ui/react-use-callback-ref/1.0.0_react@18.2.0:
|
/@radix-ui/react-use-callback-ref/1.0.0_react@18.2.0:
|
||||||
resolution: {integrity: sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg==}
|
resolution: {integrity: sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
@ -4757,6 +4804,38 @@ packages:
|
||||||
- '@types/react'
|
- '@types/react'
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@saleor/macaw-ui/0.8.0-pre.79_5ndqzdd6t4rivxsukjv3i3ak2q:
|
||||||
|
resolution: {integrity: sha512-E+kqNPPyD5QR+DHLIrhbfN++g7DC+LyrINYeSnDXYad03BZz1AXcY9tagNMIt8ie4qrmQ/gMrsEQpN0yMUlNgg==}
|
||||||
|
engines: {node: '>=16 <19', pnpm: '>=8'}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||||
|
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||||
|
dependencies:
|
||||||
|
'@dessert-box/react': 0.4.0_react@18.2.0
|
||||||
|
'@floating-ui/react-dom-interactions': 0.5.0_5ndqzdd6t4rivxsukjv3i3ak2q
|
||||||
|
'@radix-ui/react-accordion': 1.1.1_biqbaboplfbrettd7655fr4n2y
|
||||||
|
'@radix-ui/react-checkbox': 1.0.3_biqbaboplfbrettd7655fr4n2y
|
||||||
|
'@radix-ui/react-dialog': 1.0.3_5ndqzdd6t4rivxsukjv3i3ak2q
|
||||||
|
'@radix-ui/react-dropdown-menu': 2.0.4_5ndqzdd6t4rivxsukjv3i3ak2q
|
||||||
|
'@radix-ui/react-popover': 1.0.5_5ndqzdd6t4rivxsukjv3i3ak2q
|
||||||
|
'@radix-ui/react-portal': 1.0.2_biqbaboplfbrettd7655fr4n2y
|
||||||
|
'@radix-ui/react-radio-group': 1.1.2_biqbaboplfbrettd7655fr4n2y
|
||||||
|
'@radix-ui/react-select': 1.2.1_5ndqzdd6t4rivxsukjv3i3ak2q
|
||||||
|
'@radix-ui/react-toggle': 1.0.2_biqbaboplfbrettd7655fr4n2y
|
||||||
|
'@radix-ui/react-tooltip': 1.0.5_5ndqzdd6t4rivxsukjv3i3ak2q
|
||||||
|
'@vanilla-extract/css-utils': 0.1.3
|
||||||
|
clsx: 1.2.1
|
||||||
|
downshift: 6.1.12_react@18.2.0
|
||||||
|
downshift7: /downshift/7.6.0_react@18.2.0
|
||||||
|
lodash: 4.17.21
|
||||||
|
lodash-es: 4.17.21
|
||||||
|
react: 18.2.0
|
||||||
|
react-dom: 18.2.0_react@18.2.0
|
||||||
|
react-inlinesvg: 3.0.1_react@18.2.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/react'
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@selderee/plugin-htmlparser2/0.10.0:
|
/@selderee/plugin-htmlparser2/0.10.0:
|
||||||
resolution: {integrity: sha512-gW69MEamZ4wk1OsOq1nG1jcyhXIQcnrsX5JwixVw/9xaiav8TCyjESAruu1Rz9yyInhgBXxkNwMeygKnN2uxNA==}
|
resolution: {integrity: sha512-gW69MEamZ4wk1OsOq1nG1jcyhXIQcnrsX5JwixVw/9xaiav8TCyjESAruu1Rz9yyInhgBXxkNwMeygKnN2uxNA==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -6412,6 +6491,10 @@ packages:
|
||||||
graphql: 16.6.0
|
graphql: 16.6.0
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/@vanilla-extract/css-utils/0.1.3:
|
||||||
|
resolution: {integrity: sha512-PZAcHROlgtCUGI2y0JntdNwvPwCNyeVnkQu6KTYKdmxBbK3w72XJUmLFYapfaFfgami4I9CTLnrJTPdtmS3gpw==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@vitejs/plugin-react/3.1.0_vite@4.2.1:
|
/@vitejs/plugin-react/3.1.0_vite@4.2.1:
|
||||||
resolution: {integrity: sha512-AfgcRL8ZBhAlc3BFdigClmTUMISmmzHn7sB2h9U1odvc5U/MjWXsAaz18b/WoppUTDBzxOJwo2VdClfUcItu9g==}
|
resolution: {integrity: sha512-AfgcRL8ZBhAlc3BFdigClmTUMISmmzHn7sB2h9U1odvc5U/MjWXsAaz18b/WoppUTDBzxOJwo2VdClfUcItu9g==}
|
||||||
engines: {node: ^14.18.0 || >=16.0.0}
|
engines: {node: ^14.18.0 || >=16.0.0}
|
||||||
|
@ -7741,6 +7824,10 @@ packages:
|
||||||
/compute-scroll-into-view/1.0.20:
|
/compute-scroll-into-view/1.0.20:
|
||||||
resolution: {integrity: sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==}
|
resolution: {integrity: sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==}
|
||||||
|
|
||||||
|
/compute-scroll-into-view/2.0.4:
|
||||||
|
resolution: {integrity: sha512-y/ZA3BGnxoM/QHHQ2Uy49CLtnWPbt4tTPpEEZiEmmiWBFKjej7nEyH8Ryz54jH0MLXflUYA3Er2zUxPSJu5R+g==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/computed-style/0.1.4:
|
/computed-style/0.1.4:
|
||||||
resolution: {integrity: sha512-WpAmaKbMNmS3OProfHIdJiNleNJdgUrJfbKArXua28QF7+0CoZjlLn0lp6vlc+dl5r2/X9GQiQRQQU4BzSa69w==}
|
resolution: {integrity: sha512-WpAmaKbMNmS3OProfHIdJiNleNJdgUrJfbKArXua28QF7+0CoZjlLn0lp6vlc+dl5r2/X9GQiQRQQU4BzSa69w==}
|
||||||
dev: false
|
dev: false
|
||||||
|
@ -8295,6 +8382,19 @@ packages:
|
||||||
react-is: 17.0.2
|
react-is: 17.0.2
|
||||||
tslib: 2.5.0
|
tslib: 2.5.0
|
||||||
|
|
||||||
|
/downshift/7.6.0_react@18.2.0:
|
||||||
|
resolution: {integrity: sha512-VSoTVynTAsabou/hbZ6HJHUVhtBiVOjQoBsCPcQq5eAROIGP+9XKMp9asAKQ3cEcUP4oe0fFdD2pziUjhFY33Q==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.12.0'
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.20.13
|
||||||
|
compute-scroll-into-view: 2.0.4
|
||||||
|
prop-types: 15.8.1
|
||||||
|
react: 18.2.0
|
||||||
|
react-is: 17.0.2
|
||||||
|
tslib: 2.5.0
|
||||||
|
dev: false
|
||||||
|
|
||||||
/dset/3.1.2:
|
/dset/3.1.2:
|
||||||
resolution: {integrity: sha512-g/M9sqy3oHe477Ar4voQxWtaPIFw1jTdKZuomOjhCcBx9nHUNn0pu6NopuFFrTh/TRZIKEj+76vLWFu9BNKk+Q==}
|
resolution: {integrity: sha512-g/M9sqy3oHe477Ar4voQxWtaPIFw1jTdKZuomOjhCcBx9nHUNn0pu6NopuFFrTh/TRZIKEj+76vLWFu9BNKk+Q==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
Loading…
Reference in a new issue