Update E&M Provider UI (#459)

* update to macaw next
* update ui to new design
This commit is contained in:
Krzysztof Wolski 2023-05-24 10:38:09 +02:00 committed by Krzysztof Wolski
parent e04ee04b3d
commit 44741aa74e
83 changed files with 3651 additions and 769 deletions

View file

@ -16,10 +16,11 @@
"schemaVersion": "3.11.7"
},
"dependencies": {
"@hookform/resolvers": "^2.9.10",
"@monaco-editor/react": "^4.4.6",
"@saleor/app-sdk": "0.38.0",
"@saleor/apps-shared": "workspace:*",
"@saleor/macaw-ui": "0.8.0-pre.72",
"@saleor/macaw-ui": "0.8.0-pre.83",
"@sendgrid/client": "^7.7.0",
"@sendgrid/mail": "^7.7.0",
"@sentry/nextjs": "^7.52.1",
@ -29,7 +30,7 @@
"@trpc/react-query": "^10.13.0",
"@trpc/server": "^10.13.0",
"@urql/exchange-auth": "^1.0.0",
"@vitejs/plugin-react": "^3.0.1",
"@vitejs/plugin-react": "^4.0.0",
"clsx": "^1.2.1",
"graphql": "^16.6.0",
"graphql-tag": "^2.12.6",
@ -49,8 +50,8 @@
"react-query": "^3.39.3",
"urql": "^3.0.3",
"usehooks-ts": "^2.9.1",
"vite": "^4.3.1",
"vitest": "^0.30.1",
"vite": "^4.3.8",
"vitest": "^0.31.1",
"zod": "^3.20.2"
},
"devDependencies": {

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

View file

@ -0,0 +1,19 @@
import { Box, BoxProps } from "@saleor/macaw-ui/next";
import { defaultPadding } from "./ui-defaults";
export const BoxFooter = (props: BoxProps) => {
return (
<Box
borderTopStyle="solid"
borderWidth={1}
borderColor="neutralDefault"
padding={defaultPadding}
display="flex"
flexDirection="row"
justifyContent="flex-end"
{...props}
>
{props.children}
</Box>
);
};

View file

@ -0,0 +1,15 @@
import { Box, BoxProps } from "@saleor/macaw-ui/next";
export const BoxWithBorder = (props: BoxProps) => {
return (
<Box
borderWidth={1}
borderStyle="solid"
borderColor="neutralDefault"
borderRadius={5}
{...props}
>
{props.children}
</Box>
);
};

View file

@ -0,0 +1,55 @@
import { Box, Text, TextProps } from "@saleor/macaw-ui/next";
import { TextLink } from "./text-link";
import icon from "../public/breadcrumb-separator.svg";
import Image from "next/image";
type BreadcrumbItem = { name: string; href?: string };
interface BreadcrumbProps extends BreadcrumbItem {
isLast?: boolean;
}
const Breadcrumb = ({ name, href, isLast }: BreadcrumbProps) => {
const textProps: TextProps = {
variant: "hero",
display: isLast ? "block" : { mobile: "none", desktop: "block" },
};
if (!!href) {
return (
<TextLink href={href} {...textProps}>
{name}
</TextLink>
);
}
return <Text {...textProps}>{name}</Text>;
};
interface BreadcrumbsProps {
items: Array<BreadcrumbItem>;
}
/**
* 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}>
<Breadcrumb {...item} key={item.name} />
<Image alt="Separator icon" src={icon} height={32} width={32} />
</Box>
))}
<Breadcrumb isLast={true} {...lastItem} />
</Box>
);
};

View file

@ -0,0 +1,30 @@
import { Text, Chip, ChipProps } from "@saleor/macaw-ui/next";
const colorPropsMapping: Record<ChipProps["variant"], ChipProps["borderColor"]> = {
default: "neutralSubdued",
warning: "brandHighlight",
error: "criticalDefault",
success: "neutralDefault",
};
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,
};
return (
<Chip {...commonProps} borderColor={colorPropsMapping[variant]}>
<Text color="textNeutralDefault" size="small" variant="caption">
{content}
</Text>
</Chip>
);
};

View file

@ -0,0 +1,36 @@
import { Combobox as $Combobox, type ComboboxProps as $ComboboxProps } from "@saleor/macaw-ui/next";
import { Control, Controller, FieldPath, FieldValues } from "react-hook-form";
export type ComboboxProps<T extends FieldValues = FieldValues> = Omit<$ComboboxProps, "name"> & {
name: FieldPath<T>;
control: Control<T>;
};
export function Combobox<TFieldValues extends FieldValues = FieldValues>({
type,
required,
name,
control,
options,
...rest
}: ComboboxProps<TFieldValues>): JSX.Element {
return (
<Controller
name={name}
control={control}
render={({ field: { value, ...field }, fieldState: { error } }) => (
<$Combobox
{...rest}
{...field}
options={options}
value={value || ""}
name={name}
required={required}
type={type}
error={!!error}
helperText={rest.helperText}
/>
)}
/>
);
}

View file

@ -0,0 +1,36 @@
import { Input as $Input, type InputProps as $InputProps } from "@saleor/macaw-ui/next";
import { Control, Controller, FieldPath, FieldValues } from "react-hook-form";
export type TextFieldElementProps<T extends FieldValues = FieldValues> = Omit<
$InputProps,
"name"
> & {
name: FieldPath<T>;
control: Control<T>;
};
export function Input<TFieldValues extends FieldValues = FieldValues>({
type,
required,
name,
control,
...rest
}: TextFieldElementProps<TFieldValues>): JSX.Element {
return (
<Controller
name={name}
control={control}
render={({ field, fieldState: { error } }) => (
<$Input
{...rest}
{...field}
name={name}
required={required}
type={type}
error={!!error}
helperText={rest.helperText}
/>
)}
/>
);
}

View file

@ -0,0 +1,42 @@
import {
Multiselect as $Multiselect,
type MultiselectProps as $MultiselectProps,
} from "@saleor/macaw-ui/next";
import { Control, Controller, FieldPath, FieldValues, UseFormSetError } from "react-hook-form";
export type MultiselectProps<T extends FieldValues = FieldValues> = Omit<
$MultiselectProps,
"name"
> & {
name: FieldPath<T>;
control: Control<T>;
};
export function Multiselect<TFieldValues extends FieldValues = FieldValues>({
type,
required,
name,
control,
options,
...rest
}: MultiselectProps<TFieldValues>): JSX.Element {
return (
<Controller
name={name}
control={control}
render={({ field: { value, ...field }, fieldState: { error } }) => (
<$Multiselect
{...rest}
{...field}
options={options}
value={value || []}
name={name}
required={required}
type={type}
error={!!error}
helperText={rest.helperText}
/>
)}
/>
);
}

View file

@ -0,0 +1,3 @@
Bindings for Macaw, code based on https://github.com/jsun969/react-hook-form-antd/
Components will be moved to the separate library inside apps monorepo.

View file

@ -0,0 +1,27 @@
import { Box, Text } from "@saleor/macaw-ui/next";
interface SectionWithDescriptionProps {
title: string;
description?: React.ReactNode;
children?: React.ReactNode;
}
export const SectionWithDescription = (props: SectionWithDescriptionProps) => {
return (
<Box display="grid" gridTemplateColumns={{ desktop: 3, mobile: 1 }} gap={6}>
<Box>
<Text variant="heading" display="block">
{props.title}
</Text>
{props.description}
</Box>
{!!props.children && (
<Box
gridColumnStart={{ desktop: "2", mobile: "1" }}
gridColumnEnd={{ desktop: "4", mobile: "1" }}
>
{props.children}
</Box>
)}
</Box>
);
};

View file

@ -0,0 +1,35 @@
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
import { TextProps, Text } from "@saleor/macaw-ui/next";
import Link from "next/link";
interface TextLinkProps extends TextProps {
href: string;
openNewTab?: boolean;
}
export const TextLink = ({ href, openNewTab, children, props }: TextLinkProps) => {
const { appBridge } = useAppBridge();
const onClick = (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
event.preventDefault();
appBridge?.dispatch(
actions.Redirect({
to: href,
newContext: true,
})
);
};
return (
<Text textDecoration="underline" variant="bodyStrong" {...props}>
{openNewTab ? (
<a href={href} target="_blank" rel="noopener noreferrer" onClick={onClick}>
{children}
</a>
) : (
<Link href={href}>{children}</Link>
)}
</Text>
);
};

View file

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

View file

@ -1,4 +1,12 @@
export const getBaseUrl = (headers: { [name: string]: string | string[] | undefined }): string => {
const { host, "x-forwarded-proto": protocol = "http" } = headers;
const { host, "x-forwarded-proto": xForwardedProto = "http" } = headers;
const xForwardedProtos = Array.isArray(xForwardedProto)
? xForwardedProto.join(",")
: xForwardedProto;
const protocols = xForwardedProtos.split(",");
// prefer https over other protocols
const protocol = protocols.find((el) => el === "https") || protocols[0];
return `${protocol}://${host}`;
};

View file

@ -0,0 +1,38 @@
import { TRPCClientErrorLike } from "@trpc/client";
import { AppRouter } from "../modules/trpc/trpc-app-router";
import { FieldValues, UseFormSetError } from "react-hook-form";
type SetBackendErrorsProps<T extends FieldValues = FieldValues> = {
error: TRPCClientErrorLike<AppRouter>;
setError?: UseFormSetError<T>;
notifyError: (title: string, text?: string, apiMessage?: string) => void;
};
export function setBackendErrors<T extends FieldValues = FieldValues>({
error,
setError,
notifyError,
}: SetBackendErrorsProps<T>) {
let isFieldErrorSet = false;
const fieldErrors = error.data?.zodError?.fieldErrors || {};
for (const fieldName in fieldErrors) {
for (const message of fieldErrors[fieldName] || []) {
isFieldErrorSet = true;
if (!!setError) {
setError(fieldName as keyof UseFormSetError<T>, {
type: "manual",
message,
});
}
}
}
const formErrors = error.data?.zodError?.formErrors || [];
const formErrorMessage = formErrors.length ? formErrors.join("\n") : undefined;
notifyError(
"Could not save the configuration",
isFieldErrorSet ? "Submitted form contain errors" : "Error saving configuration",
formErrorMessage
);
}

View file

@ -1,48 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { AppBridgeState } from "@saleor/app-sdk/app-bridge";
import { render, waitFor } from "@testing-library/react";
import { ThemeSynchronizer } from "./theme-synchronizer";
const appBridgeState: AppBridgeState = {
ready: true,
token: "token",
domain: "some-domain.saleor.cloud",
theme: "dark",
path: "/",
locale: "en",
id: "app-id",
saleorApiUrl: "https://some-domain.saleor.cloud/graphql/",
};
const mockThemeChange = vi.fn();
vi.mock("@saleor/app-sdk/app-bridge", () => {
return {
useAppBridge() {
return {
appBridgeState: appBridgeState,
};
},
};
});
vi.mock("@saleor/macaw-ui", () => {
return {
useTheme() {
return {
setTheme: mockThemeChange,
themeType: "light",
};
},
};
});
describe("ThemeSynchronizer", () => {
it("Updates MacawUI theme when AppBridgeState theme changes", () => {
render(<ThemeSynchronizer />);
return waitFor(() => {
expect(mockThemeChange).toHaveBeenCalledWith("dark");
});
});
});

View file

@ -0,0 +1,133 @@
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import { BoxWithBorder } from "../../../components/box-with-border";
import { BoxFooter } from "../../../components/box-footer";
import { defaultPadding } from "../../../components/ui-defaults";
import { useRouter } from "next/router";
import { ChipText } from "../../../components/chip-text";
import Image from "next/image";
import sendgrid from "../../../public/sendgrid.png";
import smtp from "../../../public/smtp.svg";
import { smtpUrls } from "../../smtp/urls";
import { sendgridUrls } from "../../sendgrid/urls";
import { appUrls } from "../urls";
import React from "react";
const NoExistingConfigurations = () => {
const { replace } = useRouter();
const redirectToProvidersSelection = () => {
replace("/configuration/choose-provider");
};
return (
<BoxWithBorder padding={10} display="grid" alignItems="center" justifyContent="center">
<Text>No providers configured yet</Text>
<Button onClick={redirectToProvidersSelection}>Add first provider</Button>
</BoxWithBorder>
);
};
type ProviderType = "sendgrid" | "smtp";
const providerLabels: Record<ProviderType, string> = {
sendgrid: "Sendgrid",
smtp: "SMTP",
};
export type ConfigurationListItem = {
id: string;
name: string;
active: boolean;
provider: ProviderType;
};
interface MessagingProvidersSectionProps {
configurations: ConfigurationListItem[];
isLoading: boolean;
}
export const MessagingProvidersBox = ({
configurations,
isLoading: loading,
}: MessagingProvidersSectionProps) => {
const { push } = useRouter();
if (loading) {
return (
<BoxWithBorder padding={10} display="grid" alignItems="center" justifyContent="center">
<Text>Loading</Text>
</BoxWithBorder>
);
}
if (configurations.length === 0) {
return <NoExistingConfigurations />;
}
const redirectToProvidersSelection = () => {
push(appUrls.chooseProvider());
};
const getEditLink = (configuration: ConfigurationListItem) => {
switch (configuration.provider) {
case "smtp":
return smtpUrls.configuration(configuration.id);
case "sendgrid":
return sendgridUrls.configuration(configuration.id);
}
};
const getProviderLogo = (configuration: ConfigurationListItem) => {
switch (configuration.provider) {
case "smtp":
return <Image alt="SMTP logo" src={smtp} height={20} width={20} />;
case "sendgrid":
return <Image alt="Sendgrid logo" src={sendgrid} height={20} width={20} />;
}
};
return (
<BoxWithBorder>
<Box padding={defaultPadding} display="grid" gridTemplateColumns={4} gap={defaultPadding}>
<Text variant="caption" color="textNeutralSubdued">
Provider
</Text>
<Text variant="caption" color="textNeutralSubdued">
Configuration name
</Text>
<Text variant="caption" color="textNeutralSubdued">
Status
</Text>
<Box />
{configurations.map((configuration) => (
<React.Fragment key={configuration.id}>
<Box display="flex" gap={defaultPadding}>
{getProviderLogo(configuration)}
<Text>{providerLabels[configuration.provider]}</Text>
</Box>
<Text>{configuration.name}</Text>
<ChipText
content={configuration.active ? "Active" : "Inactive"}
variant={configuration.active ? "success" : "error"}
/>
<Box display="flex" justifyContent="flex-end">
<Button
variant="tertiary"
size="small"
onClick={() => {
push(getEditLink(configuration));
}}
>
Edit
</Button>
</Box>
</React.Fragment>
))}
</Box>
<BoxFooter>
<Button onClick={redirectToProvidersSelection}>Add provider</Button>
</BoxFooter>
</BoxWithBorder>
);
};

View file

@ -11,24 +11,14 @@ interface ProviderSelectionBoxProps {
export const ProviderSelectionBox = (props: ProviderSelectionBoxProps) => {
return (
<BoxWithBorder display={"grid"} alignItems={"center"} justifyContent={"center"}>
<Box
padding={defaultPadding}
display={"grid"}
alignItems={"center"}
justifyContent={"center"}
>
<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"}
>
<Box padding={defaultPadding} display="grid" alignItems="center" justifyContent="center">
<Text>{props.providerDescription}</Text>
</Box>
<BoxFooter justifyContent={"center"} alignItems={"center"}>
<BoxFooter justifyContent="center" alignItems="center">
<Button onClick={props.onClick}>Choose</Button>
</BoxFooter>
</BoxWithBorder>

View file

@ -1,4 +1,4 @@
export const channelUrls = {
configuration: (id?: string) =>
id ? `/configuration/channels/${id}` : "/configuration/channels",
export const appUrls = {
configuration: () => "/configuration/",
chooseProvider: () => "/configuration/choose-provider",
};

View file

@ -0,0 +1,19 @@
import { z } from "zod";
export const channelMode = ["exclude", "restrict"] as const;
export const channelConfigurationSchema = z.object({
override: z.boolean().default(false),
channels: z.array(z.string()).default([]),
mode: z.enum(channelMode).default("restrict"),
});
export type ChannelConfiguration = z.infer<typeof channelConfigurationSchema>;
export const updateChannelsInputSchema = channelConfigurationSchema.merge(
z.object({
id: z.string().min(1),
})
);
export type UpdateChannelsInput = z.infer<typeof updateChannelsInputSchema>;

View file

@ -0,0 +1,95 @@
import { vi, expect, describe, it } from "vitest";
import { isAvailableInChannel } from "./is-available-in-channel";
describe("isAvailableInChannel", function () {
it("Passes when no overrides are set", () => {
expect(
isAvailableInChannel({
channel: "default-channel",
channelConfiguration: {
channels: [],
mode: "restrict",
override: false,
},
})
).toEqual(true);
});
describe("Restrict mode", () => {
it("Fails if no channel is specified", () => {
expect(
isAvailableInChannel({
channel: "default-channel",
channelConfiguration: {
channels: [],
mode: "restrict",
override: true,
},
})
).toEqual(false);
});
it("Fails if tested channel is not on the list", () => {
expect(
isAvailableInChannel({
channel: "default-channel",
channelConfiguration: {
channels: ["another-channel"],
mode: "restrict",
override: true,
},
})
).toEqual(false);
});
it("Passes if tested channel is on the list", () => {
expect(
isAvailableInChannel({
channel: "default-channel",
channelConfiguration: {
channels: ["default-channel"],
mode: "restrict",
override: true,
},
})
).toEqual(true);
});
});
describe("Exclude mode", () => {
it("Passes if no channel is specified", () => {
expect(
isAvailableInChannel({
channel: "default-channel",
channelConfiguration: {
channels: [],
mode: "exclude",
override: true,
},
})
).toEqual(true);
});
it("Passes if other channels are specified", () => {
expect(
isAvailableInChannel({
channel: "default-channel",
channelConfiguration: {
channels: ["other-channel", "different-channel"],
mode: "exclude",
override: true,
},
})
).toEqual(true);
});
it("Fails if channel is on the list", () => {
expect(
isAvailableInChannel({
channel: "default-channel",
channelConfiguration: {
channels: ["default-channel", "different-channel"],
mode: "exclude",
override: true,
},
})
).toEqual(false);
});
});
});

View file

@ -1,7 +1,8 @@
import { ChannelConfiguration } from "./channel-configuration-schema";
interface IsAvailableInChannelArgs {
channel: string;
restrictedToChannels: string[];
excludedChannels: string[];
channelConfiguration: ChannelConfiguration;
}
/**
@ -14,14 +15,13 @@ interface IsAvailableInChannelArgs {
*/
export const isAvailableInChannel = ({
channel,
restrictedToChannels,
excludedChannels,
channelConfiguration,
}: IsAvailableInChannelArgs): boolean => {
if (channel in excludedChannels) {
return false;
if (!channelConfiguration.override) {
return true;
}
if (restrictedToChannels.length > 0 && !(channel in restrictedToChannels)) {
return false;
if (channelConfiguration.mode === "restrict") {
return channelConfiguration.channels.includes(channel);
}
return true;
return !channelConfiguration.channels.includes(channel);
};

View file

@ -0,0 +1,52 @@
import { ChannelConfiguration } from "../channel-configuration-schema";
import { Text } from "@saleor/macaw-ui/next";
interface OverrideMessageArgs {
availableChannels: string[];
channelConfiguration: ChannelConfiguration;
}
export const AssignedChannelsMessage = ({
availableChannels,
channelConfiguration: { channels, mode, override },
}: OverrideMessageArgs) => {
if (!override) {
return (
<Text>
Configuration will be used with <Text variant="bodyStrong"> all</Text> channels.
</Text>
);
}
if (mode === "exclude") {
const leftChannels = availableChannels.filter((channel) => !channels.includes(channel));
if (!leftChannels.length) {
return (
<Text variant="bodyStrong">
Theres no channel which will be used with this configuration.
</Text>
);
}
return (
<Text>
Configuration will be used with channels:{" "}
<Text variant="bodyStrong">{leftChannels.join(", ")}</Text>.
</Text>
);
}
if (channels.length === 0) {
return (
<Text>
<Text variant="bodyStrong">No channels assigned. The configuration will not be used!</Text>
</Text>
);
}
return (
<Text>
Configuration will be used with channels:{" "}
<Text variant="bodyStrong">{channels.join(", ")}</Text>.
</Text>
);
};

View file

@ -0,0 +1,118 @@
import { BoxWithBorder } from "../../../components/box-with-border";
import { Box, Button, ProductsIcons, Switch, TableEditIcon, Text } from "@saleor/macaw-ui/next";
import { defaultPadding } from "../../../components/ui-defaults";
import { trpcClient } from "../../trpc/trpc-client";
import { Controller, useForm } from "react-hook-form";
import { BoxFooter } from "../../../components/box-footer";
import { SectionWithDescription } from "../../../components/section-with-description";
import { zodResolver } from "@hookform/resolvers/zod";
import { Multiselect } from "../../../components/react-hook-form-macaw/Multiselect";
import { AssignedChannelsMessage } from "./assigned-channels-message";
import {
ChannelConfiguration,
UpdateChannelsInput,
updateChannelsInputSchema,
} from "../channel-configuration-schema";
interface UniversalChannelsSectionProps {
configurationId: string;
channelConfiguration: ChannelConfiguration;
onSubmit: (formData: UpdateChannelsInput) => void;
}
export const UniversalChannelsSection = ({
configurationId,
channelConfiguration,
onSubmit,
}: UniversalChannelsSectionProps) => {
const { handleSubmit, control, register } = useForm<UpdateChannelsInput>({
defaultValues: {
id: configurationId,
...channelConfiguration,
},
resolver: zodResolver(updateChannelsInputSchema),
});
const { data: channels } = trpcClient.channels.fetch.useQuery();
return (
<SectionWithDescription
title="Channels"
description={
<>
<Text display="block">
By default, provider will work for every channel. You can change this behavior with
excluding or including strategy.
</Text>
<Text display="block">
<Text variant="bodyStrong">Excluding</Text> - all current channels and new created
channels will work, excluding selected
</Text>
<Text display="block">
<Text variant="bodyStrong">Including</Text> - only selected channels will work, new
created channels will not work
</Text>
</>
}
>
<form
onSubmit={handleSubmit((data, event) => {
onSubmit(data);
})}
>
<BoxWithBorder>
<Box padding={defaultPadding} display="flex" flexDirection="column" gap={defaultPadding}>
<Box display="flex" flexDirection="column" gap={defaultPadding}>
<Text variant="heading">Current behaviour</Text>
<AssignedChannelsMessage
availableChannels={channels?.map((channel) => channel.slug) || []}
channelConfiguration={channelConfiguration}
/>
<Text variant="heading">Settings</Text>
<label>
<input type="checkbox" {...register("override")} />
<Text paddingLeft={defaultPadding}>Override channels</Text>
</label>
<Controller
name="mode"
control={control}
render={({ field: { onChange } }) => (
<Switch
defaultValue={channelConfiguration.mode}
__maxWidth="max-content"
onValueChange={onChange}
>
<Switch.Item id="1" value="restrict">
<TableEditIcon size="medium" />
<Text>Include</Text>
</Switch.Item>
<Switch.Item id="2" value="exclude">
<ProductsIcons size="medium" />
<Text>Exclude</Text>
</Switch.Item>
</Switch>
)}
/>
<Multiselect
control={control}
label="Channels"
size="large"
name="channels"
options={
channels?.map((channel) => ({
label: channel.name,
value: channel.slug,
})) || []
}
/>
</Box>
</Box>
<BoxFooter>
<Button type="submit">Save provider</Button>
</BoxFooter>
</BoxWithBorder>
</form>
</SectionWithDescription>
);
};

View file

@ -1,8 +1,8 @@
import { AuthData } from "@saleor/app-sdk/APL";
import { Client } from "urql";
import { createLogger } from "@saleor/apps-shared";
import { MjmlConfigurationService } from "../mjml/configuration/get-mjml-configuration.service";
import { sendMjml } from "../mjml/send-mjml";
import { SmtpConfigurationService } from "../smtp/configuration/get-smtp-configuration.service";
import { sendSmtp } from "../smtp/send-smtp";
import { SendgridConfigurationService } from "../sendgrid/configuration/get-sendgrid-configuration.service";
import { sendSendgrid } from "../sendgrid/send-sendgrid";
import { MessageEventTypes } from "./message-event-types";
@ -30,27 +30,27 @@ export const sendEventMessages = async ({
logger.debug("Function called");
const mjmlConfigurationService = new MjmlConfigurationService({
const smtpConfigurationService = new SmtpConfigurationService({
apiClient: client,
saleorApiUrl: authData.saleorApiUrl,
});
const availableMjmlConfigurations = await mjmlConfigurationService.getConfigurations({
const availableSmtpConfigurations = await smtpConfigurationService.getConfigurations({
active: true,
availableInChannel: channel,
});
for (const mjmlConfiguration of availableMjmlConfigurations) {
const mjmlStatus = await sendMjml({
for (const smtpConfiguration of availableSmtpConfigurations) {
const smtpStatus = await sendSmtp({
event,
payload,
recipientEmail,
mjmlConfiguration,
smtpConfiguration,
});
if (mjmlStatus?.errors.length) {
logger.error("MJML errors");
logger.error(mjmlStatus?.errors);
if (smtpStatus?.errors.length) {
logger.error("SMTP errors");
logger.error(smtpStatus?.errors);
}
}

View file

@ -1,142 +0,0 @@
import { messageEventTypes } from "../../event-handlers/message-event-types";
import { MjmlConfig as MjmlConfigurationRoot, MjmlConfiguration } from "./mjml-config";
import { defaultMjmlTemplates, defaultMjmlSubjectTemplates } from "../default-templates";
import { generateRandomId } from "../../../lib/generate-random-id";
import { isAvailableInChannel } from "../../../lib/is-available-in-channel";
export const getDefaultEventsConfiguration = (): MjmlConfiguration["events"] =>
messageEventTypes.map((eventType) => ({
active: true,
eventType: eventType,
template: defaultMjmlTemplates[eventType],
subject: defaultMjmlSubjectTemplates[eventType],
}));
export const getDefaultEmptyConfiguration = (): MjmlConfiguration => {
const defaultConfig: MjmlConfiguration = {
id: "",
active: true,
configurationName: "",
senderName: "",
senderEmail: "",
smtpHost: "",
smtpPort: "",
smtpUser: "",
smtpPassword: "",
encryption: "NONE",
events: getDefaultEventsConfiguration(),
channels: {
excludedFrom: [],
restrictedTo: [],
},
};
return defaultConfig;
};
interface GetConfigurationArgs {
id: string;
}
const getConfiguration =
(mjmlConfigRoot: MjmlConfigurationRoot | null | undefined) =>
({ id }: GetConfigurationArgs) => {
if (!mjmlConfigRoot || !mjmlConfigRoot.configurations) {
return;
}
return mjmlConfigRoot.configurations.find((c) => c.id === id);
};
export interface FilterConfigurationsArgs {
ids?: string[];
availableInChannel?: string;
active?: boolean;
}
const getConfigurations =
(mjmlConfigRoot: MjmlConfigurationRoot | null | undefined) =>
(filter: FilterConfigurationsArgs | undefined): MjmlConfiguration[] => {
if (!mjmlConfigRoot || !mjmlConfigRoot.configurations) {
return [];
}
let filtered = mjmlConfigRoot.configurations;
if (!filter) {
return filtered;
}
if (filter.ids?.length) {
filtered = filtered.filter((c) => filter?.ids?.includes(c.id));
}
if (filter.active !== undefined) {
filtered = filtered.filter((c) => c.active === filter.active);
}
if (filter.availableInChannel?.length) {
filtered = filtered.filter((c) =>
isAvailableInChannel({
channel: filter.availableInChannel!,
restrictedToChannels: c.channels.restrictedTo,
excludedChannels: c.channels.excludedFrom,
})
);
}
return filtered;
};
const createConfiguration =
(mjmlConfigRoot: MjmlConfigurationRoot | null | undefined) =>
(mjmlConfiguration: Omit<MjmlConfiguration, "id" | "events">) => {
const mjmlConfigNormalized = structuredClone(mjmlConfigRoot) ?? { configurations: [] };
// for creating a new configurations, the ID has to be generated
const newConfiguration = {
...mjmlConfiguration,
id: generateRandomId(),
events: getDefaultEventsConfiguration(),
};
mjmlConfigNormalized.configurations.push(newConfiguration);
return mjmlConfigNormalized;
};
const updateConfiguration =
(mjmlConfig: MjmlConfigurationRoot | null | undefined) =>
(mjmlConfiguration: MjmlConfiguration) => {
const mjmlConfigNormalized = structuredClone(mjmlConfig) ?? { configurations: [] };
const configurationIndex = mjmlConfigNormalized.configurations.findIndex(
(configuration) => configuration.id === mjmlConfiguration.id
);
mjmlConfigNormalized.configurations[configurationIndex] = mjmlConfiguration;
return mjmlConfigNormalized;
};
interface DeleteConfigurationArgs {
id: string;
}
const deleteConfiguration =
(mjmlConfig: MjmlConfigurationRoot | null | undefined) =>
({ id }: DeleteConfigurationArgs) => {
const mjmlConfigNormalized = structuredClone(mjmlConfig) ?? { configurations: [] };
mjmlConfigNormalized.configurations = mjmlConfigNormalized.configurations.filter(
(configuration) => configuration.id !== id
);
return mjmlConfigNormalized;
};
export const MjmlConfigContainer = {
createConfiguration,
getConfiguration,
updateConfiguration,
deleteConfiguration,
getConfigurations,
};

View file

@ -1,56 +0,0 @@
import { z } from "zod";
import { messageEventTypes } from "../../event-handlers/message-event-types";
import { smtpEncryptionTypes } from "./mjml-config";
export const mjmlConfigurationEventObjectSchema = z.object({
active: z.boolean(),
eventType: z.enum(messageEventTypes),
template: z.string().min(1),
subject: z.string().min(1),
});
export const mjmlConfigurationBaseObjectSchema = z.object({
active: z.boolean(),
configurationName: z.string().min(1),
senderName: z.string().min(1),
senderEmail: z.string().email().min(5),
smtpHost: z.string().min(1),
smtpPort: z.string(),
smtpUser: z.string(),
smtpPassword: z.string(),
encryption: z.enum(smtpEncryptionTypes),
channels: z.object({
excludedFrom: z.array(z.string()),
restrictedTo: z.array(z.string()),
}),
});
export const mjmlCreateConfigurationSchema = mjmlConfigurationBaseObjectSchema;
export const mjmlUpdateOrCreateConfigurationSchema = mjmlConfigurationBaseObjectSchema.merge(
z.object({
id: z.string().optional(),
})
);
export const mjmlGetConfigurationInputSchema = z.object({
id: z.string(),
});
export const mjmlDeleteConfigurationInputSchema = z.object({
id: z.string(),
});
export const mjmlGetConfigurationsInputSchema = z
.object({
ids: z.array(z.string()).optional(),
active: z.boolean().optional(),
})
.optional();
export const mjmlUpdateEventConfigurationInputSchema = z
.object({
configurationId: z.string(),
})
.merge(mjmlConfigurationEventObjectSchema);
export const mjmlGetEventConfigurationInputSchema = z.object({
configurationId: z.string(),
eventType: z.enum(messageEventTypes),
});

View file

@ -1,34 +0,0 @@
import { MessageEventTypes } from "../../event-handlers/message-event-types";
export interface MjmlEventConfiguration {
active: boolean;
eventType: MessageEventTypes;
template: string;
subject: string;
}
export const smtpEncryptionTypes = ["NONE", "TLS", "SSL"] as const;
export type SmtpEncryptionType = (typeof smtpEncryptionTypes)[number];
export interface MjmlConfiguration {
id: string;
active: boolean;
configurationName: string;
channels: {
excludedFrom: string[];
restrictedTo: string[];
};
senderName: string;
senderEmail: string;
smtpHost: string;
smtpPort: string;
smtpUser: string;
smtpPassword: string;
encryption: SmtpEncryptionType;
events: MjmlEventConfiguration[];
}
export type MjmlConfig = {
configurations: MjmlConfiguration[];
};

View file

@ -1,7 +0,0 @@
import { MessageEventTypes } from "../event-handlers/message-event-types";
export const mjmlUrls = {
configuration: (id?: string) => (!id ? "/configuration/mjml" : `/configuration/mjml/${id}`),
eventConfiguration: (id: string, event: MessageEventTypes) =>
`/configuration/mjml/${id}/event/${event}`,
};

View file

@ -1,7 +1,7 @@
import { SendgridConfigurator, PrivateMetadataSendgridConfigurator } from "./sendgrid-configurator";
import { Client } from "urql";
import { createLogger } from "@saleor/apps-shared";
import { SendgridConfig, SendgridConfiguration } from "./sendgrid-config";
import { SendgridConfig, SendgridConfiguration } from "./sendgrid-config-schema";
import { FilterConfigurationsArgs, SendgridConfigContainer } from "./sendgrid-config-container";
import { createSettingsManager } from "../../../lib/metadata-manager";

View file

@ -1,33 +1,27 @@
import { generateRandomId } from "../../../lib/generate-random-id";
import { isAvailableInChannel } from "../../../lib/is-available-in-channel";
import { isAvailableInChannel } from "../../channels/is-available-in-channel";
import { messageEventTypes } from "../../event-handlers/message-event-types";
import {
SendgridConfig as SendgridConfigurationRoot,
SendgridConfiguration,
} from "./sendgrid-config";
sendgridConfigurationEventSchema,
sendgridConfigurationSchema,
} from "./sendgrid-config-schema";
export const getDefaultEventsConfiguration = (): SendgridConfiguration["events"] =>
messageEventTypes.map((eventType) => ({
active: true,
eventType: eventType,
template: "",
}));
messageEventTypes.map((eventType) => sendgridConfigurationEventSchema.parse({ eventType }));
export const getDefaultEmptyConfiguration = (): SendgridConfiguration => {
const defaultConfig: SendgridConfiguration = {
id: "",
active: true,
configurationName: "",
senderName: undefined,
senderEmail: undefined,
apiKey: "",
sandboxMode: false,
events: getDefaultEventsConfiguration(),
const defaultConfig: SendgridConfiguration = sendgridConfigurationSchema.parse({
id: "id",
name: "name",
apiKey: "key",
channels: {
excludedFrom: [],
restrictedTo: [],
},
};
events: getDefaultEventsConfiguration(),
});
return defaultConfig;
};
@ -77,8 +71,7 @@ const getConfigurations =
filtered = filtered.filter((c) =>
isAvailableInChannel({
channel: filter.availableInChannel!,
restrictedToChannels: c.channels.restrictedTo,
excludedChannels: c.channels.excludedFrom,
channelConfiguration: c.channels,
})
);
}

View file

@ -1,41 +1,26 @@
import { z } from "zod";
import { messageEventTypes } from "../../event-handlers/message-event-types";
import {
sendgridConfigurationEventSchema,
sendgridConfigurationSchema,
} from "./sendgrid-config-schema";
import { channelConfigurationSchema } from "../../channels/channel-configuration-schema";
export const sendgridConfigurationEventObjectSchema = z.object({
active: z.boolean(),
eventType: z.enum(messageEventTypes),
template: z.string(),
export const sendgridCreateConfigurationInputSchema = sendgridConfigurationSchema.pick({
name: true,
apiKey: true,
});
export const sendgridConfigurationBaseObjectSchema = z.object({
active: z.boolean(),
configurationName: z.string().min(1),
sandboxMode: z.boolean(),
apiKey: z.string().min(1),
senderName: z.string().min(1).optional(),
senderEmail: z.string().email().min(5).optional(),
channels: z.object({
excludedFrom: z.array(z.string()),
restrictedTo: z.array(z.string()),
}),
export type SendgridCreateConfigurationInput = z.infer<
typeof sendgridCreateConfigurationInputSchema
>;
export const sendgridConfigurationIdInputSchema = sendgridConfigurationSchema.pick({
id: true,
});
export const sendgridCreateConfigurationSchema = sendgridConfigurationBaseObjectSchema.omit({
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 type SendgridGetConfigurationIdInput = z.infer<typeof sendgridConfigurationIdInputSchema>;
export const sendgridGetConfigurationsInputSchema = z
.object({
ids: z.array(z.string()).optional(),
@ -43,13 +28,61 @@ export const sendgridGetConfigurationsInputSchema = z
})
.optional();
export type SendgridGetConfigurationsInput = z.infer<typeof sendgridGetConfigurationsInputSchema>;
export const sendgridUpdateEventConfigurationInputSchema = z
.object({
configurationId: z.string(),
})
.merge(sendgridConfigurationEventObjectSchema);
.merge(sendgridConfigurationEventSchema);
export type SendgridUpdateEventConfigurationInput = z.infer<
typeof sendgridUpdateEventConfigurationInputSchema
>;
export const sendgridGetEventConfigurationInputSchema = z.object({
configurationId: z.string(),
eventType: z.enum(messageEventTypes),
});
export type SendgridGetEventConfigurationInput = z.infer<
typeof sendgridGetEventConfigurationInputSchema
>;
export const sendgridUpdateBasicInformationSchema = sendgridConfigurationSchema.pick({
id: true,
name: true,
active: true,
});
export type SendgridUpdateBasicInformation = z.infer<typeof sendgridUpdateBasicInformationSchema>;
export const sendgridUpdateApiConnectionSchema = sendgridConfigurationSchema.pick({
id: true,
apiKey: true,
sandboxMode: true,
});
export type SendgridUpdateApiConnection = z.infer<typeof sendgridUpdateApiConnectionSchema>;
export const sendgridUpdateSenderSchema = sendgridConfigurationSchema.pick({
id: true,
sender: true,
});
export type SendgridUpdateSender = z.infer<typeof sendgridUpdateSenderSchema>;
export const sendgridUpdateChannelsSchema = channelConfigurationSchema.merge(
sendgridConfigurationSchema.pick({
id: true,
})
);
export type SendgridUpdateChannels = z.infer<typeof sendgridUpdateChannelsSchema>;
export const sendgridUpdateEventSchema = sendgridConfigurationEventSchema.merge(
sendgridConfigurationSchema.pick({
id: true,
})
);
export type SendgridUpdateEvent = z.infer<typeof sendgridUpdateEventSchema>;

View file

@ -0,0 +1,32 @@
import { z } from "zod";
import { messageEventTypes } from "../../event-handlers/message-event-types";
import { channelConfigurationSchema } from "../../channels/channel-configuration-schema";
export const sendgridConfigurationEventSchema = z.object({
active: z.boolean().default(false),
eventType: z.enum(messageEventTypes),
template: z.string().optional(),
});
export type SendgridEventConfiguration = z.infer<typeof sendgridConfigurationEventSchema>;
export const sendgridConfigurationSchema = z.object({
id: z.string().min(1),
active: z.boolean().default(true),
name: z.string().min(1),
sandboxMode: z.boolean().default(false),
apiKey: z.string().min(1),
sender: z.string().min(1).optional(),
senderEmail: z.string().email().optional(),
senderName: z.string().optional(),
channels: channelConfigurationSchema,
events: z.array(sendgridConfigurationEventSchema),
});
export type SendgridConfiguration = z.infer<typeof sendgridConfigurationSchema>;
export const sendgridConfigSchema = z.object({
configurations: z.array(sendgridConfigurationSchema),
});
export type SendgridConfig = z.infer<typeof sendgridConfigSchema>;

View file

@ -1,26 +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[];
channels: {
excludedFrom: string[];
restrictedTo: string[];
};
}
export type SendgridConfig = {
configurations: SendgridConfiguration[];
};

View file

@ -1,17 +1,22 @@
import { createLogger } from "@saleor/apps-shared";
import {
sendgridCreateConfigurationSchema,
sendgridDeleteConfigurationInputSchema,
sendgridGetConfigurationInputSchema,
sendgridConfigurationIdInputSchema,
sendgridCreateConfigurationInputSchema,
sendgridGetConfigurationsInputSchema,
sendgridGetEventConfigurationInputSchema,
sendgridUpdateApiConnectionSchema,
sendgridUpdateBasicInformationSchema,
sendgridUpdateEventConfigurationInputSchema,
sendgridUpdateOrCreateConfigurationSchema,
sendgridUpdateEventSchema,
sendgridUpdateSenderSchema,
} from "./sendgrid-config-input-schema";
import { SendgridConfigurationService } from "./get-sendgrid-configuration.service";
import { router } from "../../trpc/trpc-server";
import { protectedClientProcedure } from "../../trpc/protected-client-procedure";
import { TRPCError } from "@trpc/server";
import { getDefaultEmptyConfiguration } from "./sendgrid-config-container";
import { fetchSenders } from "../sendgrid-api";
import { updateChannelsInputSchema } from "../../channels/channel-configuration-schema";
/*
* Allow access only for the dashboard users and attaches the
@ -38,7 +43,7 @@ export const sendgridConfigurationRouter = router({
}),
getConfiguration: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(sendgridGetConfigurationInputSchema)
.input(sendgridConfigurationIdInputSchema)
.query(async ({ ctx, input }) => {
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
@ -56,16 +61,21 @@ export const sendgridConfigurationRouter = router({
}),
createConfiguration: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(sendgridCreateConfigurationSchema)
.input(sendgridCreateConfigurationInputSchema)
.mutation(async ({ ctx, input }) => {
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug(input, "sendgridConfigurationRouter.create called");
return await ctx.configurationService.createConfiguration(input);
const newConfiguration = {
...getDefaultEmptyConfiguration(),
...input,
};
return await ctx.configurationService.createConfiguration(newConfiguration);
}),
deleteConfiguration: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(sendgridDeleteConfigurationInputSchema)
.input(sendgridConfigurationIdInputSchema)
.mutation(async ({ ctx, input }) => {
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
@ -81,37 +91,6 @@ export const sendgridConfigurationRouter = router({
await ctx.configurationService.deleteConfiguration(input);
return null;
}),
updateOrCreateConfiguration: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(sendgridUpdateOrCreateConfigurationSchema)
.mutation(async ({ ctx, input }) => {
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug(input, "sendgridConfigurationRouter.update or create called");
const { id } = input;
if (!id) {
return await ctx.configurationService.createConfiguration(input);
} else {
const existingConfiguration = await ctx.configurationService.getConfiguration({ id });
if (!existingConfiguration) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Configuration not found",
});
}
const configuration = {
id,
...input,
events: existingConfiguration.events,
};
await ctx.configurationService.updateConfiguration(configuration);
return configuration;
}
}),
getEventConfiguration: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(sendgridGetEventConfigurationInputSchema)
@ -167,6 +146,130 @@ export const sendgridConfigurationRouter = router({
eventType: input.eventType,
template: input.template,
};
await ctx.configurationService.updateConfiguration(configuration);
return configuration;
}),
updateBasicInformation: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(sendgridUpdateBasicInformationSchema)
.mutation(async ({ ctx, input }) => {
const configuration = await ctx.configurationService.getConfiguration({
id: input.id,
});
if (!configuration) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Configuration not found",
});
}
await ctx.configurationService.updateConfiguration({ ...configuration, ...input });
return configuration;
}),
updateApiConnection: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(sendgridUpdateApiConnectionSchema)
.mutation(async ({ ctx, input }) => {
const configuration = await ctx.configurationService.getConfiguration({
id: input.id,
});
if (!configuration) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Configuration not found",
});
}
await ctx.configurationService.updateConfiguration({ ...configuration, ...input });
return configuration;
}),
updateSender: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(sendgridUpdateSenderSchema)
.mutation(async ({ ctx, input }) => {
const configuration = await ctx.configurationService.getConfiguration({
id: input.id,
});
if (!configuration) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Configuration not found",
});
}
// Pull fresh sender data from the API
const senders = await fetchSenders({ apiKey: configuration.apiKey })();
const chosenSender = senders.find((s) => s.value === input.sender);
if (!chosenSender) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Sender does not exist",
});
}
await ctx.configurationService.updateConfiguration({
...configuration,
...input,
senderEmail: chosenSender.from_email,
senderName: chosenSender.label,
});
return configuration;
}),
updateChannels: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(updateChannelsInputSchema)
.mutation(async ({ ctx, input }) => {
const configuration = await ctx.configurationService.getConfiguration({
id: input.id,
});
if (!configuration) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Configuration not found",
});
}
configuration.channels = {
override: input.override,
channels: input.channels,
mode: input.mode,
};
await ctx.configurationService.updateConfiguration(configuration);
return configuration;
}),
updateEvent: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(sendgridUpdateEventSchema)
.mutation(async ({ ctx, input }) => {
const configuration = await ctx.configurationService.getConfiguration({
id: input.id,
});
if (!configuration) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Configuration not found",
});
}
const event = configuration.events.find((e) => e.eventType === input.eventType);
if (!event) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Configuration event not found",
});
}
event.template = input.template;
event.active = input.active;
await ctx.configurationService.updateConfiguration(configuration);
return configuration;
}),

View file

@ -1,4 +1,4 @@
import { SendgridConfig } from "./sendgrid-config";
import { SendgridConfig } from "./sendgrid-config-schema";
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
export interface SendgridConfigurator {

View file

@ -1,5 +1,5 @@
import { createLogger } from "@saleor/apps-shared";
import { SendgridConfiguration } from "./configuration/sendgrid-config";
import { SendgridConfiguration } from "./configuration/sendgrid-config-schema";
import { MailService } from "@sendgrid/mail";
import { MessageEventTypes } from "../event-handlers/message-event-types";

View file

@ -30,7 +30,7 @@ export const fetchTemplates =
};
const templates =
resJson.result?.map((r) => ({
value: r.id,
value: r.id.toString(),
label: r.name,
})) || [];
return templates;
@ -65,7 +65,7 @@ export const fetchSenders =
};
const senders =
resJson.results?.map((r) => ({
value: r.id,
value: r.id.toString(),
label: `${r.nickname} (${r.from_email})`,
nickname: r.nickname,
from_email: r.from_email,

View file

@ -0,0 +1,79 @@
import { SendgridConfiguration } from "../configuration/sendgrid-config-schema";
import { BoxWithBorder } from "../../../components/box-with-border";
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import { defaultPadding } from "../../../components/ui-defaults";
import { useDashboardNotification } from "@saleor/apps-shared";
import { trpcClient } from "../../trpc/trpc-client";
import {
SendgridUpdateApiConnection,
sendgridUpdateApiConnectionSchema,
} from "../configuration/sendgrid-config-input-schema";
import { useForm } from "react-hook-form";
import { BoxFooter } from "../../../components/box-footer";
import { SectionWithDescription } from "../../../components/section-with-description";
import { Input } from "../../../components/react-hook-form-macaw/Input";
import { zodResolver } from "@hookform/resolvers/zod";
import { setBackendErrors } from "../../../lib/set-backend-errors";
interface ApiConnectionSectionProps {
configuration: SendgridConfiguration;
}
export const ApiConnectionSection = ({ configuration }: ApiConnectionSectionProps) => {
const { notifySuccess, notifyError } = useDashboardNotification();
const { handleSubmit, control, setError, register } = useForm<SendgridUpdateApiConnection>({
defaultValues: {
id: configuration.id,
apiKey: configuration.apiKey,
sandboxMode: configuration.sandboxMode,
},
resolver: zodResolver(sendgridUpdateApiConnectionSchema),
});
const trpcContext = trpcClient.useContext();
const { mutate } = trpcClient.sendgridConfiguration.updateApiConnection.useMutation({
onSuccess: async (data, variables) => {
notifySuccess("Configuration saved");
trpcContext.sendgridConfiguration.invalidate();
},
onError(error) {
setBackendErrors<SendgridUpdateApiConnection>({
error,
setError,
notifyError,
});
},
});
return (
<SectionWithDescription title="API Connection">
<BoxWithBorder>
<form
onSubmit={handleSubmit((data, event) => {
mutate({
...data,
});
})}
>
<Box padding={defaultPadding} display="flex" flexDirection="column" gap={10}>
<Input
label="API Key"
name="apiKey"
control={control}
helperText="Name of the configuration, for example 'Production' or 'Test'"
/>
<label>
<input type="checkbox" {...register("sandboxMode")} />
<Text paddingLeft={defaultPadding}>Sandbox mode</Text>
</label>
</Box>
<BoxFooter>
<Button type="submit">Save provider</Button>
</BoxFooter>
</form>
</BoxWithBorder>
</SectionWithDescription>
);
};

View file

@ -0,0 +1,88 @@
import { SendgridConfiguration } from "../configuration/sendgrid-config-schema";
import { BoxWithBorder } from "../../../components/box-with-border";
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import { defaultPadding } from "../../../components/ui-defaults";
import { useDashboardNotification } from "@saleor/apps-shared";
import { trpcClient } from "../../trpc/trpc-client";
import {
SendgridUpdateBasicInformation,
sendgridUpdateBasicInformationSchema,
} from "../configuration/sendgrid-config-input-schema";
import { z } from "zod";
import { Controller, useForm } from "react-hook-form";
import { BoxFooter } from "../../../components/box-footer";
import { SectionWithDescription } from "../../../components/section-with-description";
import { Input } from "../../../components/react-hook-form-macaw/Input";
import { zodResolver } from "@hookform/resolvers/zod";
import { setBackendErrors } from "../../../lib/set-backend-errors";
interface SendgridBasicInformationSectionProps {
configuration: SendgridConfiguration;
}
export const SendgridBasicInformationSection = ({
configuration,
}: SendgridBasicInformationSectionProps) => {
const { notifySuccess, notifyError } = useDashboardNotification();
const { handleSubmit, control, setError, register } = useForm<SendgridUpdateBasicInformation>({
defaultValues: {
id: configuration.id,
name: configuration.name,
active: configuration.active,
},
resolver: zodResolver(sendgridUpdateBasicInformationSchema),
});
const trpcContext = trpcClient.useContext();
const { mutate } = trpcClient.sendgridConfiguration.updateBasicInformation.useMutation({
onSuccess: async () => {
notifySuccess("Configuration saved");
trpcContext.sendgridConfiguration.invalidate();
},
onError(error) {
setBackendErrors<SendgridUpdateBasicInformation>({
error,
setError,
notifyError,
});
},
});
return (
<SectionWithDescription
title="Connect Sendgrid"
description={
<Text>
Provide unique name for your configuration - you can create more than one. For example -
production and development. Then, pass your API Key. Obtain it here.
</Text>
}
>
<BoxWithBorder>
<form
onSubmit={handleSubmit((data, event) => {
mutate({
...data,
});
})}
>
<Box padding={defaultPadding} display="flex" flexDirection="column" gap={10}>
<Input
label="Configuration name"
control={control}
name="name"
helperText="Name of the configuration, for example 'Production' or 'Test'"
/>
<label>
<input type="checkbox" placeholder="Enabled" {...register("active")} />
<Text paddingLeft={defaultPadding}>Active</Text>
</label>
</Box>
<BoxFooter>
<Button type="submit">Save provider</Button>
</BoxFooter>
</form>
</BoxWithBorder>
</SectionWithDescription>
);
};

View file

@ -0,0 +1,33 @@
import { SendgridConfiguration } from "../configuration/sendgrid-config-schema";
import { useDashboardNotification } from "@saleor/apps-shared";
import { trpcClient } from "../../trpc/trpc-client";
import { setBackendErrors } from "../../../lib/set-backend-errors";
import { UniversalChannelsSection } from "../../channels/ui/universal-channels-section";
import { UpdateChannelsInput } from "../../channels/channel-configuration-schema";
interface SendgridChannelsSectionProps {
configuration: SendgridConfiguration;
}
export const SendgridChannelsSection = ({ configuration }: SendgridChannelsSectionProps) => {
const { notifySuccess, notifyError } = useDashboardNotification();
const trpcContext = trpcClient.useContext();
const { mutate } = trpcClient.sendgridConfiguration.updateChannels.useMutation({
onSuccess: async () => {
notifySuccess("Configuration saved");
trpcContext.sendgridConfiguration.invalidate();
},
onError(error) {
setBackendErrors<UpdateChannelsInput>({ error, notifyError });
},
});
return (
<UniversalChannelsSection
configurationId={configuration.id}
channelConfiguration={configuration.channels}
onSubmit={mutate}
/>
);
};

View file

@ -0,0 +1,73 @@
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import { BoxWithBorder } from "../../../components/box-with-border";
import { SectionWithDescription } from "../../../components/section-with-description";
import { defaultPadding } from "../../../components/ui-defaults";
import { BoxFooter } from "../../../components/box-footer";
import { SendgridConfiguration } from "../configuration/sendgrid-config-schema";
import { trpcClient } from "../../trpc/trpc-client";
import { useDashboardNotification } from "@saleor/apps-shared";
import { useForm } from "react-hook-form";
import { SendgridGetConfigurationIdInput } from "../configuration/sendgrid-config-input-schema";
import { useRouter } from "next/router";
import { appUrls } from "../../app-configuration/urls";
import { setBackendErrors } from "../../../lib/set-backend-errors";
interface SendgridDangerousSectionProps {
configuration: SendgridConfiguration;
}
export const SendgridDangerousSection = ({ configuration }: SendgridDangerousSectionProps) => {
const { notifySuccess, notifyError } = useDashboardNotification();
const { replace } = useRouter();
const { handleSubmit, setError } = useForm<SendgridGetConfigurationIdInput>({
defaultValues: {
id: configuration.id,
},
});
const { mutate } = trpcClient.sendgridConfiguration.deleteConfiguration.useMutation({
onSuccess: async () => {
notifySuccess("Configuration removed");
replace(appUrls.configuration());
},
onError(error) {
setBackendErrors<SendgridGetConfigurationIdInput>({ error, setError, notifyError });
},
});
return (
<SectionWithDescription title="Danger zone">
<form
onSubmit={handleSubmit((data, event) => {
mutate({
...data,
});
})}
>
<BoxWithBorder backgroundColor="surfaceCriticalSubdued" borderColor="criticalSubdued">
<Box padding={defaultPadding}>
<Text variant="heading" display="block">
Remove provider
</Text>
<Text display="block">You can remove provider configuration.</Text>
<Text display="block">
This operation will remove all settings related to this configuration. Data will be
permanently removed from the App.{" "}
</Text>
<Text display="block">This operation cant be undone.</Text>
<Text display="block">You still can create new configuration.</Text>
</Box>
<BoxFooter borderColor="criticalSubdued">
<Button
color="textNeutralSubdued"
backgroundColor="interactiveCriticalDefault"
type="submit"
>
Remove provider
</Button>
</BoxFooter>
</BoxWithBorder>
</form>
</SectionWithDescription>
);
};

View file

@ -0,0 +1,119 @@
import {
SendgridConfiguration,
SendgridEventConfiguration,
} from "../configuration/sendgrid-config-schema";
import { BoxWithBorder } from "../../../components/box-with-border";
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import { defaultPadding } from "../../../components/ui-defaults";
import { useDashboardNotification } from "@saleor/apps-shared";
import { trpcClient } from "../../trpc/trpc-client";
import {
SendgridUpdateEvent,
sendgridUpdateEventSchema,
} from "../configuration/sendgrid-config-input-schema";
import { useForm } from "react-hook-form";
import { BoxFooter } from "../../../components/box-footer";
import { SectionWithDescription } from "../../../components/section-with-description";
import { useQuery } from "@tanstack/react-query";
import { fetchTemplates } from "../sendgrid-api";
import { zodResolver } from "@hookform/resolvers/zod";
import { Combobox } from "../../../components/react-hook-form-macaw/Combobox";
import { setBackendErrors } from "../../../lib/set-backend-errors";
interface EventBoxProps {
configuration: SendgridConfiguration;
event: SendgridEventConfiguration;
}
const EventBox = ({ event, configuration }: EventBoxProps) => {
const { notifySuccess, notifyError } = useDashboardNotification();
const { data: templatesChoices } = useQuery({
queryKey: ["sendgridTemplates"],
queryFn: fetchTemplates({ apiKey: configuration.apiKey }),
enabled: !!configuration.apiKey?.length,
});
const { handleSubmit, control, setError, register } = useForm<SendgridUpdateEvent>({
defaultValues: {
id: configuration.id,
...event,
},
resolver: zodResolver(sendgridUpdateEventSchema),
});
const trpcContext = trpcClient.useContext();
const { mutate } = trpcClient.sendgridConfiguration.updateEvent.useMutation({
onSuccess: async () => {
notifySuccess("Configuration saved");
trpcContext.sendgridConfiguration.invalidate();
},
onError(error) {
setBackendErrors<SendgridUpdateEvent>({ error, setError, notifyError });
},
});
return (
<form
onSubmit={handleSubmit((data, event) => {
mutate({
...data,
});
})}
>
<BoxWithBorder>
<Box padding={defaultPadding} display="flex" flexDirection="column" gap={defaultPadding}>
<Text variant="heading">{event.eventType}</Text>
{templatesChoices?.length ? (
<Combobox
name="template"
control={control}
label="Template"
options={templatesChoices.map((sender) => ({
label: sender.label,
value: sender.value,
}))}
/>
) : (
<Combobox name="template" control={control} label="Template" options={[]} />
)}
<label>
<input type="checkbox" placeholder="Enabled" {...register("active")} />
<Text paddingLeft={defaultPadding}>Active</Text>
</label>
</Box>
<BoxFooter>
<Button type="submit">Save event</Button>
</BoxFooter>
</BoxWithBorder>
</form>
);
};
interface SendgridEventsSectionProps {
configuration: SendgridConfiguration;
}
export const SendgridEventsSection = ({ configuration }: SendgridEventsSectionProps) => {
return (
<SectionWithDescription
title="Events"
description={
<>
<Text display="block">
Provide unique name for your configuration - you can create more than one. For example -
production and development.
</Text>
<Text display="block">Then, pass your API Key. Obtain it here.</Text>
</>
}
>
<Box display="flex" flexDirection="column" gap={defaultPadding}>
{configuration.events.map((event) => (
<EventBox key={event.eventType} configuration={configuration} event={event} />
))}
</Box>
</SectionWithDescription>
);
};

View file

@ -0,0 +1,84 @@
import { SendgridConfiguration } from "../configuration/sendgrid-config-schema";
import { BoxWithBorder } from "../../../components/box-with-border";
import { Box, Button } from "@saleor/macaw-ui/next";
import { defaultPadding } from "../../../components/ui-defaults";
import { useDashboardNotification } from "@saleor/apps-shared";
import { trpcClient } from "../../trpc/trpc-client";
import {
SendgridUpdateSender,
sendgridUpdateSenderSchema,
} from "../configuration/sendgrid-config-input-schema";
import { useForm } from "react-hook-form";
import { BoxFooter } from "../../../components/box-footer";
import { SectionWithDescription } from "../../../components/section-with-description";
import { fetchSenders } from "../sendgrid-api";
import { useQuery } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod";
import { Combobox } from "../../../components/react-hook-form-macaw/Combobox";
import { setBackendErrors } from "../../../lib/set-backend-errors";
interface SendgridSenderSectionProps {
configuration: SendgridConfiguration;
}
export const SendgridSenderSection = ({ configuration }: SendgridSenderSectionProps) => {
const { notifySuccess, notifyError } = useDashboardNotification();
const { data: sendersChoices } = useQuery({
queryKey: ["sendgridSenders"],
queryFn: fetchSenders({ apiKey: configuration.apiKey }),
enabled: !!configuration.apiKey?.length,
});
const { handleSubmit, control, setError } = useForm<SendgridUpdateSender>({
defaultValues: {
id: configuration.id,
sender: configuration.sender,
},
resolver: zodResolver(sendgridUpdateSenderSchema),
});
const trpcContext = trpcClient.useContext();
const { mutate } = trpcClient.sendgridConfiguration.updateSender.useMutation({
onSuccess: async () => {
notifySuccess("Configuration saved");
trpcContext.sendgridConfiguration.invalidate();
},
onError(error) {
setBackendErrors<SendgridUpdateSender>({ error, setError, notifyError });
},
});
return (
<SectionWithDescription title="Sender">
<BoxWithBorder>
<form
onSubmit={handleSubmit((data, event) => {
mutate({
...data,
});
})}
>
<Box padding={defaultPadding} display="flex" flexDirection="column" gap={10}>
{sendersChoices?.length ? (
<Combobox
name="sender"
control={control}
label="Sender"
options={sendersChoices.map((sender) => ({
label: sender.label,
value: sender.value,
}))}
/>
) : (
<Combobox name="sender" control={control} label="Sender" options={[]} />
)}
</Box>
<BoxFooter>
<Button type="submit">Save provider</Button>
</BoxFooter>
</form>
</BoxWithBorder>
</SectionWithDescription>
);
};

View file

@ -1,8 +1,8 @@
import { MessageEventTypes } from "../event-handlers/message-event-types";
export const sendgridUrls = {
configuration: (id?: string) =>
!id ? "/configuration/sendgrid" : `/configuration/sendgrid/${id}`,
newConfiguration: () => `/configuration/sendgrid/new`,
configuration: (id: string) => `/configuration/sendgrid/${id}`,
eventConfiguration: (id: string, event: MessageEventTypes) =>
`/configuration/sendgrid/${id}/event/${event}`,
};

View file

@ -1,20 +1,20 @@
import { MjmlConfigurator, PrivateMetadataMjmlConfigurator } from "./mjml-configurator";
import { SmtpConfigurator, PrivateMetadataSmtpConfigurator } from "./smtp-configurator";
import { Client } from "urql";
import { createLogger } from "@saleor/apps-shared";
import { MjmlConfig, MjmlConfiguration } from "./mjml-config";
import { FilterConfigurationsArgs, MjmlConfigContainer } from "./mjml-config-container";
import { FilterConfigurationsArgs, SmtpConfigContainer } from "./smtp-config-container";
import { createSettingsManager } from "../../../lib/metadata-manager";
import { SmtpConfig, SmtpConfiguration } from "./smtp-config-schema";
const logger = createLogger({
service: "MjmlConfigurationService",
service: "SmtpConfigurationService",
});
export class MjmlConfigurationService {
private configurationData?: MjmlConfig;
private metadataConfigurator: MjmlConfigurator;
export class SmtpConfigurationService {
private configurationData?: SmtpConfig;
private metadataConfigurator: SmtpConfigurator;
constructor(args: { apiClient: Client; saleorApiUrl: string; initialData?: MjmlConfig }) {
this.metadataConfigurator = new PrivateMetadataMjmlConfigurator(
constructor(args: { apiClient: Client; saleorApiUrl: string; initialData?: SmtpConfig }) {
this.metadataConfigurator = new PrivateMetadataSmtpConfigurator(
createSettingsManager(args.apiClient),
args.saleorApiUrl
);
@ -61,7 +61,7 @@ export class MjmlConfigurationService {
}
// Saves configuration to Saleor API and cache it
async setConfigurationRoot(config: MjmlConfig) {
async setConfigurationRoot(config: SmtpConfig) {
logger.debug("Set configuration root");
this.configurationData = config;
@ -70,17 +70,17 @@ export class MjmlConfigurationService {
async getConfiguration({ id }: { id: string }) {
logger.debug("Get configuration");
return MjmlConfigContainer.getConfiguration(await this.getConfigurationRoot())({ id });
return SmtpConfigContainer.getConfiguration(await this.getConfigurationRoot())({ id });
}
async getConfigurations(filter?: FilterConfigurationsArgs) {
logger.debug("Get configuration");
return MjmlConfigContainer.getConfigurations(await this.getConfigurationRoot())(filter);
return SmtpConfigContainer.getConfigurations(await this.getConfigurationRoot())(filter);
}
async createConfiguration(config: Omit<MjmlConfiguration, "id" | "events">) {
async createConfiguration(config: Omit<SmtpConfiguration, "id" | "events">) {
logger.debug("Create configuration");
const updatedConfigurationRoot = MjmlConfigContainer.createConfiguration(
const updatedConfigurationRoot = SmtpConfigContainer.createConfiguration(
await this.getConfigurationRoot()
)(config);
@ -91,9 +91,9 @@ export class MjmlConfigurationService {
];
}
async updateConfiguration(config: MjmlConfiguration) {
async updateConfiguration(config: SmtpConfiguration) {
logger.debug("Update configuration");
const updatedConfigurationRoot = MjmlConfigContainer.updateConfiguration(
const updatedConfigurationRoot = SmtpConfigContainer.updateConfiguration(
await this.getConfigurationRoot()
)(config);
@ -102,7 +102,7 @@ export class MjmlConfigurationService {
async deleteConfiguration({ id }: { id: string }) {
logger.debug("Delete configuration");
const updatedConfigurationRoot = MjmlConfigContainer.deleteConfiguration(
const updatedConfigurationRoot = SmtpConfigContainer.deleteConfiguration(
await this.getConfigurationRoot()
)({ id });

View file

@ -0,0 +1,142 @@
import { messageEventTypes } from "../../event-handlers/message-event-types";
import { defaultMjmlTemplates, defaultMjmlSubjectTemplates } from "../default-templates";
import { generateRandomId } from "../../../lib/generate-random-id";
import {
SmtpConfiguration,
smtpConfigurationSchema,
smtpConfigurationEventSchema,
SmtpConfig,
} from "./smtp-config-schema";
import { isAvailableInChannel } from "../../channels/is-available-in-channel";
export const getDefaultEventsConfiguration = (): SmtpConfiguration["events"] =>
messageEventTypes.map((eventType) =>
smtpConfigurationEventSchema.parse({
eventType: eventType,
template: defaultMjmlTemplates[eventType],
subject: defaultMjmlSubjectTemplates[eventType],
})
);
export const getDefaultEmptyConfiguration = (): SmtpConfiguration => {
const defaultConfig: SmtpConfiguration = smtpConfigurationSchema.parse({
id: "id",
name: "name",
active: true,
smtpHost: "host",
smtpPort: "1024",
channels: {
excludedFrom: [],
restrictedTo: [],
},
events: getDefaultEventsConfiguration(),
});
return defaultConfig;
};
interface GetConfigurationArgs {
id: string;
}
const getConfiguration =
(smtpConfigRoot: SmtpConfig | null | undefined) =>
({ id }: GetConfigurationArgs) => {
if (!smtpConfigRoot || !smtpConfigRoot.configurations) {
return;
}
return smtpConfigRoot.configurations.find((c) => c.id === id);
};
export interface FilterConfigurationsArgs {
ids?: string[];
availableInChannel?: string;
active?: boolean;
}
const getConfigurations =
(smtpConfigRoot: SmtpConfig | null | undefined) =>
(filter: FilterConfigurationsArgs | undefined): SmtpConfiguration[] => {
if (!smtpConfigRoot || !smtpConfigRoot.configurations) {
return [];
}
let filtered = smtpConfigRoot.configurations;
if (!filter) {
return filtered;
}
if (filter.ids?.length) {
filtered = filtered.filter((c) => filter?.ids?.includes(c.id));
}
if (filter.active !== undefined) {
filtered = filtered.filter((c) => c.active === filter.active);
}
if (filter.availableInChannel?.length) {
filtered = filtered.filter((c) =>
isAvailableInChannel({
channel: filter.availableInChannel!,
channelConfiguration: c.channels,
})
);
}
return filtered;
};
const createConfiguration =
(smtpConfigRoot: SmtpConfig | null | undefined) =>
(smtpConfiguration: Omit<SmtpConfiguration, "id" | "events">) => {
const configNormalized = structuredClone(smtpConfigRoot) ?? { configurations: [] };
// for creating a new configurations, the ID has to be generated
const newConfiguration = {
...smtpConfiguration,
id: generateRandomId(),
events: getDefaultEventsConfiguration(),
};
configNormalized.configurations.push(newConfiguration);
return configNormalized;
};
const updateConfiguration =
(smtpConfig: SmtpConfig | null | undefined) => (smtpConfiguration: SmtpConfiguration) => {
const configNormalized = structuredClone(smtpConfig) ?? { configurations: [] };
const configurationIndex = configNormalized.configurations.findIndex(
(configuration) => configuration.id === smtpConfiguration.id
);
configNormalized.configurations[configurationIndex] = smtpConfiguration;
return configNormalized;
};
interface DeleteConfigurationArgs {
id: string;
}
const deleteConfiguration =
(smtpConfig: SmtpConfig | null | undefined) =>
({ id }: DeleteConfigurationArgs) => {
const configNormalized = structuredClone(smtpConfig) ?? { configurations: [] };
configNormalized.configurations = configNormalized.configurations.filter(
(configuration) => configuration.id !== id
);
return configNormalized;
};
export const SmtpConfigContainer = {
createConfiguration,
getConfiguration,
updateConfiguration,
deleteConfiguration,
getConfigurations,
};

View file

@ -0,0 +1,89 @@
import { z } from "zod";
import { messageEventTypes } from "../../event-handlers/message-event-types";
import { smtpConfigurationEventSchema, smtpConfigurationSchema } from "./smtp-config-schema";
import { channelConfigurationSchema } from "../../channels/channel-configuration-schema";
export const smtpCreateConfigurationInputSchema = smtpConfigurationSchema.pick({
name: true,
smtpHost: true,
smtpPort: true,
smtpUser: true,
smtpPassword: true,
encryption: true,
});
export type SmtpCreateConfigurationInput = z.infer<typeof smtpCreateConfigurationInputSchema>;
export const smtpConfigurationIdInputSchema = smtpConfigurationSchema.pick({
id: true,
});
export type SmtpGetConfigurationIdInput = z.infer<typeof smtpConfigurationIdInputSchema>;
export const smtpGetConfigurationsInputSchema = z
.object({
ids: z.array(z.string()).optional(),
active: z.boolean().optional(),
})
.optional();
export type SmtpGetConfigurationsInput = z.infer<typeof smtpGetConfigurationsInputSchema>;
export const smtpUpdateBasicInformationSchema = smtpConfigurationSchema.pick({
id: true,
name: true,
active: true,
});
export type SmtpUpdateBasicInformation = z.infer<typeof smtpUpdateBasicInformationSchema>;
export const smtpUpdateSmtpSchema = smtpConfigurationSchema.pick({
id: true,
smtpHost: true,
smtpPort: true,
smtpPassword: true,
smtpUser: true,
encryption: true,
});
export type SmtpUpdateSmtp = z.infer<typeof smtpUpdateSmtpSchema>;
export const smtpUpdateSenderSchema = smtpConfigurationSchema.pick({
id: true,
senderEmail: true,
senderName: true,
});
export type SmtpUpdateSender = z.infer<typeof smtpUpdateSenderSchema>;
export const smtpUpdateChannelsSchema = channelConfigurationSchema.merge(
smtpConfigurationSchema.pick({
id: true,
})
);
export type SmtpUpdateChannels = z.infer<typeof smtpUpdateChannelsSchema>;
export const smtpUpdateEventSchema = smtpConfigurationEventSchema.merge(
smtpConfigurationSchema.pick({
id: true,
})
);
export type SmtpUpdateEvent = z.infer<typeof smtpUpdateEventSchema>;
export const smtpGetEventConfigurationInputSchema = smtpConfigurationIdInputSchema.merge(
z.object({
eventType: z.enum(messageEventTypes),
})
);
export type SmtpGetEventConfigurationInput = z.infer<typeof smtpGetEventConfigurationInputSchema>;
export const smtpUpdateEventConfigurationInputSchema = smtpConfigurationIdInputSchema.merge(
smtpConfigurationEventSchema
);
export type SmtpUpdateEventConfigurationInput = z.infer<
typeof smtpUpdateEventConfigurationInputSchema
>;

View file

@ -0,0 +1,37 @@
import { z } from "zod";
import { messageEventTypes } from "../../event-handlers/message-event-types";
import { channelConfigurationSchema } from "../../channels/channel-configuration-schema";
export const smtpEncryptionTypes = ["NONE", "TLS", "SSL"] as const;
export const smtpConfigurationEventSchema = z.object({
active: z.boolean().default(false),
eventType: z.enum(messageEventTypes),
template: z.string(),
subject: z.string(),
});
export type SmtpEventConfiguration = z.infer<typeof smtpConfigurationEventSchema>;
export const smtpConfigurationSchema = z.object({
id: z.string().min(1),
active: z.boolean().default(true),
name: z.string().min(1),
senderName: z.string().optional(),
senderEmail: z.string().email().min(5).optional(),
smtpHost: z.string().min(1),
smtpPort: z.string().min(1),
smtpUser: z.string().optional(),
smtpPassword: z.string().optional(),
encryption: z.enum(smtpEncryptionTypes).default("NONE"),
channels: channelConfigurationSchema,
events: z.array(smtpConfigurationEventSchema),
});
export type SmtpConfiguration = z.infer<typeof smtpConfigurationSchema>;
export const smtpConfigSchema = z.object({
configurations: z.array(smtpConfigurationSchema),
});
export type SmtpConfig = z.infer<typeof smtpConfigSchema>;

View file

@ -1,20 +1,24 @@
import { createLogger } from "@saleor/apps-shared";
import {
mjmlCreateConfigurationSchema,
mjmlDeleteConfigurationInputSchema,
mjmlGetConfigurationInputSchema,
mjmlGetConfigurationsInputSchema,
mjmlGetEventConfigurationInputSchema,
mjmlUpdateEventConfigurationInputSchema,
mjmlUpdateOrCreateConfigurationSchema,
} from "./mjml-config-input-schema";
import { MjmlConfigurationService } from "./get-mjml-configuration.service";
import { SmtpConfigurationService } from "./get-smtp-configuration.service";
import { router } from "../../trpc/trpc-server";
import { protectedClientProcedure } from "../../trpc/protected-client-procedure";
import { z } from "zod";
import { compileMjml } from "../compile-mjml";
import Handlebars from "handlebars";
import { TRPCError } from "@trpc/server";
import { getDefaultEmptyConfiguration } from "./smtp-config-container";
import {
smtpConfigurationIdInputSchema,
smtpCreateConfigurationInputSchema,
smtpGetConfigurationsInputSchema,
smtpGetEventConfigurationInputSchema,
smtpUpdateBasicInformationSchema,
smtpUpdateEventConfigurationInputSchema,
smtpUpdateEventSchema,
smtpUpdateSenderSchema,
smtpUpdateSmtpSchema,
} from "./smtp-config-input-schema";
import { updateChannelsInputSchema } from "../../channels/channel-configuration-schema";
/*
* Allow access only for the dashboard users and attaches the
@ -24,7 +28,7 @@ const protectedWithConfigurationService = protectedClientProcedure.use(({ next,
next({
ctx: {
...ctx,
configurationService: new MjmlConfigurationService({
configurationService: new SmtpConfigurationService({
apiClient: ctx.apiClient,
saleorApiUrl: ctx.saleorApiUrl,
}),
@ -32,47 +36,53 @@ const protectedWithConfigurationService = protectedClientProcedure.use(({ next,
})
);
export const mjmlConfigurationRouter = router({
export const smtpConfigurationRouter = router({
fetch: protectedWithConfigurationService.query(async ({ ctx }) => {
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug("mjmlConfigurationRouter.fetch called");
logger.debug("smtpConfigurationRouter.fetch called");
return ctx.configurationService.getConfigurationRoot();
}),
getConfiguration: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(mjmlGetConfigurationInputSchema)
.input(smtpConfigurationIdInputSchema)
.query(async ({ ctx, input }) => {
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug(input, "mjmlConfigurationRouter.get called");
logger.debug(input, "smtpConfigurationRouter.get called");
return ctx.configurationService.getConfiguration(input);
}),
getConfigurations: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(mjmlGetConfigurationsInputSchema)
.input(smtpGetConfigurationsInputSchema)
.query(async ({ ctx, input }) => {
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug(input, "mjmlConfigurationRouter.getConfigurations called");
logger.debug(input, "smtpConfigurationRouter.getConfigurations called");
return ctx.configurationService.getConfigurations(input);
}),
createConfiguration: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(mjmlCreateConfigurationSchema)
.input(smtpCreateConfigurationInputSchema)
.mutation(async ({ ctx, input }) => {
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug(input, "mjmlConfigurationRouter.create called");
return await ctx.configurationService.createConfiguration(input);
logger.debug(input, "smtpConfigurationRouter.create called");
const newConfiguration = {
...getDefaultEmptyConfiguration(),
...input,
};
console.log(newConfiguration, "this is newConfiguration");
return await ctx.configurationService.createConfiguration(newConfiguration);
}),
deleteConfiguration: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(mjmlDeleteConfigurationInputSchema)
.input(smtpConfigurationIdInputSchema)
.mutation(async ({ ctx, input }) => {
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug(input, "mjmlConfigurationRouter.delete called");
logger.debug(input, "smtpConfigurationRouter.delete called");
const existingConfiguration = await ctx.configurationService.getConfiguration(input);
if (!existingConfiguration) {
@ -84,47 +94,16 @@ export const mjmlConfigurationRouter = router({
await ctx.configurationService.deleteConfiguration(input);
return null;
}),
updateOrCreateConfiguration: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(mjmlUpdateOrCreateConfigurationSchema)
.mutation(async ({ ctx, input }) => {
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug(input, "mjmlConfigurationRouter.update or create called");
const { id } = input;
if (!id) {
return await ctx.configurationService.createConfiguration(input);
} else {
const existingConfiguration = await ctx.configurationService.getConfiguration({ id });
if (!existingConfiguration) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Configuration not found",
});
}
const configuration = {
id,
...input,
events: existingConfiguration.events,
};
await ctx.configurationService.updateConfiguration(configuration);
return configuration;
}
}),
getEventConfiguration: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(mjmlGetEventConfigurationInputSchema)
.input(smtpGetEventConfigurationInputSchema)
.query(async ({ ctx, input }) => {
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug(input, "mjmlConfigurationRouter.getEventConfiguration or create called");
logger.debug(input, "smtpConfigurationRouter.getEventConfiguration or create called");
const configuration = await ctx.configurationService.getConfiguration({
id: input.configurationId,
id: input.id,
});
if (!configuration) {
@ -146,14 +125,14 @@ export const mjmlConfigurationRouter = router({
}),
updateEventConfiguration: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(mjmlUpdateEventConfigurationInputSchema)
.input(smtpUpdateEventConfigurationInputSchema)
.mutation(async ({ ctx, input }) => {
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug(input, "mjmlConfigurationRouter.updateEventConfiguration or create called");
const configuration = await ctx.configurationService.getConfiguration({
id: input.configurationId,
id: input.id,
});
if (!configuration) {
@ -218,4 +197,114 @@ export const mjmlConfigurationRouter = router({
renderedEmailBody: renderedEmail,
};
}),
updateBasicInformation: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(smtpUpdateBasicInformationSchema)
.mutation(async ({ ctx, input }) => {
const configuration = await ctx.configurationService.getConfiguration({
id: input.id,
});
if (!configuration) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Configuration not found",
});
}
await ctx.configurationService.updateConfiguration({ ...configuration, ...input });
return configuration;
}),
updateSmtp: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(smtpUpdateSmtpSchema)
.mutation(async ({ ctx, input }) => {
const configuration = await ctx.configurationService.getConfiguration({
id: input.id,
});
if (!configuration) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Configuration not found",
});
}
await ctx.configurationService.updateConfiguration({ ...configuration, ...input });
return configuration;
}),
updateSender: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(smtpUpdateSenderSchema)
.mutation(async ({ ctx, input }) => {
const configuration = await ctx.configurationService.getConfiguration({
id: input.id,
});
if (!configuration) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Configuration not found",
});
}
await ctx.configurationService.updateConfiguration({ ...configuration, ...input });
return configuration;
}),
updateChannels: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(updateChannelsInputSchema)
.mutation(async ({ ctx, input }) => {
const configuration = await ctx.configurationService.getConfiguration({
id: input.id,
});
if (!configuration) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Configuration not found",
});
}
configuration.channels = {
override: input.override,
channels: input.channels,
mode: input.mode,
};
await ctx.configurationService.updateConfiguration(configuration);
return configuration;
}),
updateEvent: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(smtpUpdateEventSchema)
.mutation(async ({ ctx, input }) => {
const configuration = await ctx.configurationService.getConfiguration({
id: input.id,
});
if (!configuration) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Configuration not found",
});
}
const event = configuration.events.find((e) => e.eventType === input.eventType);
if (!event) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Configuration event not found",
});
}
event.template = input.template;
event.active = input.active;
await ctx.configurationService.updateConfiguration(configuration);
return configuration;
}),
});

View file

@ -1,17 +1,17 @@
import { MjmlConfig } from "./mjml-config";
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
import { SmtpConfig } from "./smtp-config-schema";
export interface MjmlConfigurator {
setConfig(config: MjmlConfig): Promise<void>;
getConfig(): Promise<MjmlConfig | undefined>;
export interface SmtpConfigurator {
setConfig(config: SmtpConfig): Promise<void>;
getConfig(): Promise<SmtpConfig | undefined>;
}
export class PrivateMetadataMjmlConfigurator implements MjmlConfigurator {
private metadataKey = "mjml-config";
export class PrivateMetadataSmtpConfigurator implements SmtpConfigurator {
private metadataKey = "smtp-config";
constructor(private metadataManager: SettingsManager, private saleorApiUrl: string) {}
getConfig(): Promise<MjmlConfig | undefined> {
getConfig(): Promise<SmtpConfig | undefined> {
return this.metadataManager.get(this.metadataKey, this.saleorApiUrl).then((data) => {
if (!data) {
return data;
@ -25,7 +25,7 @@ export class PrivateMetadataMjmlConfigurator implements MjmlConfigurator {
});
}
setConfig(config: MjmlConfig): Promise<void> {
setConfig(config: SmtpConfig): Promise<void> {
return this.metadataManager.set({
key: this.metadataKey,
value: JSON.stringify(config),

View file

@ -4,10 +4,10 @@ import { compileHandlebarsTemplate } from "./compile-handlebars-template";
import { sendEmailWithSmtp, SendMailArgs } from "./send-email-with-smtp";
import { MessageEventTypes } from "../event-handlers/message-event-types";
import { htmlToPlaintext } from "./html-to-plaintext";
import { MjmlConfiguration } from "./configuration/mjml-config";
import { SmtpConfiguration } from "./configuration/smtp-config-schema";
interface SendMjmlArgs {
mjmlConfiguration: MjmlConfiguration;
interface SendSmtpArgs {
smtpConfiguration: SmtpConfiguration;
recipientEmail: string;
event: MessageEventTypes;
payload: any;
@ -20,18 +20,18 @@ export interface EmailServiceResponse {
}[];
}
export const sendMjml = async ({
export const sendSmtp = async ({
payload,
recipientEmail,
event,
mjmlConfiguration,
}: SendMjmlArgs) => {
smtpConfiguration,
}: SendSmtpArgs) => {
const logger = createLogger({
fn: "sendMjml",
fn: "sendSmtp",
event,
});
const eventSettings = mjmlConfiguration.events.find((e) => e.eventType === event);
const eventSettings = smtpConfiguration.events.find((e) => e.eventType === event);
if (!eventSettings) {
logger.debug("No active settings for this event, skipping");
@ -133,20 +133,20 @@ export const sendMjml = async ({
mailData: {
text: emailBodyPlaintext,
html: emailBodyHtml,
from: `${mjmlConfiguration.senderName} <${mjmlConfiguration.senderEmail}>`,
from: `${smtpConfiguration.senderName} <${smtpConfiguration.senderEmail}>`,
to: recipientEmail,
subject: emailSubject,
},
smtpSettings: {
host: mjmlConfiguration.smtpHost,
port: parseInt(mjmlConfiguration.smtpPort, 10),
host: smtpConfiguration.smtpHost,
port: parseInt(smtpConfiguration.smtpPort, 10),
},
};
if (mjmlConfiguration.smtpUser) {
if (smtpConfiguration.smtpUser) {
sendEmailSettings.smtpSettings.auth = {
user: mjmlConfiguration.smtpUser,
pass: mjmlConfiguration.smtpPassword,
user: smtpConfiguration.smtpUser,
pass: smtpConfiguration.smtpPassword,
};
}

View file

@ -0,0 +1,42 @@
import React, { useCallback, useRef } from "react";
import Editor from "@monaco-editor/react";
import { useTheme } from "@saleor/macaw-ui/next";
type Props = {
onChange(value: string): void;
initialTemplate: string;
value: string;
language: string;
};
export const CodeEditor = ({ initialTemplate, onChange, value, language }: Props) => {
const { theme } = 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={theme === "defaultDark" ? "vs-dark" : "vs-light"}
defaultLanguage={language}
defaultValue={initialTemplate}
onMount={handleEditorDidMount}
onChange={handleOnChange}
/>
</>
);
};

View file

@ -0,0 +1,163 @@
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import { SmtpConfiguration } from "../configuration/smtp-config-schema";
import { MessageEventTypes } from "../../event-handlers/message-event-types";
import { Input } from "../../../components/react-hook-form-macaw/Input";
import { useDashboardNotification } from "@saleor/apps-shared";
import { trpcClient } from "../../trpc/trpc-client";
import { Controller, useForm } from "react-hook-form";
import {
SmtpUpdateEventConfigurationInput,
smtpUpdateEventConfigurationInputSchema,
} from "../configuration/smtp-config-input-schema";
import { zodResolver } from "@hookform/resolvers/zod";
import { CodeEditor } from "./code-edtor";
import { useDebounce } from "usehooks-ts";
import { useState, useEffect } from "react";
import { examplePayloads } from "../../event-handlers/default-payloads";
import { MjmlPreview } from "./mjml-preview";
import { defaultPadding } from "../../../components/ui-defaults";
import { setBackendErrors } from "../../../lib/set-backend-errors";
const PREVIEW_DEBOUNCE_DELAY = 500;
interface EventFormProps {
configuration: SmtpConfiguration;
eventType: MessageEventTypes;
}
export const EventForm = ({ configuration, eventType }: EventFormProps) => {
const { notifySuccess, notifyError } = useDashboardNotification();
const eventConfiguration = configuration?.events.find(
(eventConfiguration) => eventConfiguration.eventType === eventType
)!; // Event conf is not optional, so we can use ! here
const { handleSubmit, control, getValues, setError } = useForm<SmtpUpdateEventConfigurationInput>(
{
defaultValues: {
id: configuration.id,
...eventConfiguration,
},
resolver: zodResolver(smtpUpdateEventConfigurationInputSchema),
}
);
const trpcContext = trpcClient.useContext();
const { mutate } = trpcClient.smtpConfiguration.updateSender.useMutation({
onSuccess: async () => {
notifySuccess("Configuration saved");
trpcContext.smtpConfiguration.invalidate();
},
onError(error) {
setBackendErrors<SmtpUpdateEventConfigurationInput>({
error,
setError,
notifyError,
});
},
});
const { mutate: fetchTemplatePreview, isLoading: isFetchingTemplatePreview } =
trpcClient.smtpConfiguration.renderTemplate.useMutation({
onSuccess: (data) => {
if (data.renderedEmailBody) {
setLastValidRenderedTemplate(data.renderedEmailBody);
}
if (data.renderedSubject) {
setLastValidRenderedSubject(data.renderedSubject);
}
},
});
const [lastValidRenderedTemplate, setLastValidRenderedTemplate] = useState("");
const [lastValidRenderedSubject, setLastValidRenderedSubject] = useState("");
const [payload, setPayload] = useState<string>(
JSON.stringify(examplePayloads[eventType], undefined, 2)
);
const { template, subject } = getValues();
const debouncedMutationVariables = useDebounce(
{ template, subject, payload },
PREVIEW_DEBOUNCE_DELAY
);
const {
template: debouncedTemplate,
subject: debouncedSubject,
payload: debouncedPayload,
} = debouncedMutationVariables;
useEffect(() => {
fetchTemplatePreview({
template: debouncedTemplate,
subject: debouncedSubject,
payload: debouncedPayload,
});
}, [debouncedPayload, debouncedSubject, debouncedTemplate, fetchTemplatePreview]);
return (
<form
onSubmit={handleSubmit((data, event) => {
mutate({
...data,
});
})}
>
<Box display="flex" flexDirection="column" gap={defaultPadding}>
<Box display="flex" justifyContent="space-between">
<Text variant="hero">Edit template</Text>
<Button type="submit">Save</Button>
</Box>
<Box display="grid" gridTemplateColumns={{ desktop: 3, mobile: 1 }}>
<Input control={control} name="subject" label="Subject" />
</Box>
<Box display="grid" gridTemplateColumns={{ desktop: 5, mobile: 1 }} gap={defaultPadding}>
<Box
gridColumnStart={{ desktop: "1", mobile: "1" }}
gridColumnEnd={{ desktop: "3", mobile: "6" }}
>
<Controller
control={control}
name="template"
render={({ field: { value, onChange } }) => {
return (
<CodeEditor
initialTemplate={value}
value={value}
onChange={onChange}
language="xml"
/>
);
}}
/>
</Box>
<Box
gridColumnStart={{ desktop: "3", mobile: "1" }}
gridColumnEnd={{ desktop: "5", mobile: "6" }}
>
<CodeEditor
initialTemplate={payload}
value={payload}
onChange={setPayload}
language="json"
/>
</Box>
<Box
gridColumnStart={{ desktop: "5", mobile: "1" }}
gridColumnEnd={{ desktop: "6", mobile: "6" }}
display="flex"
flexDirection="column"
gap={defaultPadding}
>
<Text variant="heading" as="p">
Subject: {lastValidRenderedSubject}
</Text>
<MjmlPreview value={lastValidRenderedTemplate} />
</Box>
</Box>
</Box>
</form>
);
};

View file

@ -0,0 +1,17 @@
import React from "react";
type Props = {
value?: string;
};
export const MjmlPreview = ({ value }: Props) => {
return (
<>
{value?.length ? (
<div dangerouslySetInnerHTML={{ __html: value }} />
) : (
<p>No template preview</p>
)}
</>
);
};

View file

@ -0,0 +1,88 @@
import { SmtpConfiguration } from "../configuration/smtp-config-schema";
import { BoxWithBorder } from "../../../components/box-with-border";
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import { defaultPadding } from "../../../components/ui-defaults";
import { useDashboardNotification } from "@saleor/apps-shared";
import { trpcClient } from "../../trpc/trpc-client";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { BoxFooter } from "../../../components/box-footer";
import { SectionWithDescription } from "../../../components/section-with-description";
import {
SmtpUpdateBasicInformation,
smtpUpdateBasicInformationSchema,
} from "../configuration/smtp-config-input-schema";
import { Input } from "../../../components/react-hook-form-macaw/Input";
import { zodResolver } from "@hookform/resolvers/zod";
import { setBackendErrors } from "../../../lib/set-backend-errors";
interface SmtpBasicInformationSectionProps {
configuration: SmtpConfiguration;
}
export const SmtpBasicInformationSection = ({
configuration,
}: SmtpBasicInformationSectionProps) => {
const { notifySuccess, notifyError } = useDashboardNotification();
const { handleSubmit, control, setError, register } = useForm<SmtpUpdateBasicInformation>({
defaultValues: {
id: configuration.id,
name: configuration.name,
active: configuration.active,
},
resolver: zodResolver(smtpUpdateBasicInformationSchema),
});
const trpcContext = trpcClient.useContext();
const { mutate } = trpcClient.smtpConfiguration.updateBasicInformation.useMutation({
onSuccess: async () => {
notifySuccess("Configuration saved");
trpcContext.smtpConfiguration.invalidate();
},
onError(error) {
setBackendErrors<SmtpUpdateBasicInformation>({
error,
setError,
notifyError,
});
},
});
return (
<SectionWithDescription
title="Connect SMTP"
description={
<Text>
Provide unique name for your configuration - you can create more than one. For example -
production and development. Then, pass your API Key. Obtain it here.
</Text>
}
>
<BoxWithBorder>
<form
onSubmit={handleSubmit((data, event) => {
mutate({
...data,
});
})}
>
<Box padding={defaultPadding} display="flex" flexDirection="column" gap={10}>
<Input
name="name"
label="Configuration name"
control={control}
helperText="Name of the configuration, for example 'Production' or 'Test'"
/>
<label>
<input type="checkbox" placeholder="Enabled" {...register("active")} />
<Text paddingLeft={defaultPadding}>Active</Text>
</label>
</Box>
<BoxFooter>
<Button type="submit">Save provider</Button>
</BoxFooter>
</form>
</BoxWithBorder>
</SectionWithDescription>
);
};

View file

@ -0,0 +1,36 @@
import { SmtpConfiguration } from "../configuration/smtp-config-schema";
import { useDashboardNotification } from "@saleor/apps-shared";
import { trpcClient } from "../../trpc/trpc-client";
import { setBackendErrors } from "../../../lib/set-backend-errors";
import { UniversalChannelsSection } from "../../channels/ui/universal-channels-section";
import { UpdateChannelsInput } from "../../channels/channel-configuration-schema";
interface SmtpChannelsSectionProps {
configuration: SmtpConfiguration;
}
export const SmtpChannelsSection = ({ configuration }: SmtpChannelsSectionProps) => {
const { notifySuccess, notifyError } = useDashboardNotification();
const trpcContext = trpcClient.useContext();
const { mutate } = trpcClient.smtpConfiguration.updateChannels.useMutation({
onSuccess: async () => {
notifySuccess("Configuration saved");
trpcContext.smtpConfiguration.invalidate();
},
onError(error) {
setBackendErrors<UpdateChannelsInput>({
error,
notifyError,
});
},
});
return (
<UniversalChannelsSection
configurationId={configuration.id}
channelConfiguration={configuration.channels}
onSubmit={mutate}
/>
);
};

View file

@ -0,0 +1,76 @@
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import { BoxWithBorder } from "../../../components/box-with-border";
import { SectionWithDescription } from "../../../components/section-with-description";
import { defaultPadding } from "../../../components/ui-defaults";
import { BoxFooter } from "../../../components/box-footer";
import { trpcClient } from "../../trpc/trpc-client";
import { useDashboardNotification } from "@saleor/apps-shared";
import { useForm } from "react-hook-form";
import { useRouter } from "next/router";
import { SmtpConfiguration } from "../configuration/smtp-config-schema";
import { SmtpGetConfigurationIdInput } from "../configuration/smtp-config-input-schema";
import { setBackendErrors } from "../../../lib/set-backend-errors";
interface SmtpDangerousSectionProps {
configuration: SmtpConfiguration;
}
export const SmtpDangerousSection = ({ configuration }: SmtpDangerousSectionProps) => {
const { notifySuccess, notifyError } = useDashboardNotification();
const { replace } = useRouter();
const { handleSubmit, setError } = useForm<SmtpGetConfigurationIdInput>({
defaultValues: {
id: configuration.id,
},
});
const { mutate } = trpcClient.smtpConfiguration.deleteConfiguration.useMutation({
onSuccess: async () => {
notifySuccess("Configuration removed");
replace("/configuration");
},
onError(error) {
setBackendErrors<SmtpGetConfigurationIdInput>({
error,
setError,
notifyError,
});
},
});
return (
<SectionWithDescription title="Danger zone">
<form
onSubmit={handleSubmit((data, event) => {
mutate({
...data,
});
})}
>
<BoxWithBorder backgroundColor="surfaceCriticalSubdued" borderColor="criticalSubdued">
<Box padding={defaultPadding}>
<Text variant="heading" as="h1">
Remove provider
</Text>
<Text as="p">You can remove provider configuration.</Text>
<Text as="p">
This operation will remove all settings related to this configuration. Data will be
permanently removed from the App.
</Text>
<Text as="p">This operation cant be undone.</Text>
<Text as="p">You still can create new configuration.</Text>
</Box>
<BoxFooter borderColor="criticalSubdued">
<Button
color="textNeutralSubdued"
backgroundColor="interactiveCriticalDefault"
type="submit"
>
Remove provider
</Button>
</BoxFooter>
</BoxWithBorder>
</form>
</SectionWithDescription>
);
};

View file

@ -0,0 +1,106 @@
import { SmtpConfiguration, SmtpEventConfiguration } from "../configuration/smtp-config-schema";
import { BoxWithBorder } from "../../../components/box-with-border";
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import { defaultPadding } from "../../../components/ui-defaults";
import { useDashboardNotification } from "@saleor/apps-shared";
import { trpcClient } from "../../trpc/trpc-client";
import { useForm } from "react-hook-form";
import { BoxFooter } from "../../../components/box-footer";
import { SectionWithDescription } from "../../../components/section-with-description";
import { SmtpUpdateEvent, smtpUpdateEventSchema } from "../configuration/smtp-config-input-schema";
import { useRouter } from "next/router";
import { zodResolver } from "@hookform/resolvers/zod";
import { smtpUrls } from "../urls";
import { setBackendErrors } from "../../../lib/set-backend-errors";
interface EventBoxProps {
configuration: SmtpConfiguration;
event: SmtpEventConfiguration;
}
const EventBox = ({ event, configuration }: EventBoxProps) => {
const router = useRouter();
const { notifySuccess, notifyError } = useDashboardNotification();
const { handleSubmit, control, setError, register } = useForm<SmtpUpdateEvent>({
defaultValues: {
id: configuration.id,
...event,
},
resolver: zodResolver(smtpUpdateEventSchema),
});
const trpcContext = trpcClient.useContext();
const { mutate } = trpcClient.smtpConfiguration.updateEvent.useMutation({
onSuccess: async () => {
notifySuccess("Configuration saved");
trpcContext.smtpConfiguration.invalidate();
},
onError(error) {
setBackendErrors<SmtpUpdateEvent>({
error,
setError,
notifyError,
});
},
});
return (
<form
onSubmit={handleSubmit((data, event) => {
mutate({
...data,
});
})}
>
<BoxWithBorder>
<Box padding={defaultPadding} display="flex" flexDirection="column" gap={defaultPadding}>
<Text variant="heading">{event.eventType}</Text>
<Button
variant="secondary"
onClick={() => {
router.push(smtpUrls.eventConfiguration(configuration.id, event.eventType));
}}
>
Edit template
</Button>
<label>
<input type="checkbox" placeholder="Enabled" {...register("active")} />
<Text paddingLeft={defaultPadding}>Active</Text>
</label>
</Box>
<BoxFooter>
<Button type="submit">Save event</Button>
</BoxFooter>
</BoxWithBorder>
</form>
);
};
interface SmtpEventsSectionProps {
configuration: SmtpConfiguration;
}
export const SmtpEventsSection = ({ configuration }: SmtpEventsSectionProps) => {
return (
<SectionWithDescription
title="Events"
description={
<>
<Text display="block">
Provide unique name for your configuration - you can create more than one. For example -
production and development.
</Text>
<Text display="block">Then, pass your API Key. Obtain it here.</Text>
</>
}
>
<Box display="flex" flexDirection="column" gap={defaultPadding}>
{configuration.events.map((event) => (
<EventBox key={event.eventType} configuration={configuration} event={event} />
))}
</Box>
</SectionWithDescription>
);
};

View file

@ -0,0 +1,109 @@
import { SmtpConfiguration } from "../configuration/smtp-config-schema";
import { BoxWithBorder } from "../../../components/box-with-border";
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import { defaultPadding } from "../../../components/ui-defaults";
import { useDashboardNotification } from "@saleor/apps-shared";
import { trpcClient } from "../../trpc/trpc-client";
import { useForm } from "react-hook-form";
import { BoxFooter } from "../../../components/box-footer";
import { SectionWithDescription } from "../../../components/section-with-description";
import { SmtpUpdateSmtp, smtpUpdateSmtpSchema } from "../configuration/smtp-config-input-schema";
import { Input } from "../../../components/react-hook-form-macaw/Input";
import { zodResolver } from "@hookform/resolvers/zod";
import { setBackendErrors } from "../../../lib/set-backend-errors";
interface SmtpSectionProps {
configuration: SmtpConfiguration;
}
export const SmtpSection = ({ configuration }: SmtpSectionProps) => {
const { notifySuccess, notifyError } = useDashboardNotification();
const { handleSubmit, control, setError, register } = useForm<SmtpUpdateSmtp>({
defaultValues: {
id: configuration.id,
smtpHost: configuration.smtpHost,
smtpPort: configuration.smtpPort,
smtpUser: configuration.smtpUser,
smtpPassword: configuration.smtpPassword,
encryption: configuration.encryption,
},
resolver: zodResolver(smtpUpdateSmtpSchema),
});
const trpcContext = trpcClient.useContext();
const { mutate } = trpcClient.smtpConfiguration.updateSmtp.useMutation({
onSuccess: async () => {
notifySuccess("Configuration saved");
trpcContext.smtpConfiguration.invalidate();
},
onError(error) {
setBackendErrors<SmtpUpdateSmtp>({
error,
setError,
notifyError,
});
},
});
return (
<SectionWithDescription
title="Connect SMTP server"
description={<Text>Provide SMTP server configuration</Text>}
>
<BoxWithBorder>
<form
onSubmit={handleSubmit((data, event) => {
mutate({
...data,
});
})}
>
<Box padding={defaultPadding} display="flex" flexDirection="column" gap={10}>
<Input
label="Host"
helperText="Server host that will be connected."
control={control}
name="smtpHost"
/>
<Input
label="Port"
name="smtpPort"
control={control}
helperText="Port that will be used"
/>
<Input
label="User"
name="smtpUser"
control={control}
helperText="User for the SMTP server connection"
/>
<Input
label="Password"
name="smtpPassword"
control={control}
helperText="Password for the SMTP server connection"
/>
<Box display="flex" gap={defaultPadding}>
<label>
<input {...register("encryption")} type="radio" value="NONE" />
<Text paddingLeft={defaultPadding}>No encryption</Text>
</label>
<label>
<input {...register("encryption")} type="radio" value="SSL" />
<Text paddingLeft={defaultPadding}>SSL</Text>
</label>
<label>
<input {...register("encryption")} type="radio" value="TLS" />
<Text paddingLeft={defaultPadding}>TLS</Text>
</label>
</Box>
</Box>
<BoxFooter>
<Button type="submit">Save provider</Button>
</BoxFooter>
</form>
</BoxWithBorder>
</SectionWithDescription>
);
};

View file

@ -0,0 +1,79 @@
import { SmtpConfiguration } from "../configuration/smtp-config-schema";
import { BoxWithBorder } from "../../../components/box-with-border";
import { Box, Button } from "@saleor/macaw-ui/next";
import { defaultPadding } from "../../../components/ui-defaults";
import { useDashboardNotification } from "@saleor/apps-shared";
import { trpcClient } from "../../trpc/trpc-client";
import { useForm } from "react-hook-form";
import { BoxFooter } from "../../../components/box-footer";
import { SectionWithDescription } from "../../../components/section-with-description";
import {
SmtpUpdateSender,
smtpUpdateSenderSchema,
} from "../configuration/smtp-config-input-schema";
import { Input } from "../../../components/react-hook-form-macaw/Input";
import { zodResolver } from "@hookform/resolvers/zod";
import { setBackendErrors } from "../../../lib/set-backend-errors";
interface SmtpSenderSectionProps {
configuration: SmtpConfiguration;
}
export const SmtpSenderSection = ({ configuration }: SmtpSenderSectionProps) => {
const { notifySuccess, notifyError } = useDashboardNotification();
const { handleSubmit, control, setError, register } = useForm<SmtpUpdateSender>({
defaultValues: {
id: configuration.id,
senderName: configuration.senderName,
senderEmail: configuration.senderEmail,
},
resolver: zodResolver(smtpUpdateSenderSchema),
});
const trpcContext = trpcClient.useContext();
const { mutate } = trpcClient.smtpConfiguration.updateSender.useMutation({
onSuccess: async () => {
notifySuccess("Configuration saved");
trpcContext.smtpConfiguration.invalidate();
},
onError(error) {
setBackendErrors<SmtpUpdateSender>({
error,
setError,
notifyError,
});
},
});
return (
<SectionWithDescription title="Sender">
<BoxWithBorder>
<form
onSubmit={handleSubmit((data, event) => {
mutate({
...data,
});
})}
>
<Box padding={defaultPadding} display="flex" flexDirection="column" gap={10}>
<Input
label="Email"
name="senderEmail"
control={control}
helperText="Email address that will be used as sender"
/>
<Input
label="Name"
name="senderName"
control={control}
helperText="Name that will be used as sender"
/>
</Box>
<BoxFooter>
<Button type="submit">Save provider</Button>
</BoxFooter>
</form>
</BoxWithBorder>
</SectionWithDescription>
);
};

View file

@ -0,0 +1,8 @@
import { MessageEventTypes } from "../event-handlers/message-event-types";
export const smtpUrls = {
newConfiguration: () => `/configuration/smtp/new`,
configuration: (id: string) => `/configuration/smtp/${id}`,
eventConfiguration: (id: string, event: MessageEventTypes) =>
`/configuration/smtp/${id}/event/${event}`,
};

View file

@ -1,11 +1,11 @@
import { channelsRouter } from "../channels/channels.router";
import { router } from "./trpc-server";
import { mjmlConfigurationRouter } from "../mjml/configuration/mjml-configuration.router";
import { smtpConfigurationRouter } from "../smtp/configuration/smtp-configuration.router";
import { sendgridConfigurationRouter } from "../sendgrid/configuration/sendgrid-configuration.router";
export const appRouter = router({
channels: channelsRouter,
mjmlConfiguration: mjmlConfigurationRouter,
smtpConfiguration: smtpConfigurationRouter,
sendgridConfiguration: sendgridConfigurationRouter,
});

View file

@ -1,65 +1,15 @@
import "@saleor/macaw-ui/next/style";
import "../styles/globals.css";
import { Theme } from "@material-ui/core/styles";
import { AppBridge, AppBridgeProvider } from "@saleor/app-sdk/app-bridge";
import { RoutePropagator } from "@saleor/app-sdk/app-bridge/next";
import {
dark,
light,
SaleorThemeColors,
ThemeProvider as MacawUIThemeProvider,
} from "@saleor/macaw-ui";
import React, { PropsWithChildren, useEffect } from "react";
import React from "react";
import { AppProps } from "next/app";
import { ThemeProvider } from "@saleor/macaw-ui/next";
import { ThemeSynchronizer } from "../lib/theme-synchronizer";
import { NoSSRWrapper } from "../lib/no-ssr-wrapper";
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.
@ -67,34 +17,15 @@ const generateClassName = createGenerateClassName({
*/
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) {
/**
* 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 (
<NoSSRWrapper>
<AppBridgeProvider appBridgeInstance={appBridgeInstance}>
<StylesProvider generateClassName={generateClassName}>
<ThemeProvider palettes={palettes} overrides={themeOverrides} ssr>
<ThemeSynchronizer />
<RoutePropagator />
<Component {...pageProps} />
</ThemeProvider>
</StylesProvider>
<ThemeProvider defaultTheme="defaultLight">
<ThemeSynchronizer />
<RoutePropagator />
<Component {...pageProps} />
</ThemeProvider>
</AppBridgeProvider>
</NoSSRWrapper>
);

View file

@ -28,7 +28,6 @@ export default createAppRegisterHandler({
onAuthAplSaved: async (request, ctx) => {
// Subscribe to Notify using the mutation since it does not use subscriptions and can't be subscribed via manifest
logger.debug("onAuthAplSaved executing");
const baseUrl = getBaseUrl(request.headers);
const client = createClient(ctx.authData.saleorApiUrl, async () =>
Promise.resolve({ token: ctx.authData.token })

View file

@ -0,0 +1,46 @@
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";
import { sendgridUrls } from "../../modules/sendgrid/urls";
import { smtpUrls } from "../../modules/smtp/urls";
import { appUrls } from "../../modules/app-configuration/urls";
import { BasicLayout } from "../../components/basic-layout";
const ChooseProviderPage: NextPage = () => {
const { push } = useRouter();
return (
<BasicLayout
breadcrumbs={[
{ name: "Configuration", href: appUrls.configuration() },
{ name: "Add provider" },
]}
>
<Box display="grid" gridTemplateColumns={{ desktop: 3, mobile: 1 }}>
<Box>
<Text>Select and configure providers to connect Saleor with selected services.</Text>
</Box>
</Box>
<SectionWithDescription title="Choose provider">
<Box display="grid" gridTemplateColumns={2} gap={6}>
<ProviderSelectionBox
providerName="Sendgrid"
providerDescription="Use dynamic templates created in Sendgrid dashboard to send messages. Event data will be forwarded to Sendgrid."
onClick={() => push(sendgridUrls.newConfiguration())}
/>
<ProviderSelectionBox
providerName="SMTP"
providerDescription="Provide your own SMTP credentials and map Saleor event to custom MJML templates."
onClick={() => push(smtpUrls.newConfiguration())}
/>
</Box>
</SectionWithDescription>
</BasicLayout>
);
};
export default ChooseProviderPage;

View file

@ -0,0 +1,61 @@
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 {
ConfigurationListItem,
MessagingProvidersBox,
} from "../../modules/app-configuration/ui/messaging-providers-box";
import { trpcClient } from "../../modules/trpc/trpc-client";
import { appUrls } from "../../modules/app-configuration/urls";
import { BasicLayout } from "../../components/basic-layout";
const ConfigurationPage: NextPage = () => {
const { data: dataSendgrid, isLoading: isLoadingSendgrid } =
trpcClient.sendgridConfiguration.getConfigurations.useQuery();
const { data: dataSmtp, isLoading: isLoadingSmtp } =
trpcClient.smtpConfiguration.getConfigurations.useQuery();
const data: ConfigurationListItem[] = [
...(dataSendgrid?.map((configuration) => ({
name: configuration.name,
provider: "sendgrid" as const,
id: configuration.id,
active: configuration.active,
})) || []),
...(dataSmtp?.map((configuration) => ({
name: configuration.name,
provider: "smtp" as const,
id: configuration.id,
active: configuration.active,
})) || []),
];
const isLoading = isLoadingSendgrid || isLoadingSmtp;
return (
<BasicLayout breadcrumbs={[{ name: "Configuration", href: appUrls.configuration() }]}>
<Box display="grid" gridTemplateColumns={{ desktop: 3, mobile: 1 }}>
<Box>
<Text>
Configure Emails & Messages app to deliver Saleor Events webhooks to various messaging
providers
</Text>
</Box>
</Box>
<SectionWithDescription
title="Messaging providers"
description={
<Text>
Manage providers configuration to connect Saleor events with 3rd party services.
</Text>
}
>
<MessagingProvidersBox configurations={data || []} isLoading={isLoading} />
</SectionWithDescription>
</BasicLayout>
);
};
export default ConfigurationPage;

View file

@ -0,0 +1,100 @@
import { Box, Text } from "@saleor/macaw-ui/next";
import { NextPage } from "next";
import { trpcClient } from "../../../modules/trpc/trpc-client";
import { useRouter } from "next/router";
import { SendgridBasicInformationSection } from "../../../modules/sendgrid/ui/sendgrid-basic-information-section";
import { SendgridDangerousSection } from "../../../modules/sendgrid/ui/sendgrid-dangrous-section";
import { ApiConnectionSection } from "../../../modules/sendgrid/ui/api-connection-section";
import { SendgridSenderSection } from "../../../modules/sendgrid/ui/sendgrid-sender-section";
import { SendgridEventsSection } from "../../../modules/sendgrid/ui/sendgrid-events-section";
import { useDashboardNotification } from "@saleor/apps-shared";
import { BasicLayout } from "../../../components/basic-layout";
import { SendgridChannelsSection } from "../../../modules/sendgrid/ui/sendgrid-channels-section";
import { appUrls } from "../../../modules/app-configuration/urls";
const LoadingView = () => {
return (
<BasicLayout
breadcrumbs={[
{ name: "Configuration", href: appUrls.configuration() },
{ name: "Sendgrid provider" },
{ name: "..." },
]}
>
<Text variant="hero">Loading...</Text>
</BasicLayout>
);
};
const NotFoundView = () => {
return (
<BasicLayout
breadcrumbs={[
{ name: "Configuration", href: appUrls.configuration() },
{ name: "Sendgrid provider" },
{ name: "Not found" },
]}
>
<Text variant="hero">Could not find the requested configuration.</Text>
</BasicLayout>
);
};
const EditSendgridConfigurationPage: NextPage = () => {
const { notifyError } = useDashboardNotification();
const router = useRouter();
const configurationId = router.query.configurationId
? (router.query.configurationId as string)
: undefined;
const { data: configuration, isLoading } =
trpcClient.sendgridConfiguration.getConfiguration.useQuery(
{
id: configurationId!,
},
{
enabled: !!configurationId,
onSettled(data, error) {
if (error) {
console.log("Error: ", error);
}
if (error?.data?.code === "NOT_FOUND" || !data) {
notifyError("The requested configuration does not exist.");
router.replace("/configuration");
}
},
}
);
if (isLoading) {
return <LoadingView />;
}
if (!configuration) {
return <NotFoundView />;
}
return (
<BasicLayout
breadcrumbs={[
{ name: "Configuration", href: appUrls.configuration() },
{
name: `Sendgrid: ${configuration.name}`,
},
]}
>
<Box display="grid" gridTemplateColumns={{ desktop: 3, mobile: 1 }}>
<Box>
<Text>Connect Sendgrid with Saleor.</Text>
</Box>
</Box>
<SendgridBasicInformationSection configuration={configuration} />
<ApiConnectionSection configuration={configuration} />
<SendgridSenderSection configuration={configuration} />
<SendgridEventsSection configuration={configuration} />
<SendgridChannelsSection configuration={configuration} />
<SendgridDangerousSection configuration={configuration} />
</BasicLayout>
);
};
export default EditSendgridConfigurationPage;

View file

@ -0,0 +1,120 @@
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";
import { sendgridUrls } from "../../../modules/sendgrid/urls";
import { appUrls } from "../../../modules/app-configuration/urls";
import { setBackendErrors } from "../../../lib/set-backend-errors";
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(sendgridUrls.configuration(data.id));
},
onError(error) {
setBackendErrors<SendgridCreateConfigurationInput>({
error,
setError,
notifyError,
});
},
});
return (
<BasicLayout
breadcrumbs={[
{ name: "Configuration", href: appUrls.configuration() },
{ 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;

View file

@ -0,0 +1,97 @@
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 { useDashboardNotification } from "@saleor/apps-shared";
import { BasicLayout } from "../../../components/basic-layout";
import { SmtpBasicInformationSection } from "../../../modules/smtp/ui/smtp-basic-information-section";
import { SmtpSection } from "../../../modules/smtp/ui/smtp-section";
import { SmtpSenderSection } from "../../../modules/smtp/ui/smtp-sender-section";
import { SmtpDangerousSection } from "../../../modules/smtp/ui/smtp-dangrous-section";
import { SmtpChannelsSection } from "../../../modules/smtp/ui/smtp-channels-section";
import { SmtpEventsSection } from "../../../modules/smtp/ui/smtp-events-section";
import { appUrls } from "../../../modules/app-configuration/urls";
const LoadingView = () => {
return (
<BasicLayout
breadcrumbs={[
{ name: "Configuration", href: appUrls.configuration() },
{ name: "SMTP provider" },
{ name: "..." },
]}
>
<Text variant="hero">Loading...</Text>
</BasicLayout>
);
};
const NotFoundView = () => {
return (
<BasicLayout
breadcrumbs={[
{ name: "Configuration", href: appUrls.configuration() },
{ name: "SMTP provider" },
{ name: "Not found" },
]}
>
<Text variant="hero">Could not find the requested configuration.</Text>
</BasicLayout>
);
};
const EditSmtpConfigurationPage: NextPage = () => {
const { notifyError } = useDashboardNotification();
const router = useRouter();
const configurationId = router.query.configurationId
? (router.query.configurationId as string)
: undefined;
const { data: configuration, isLoading } = trpcClient.smtpConfiguration.getConfiguration.useQuery(
{
id: configurationId!,
},
{
enabled: !!configurationId,
onSettled(data, error) {
if (error) {
console.log("Error: ", error);
}
if (error?.data?.code === "NOT_FOUND" || !data) {
notifyError("The requested configuration does not exist.");
router.replace("/configuration");
}
},
}
);
if (isLoading) {
return <LoadingView />;
}
if (!configuration) {
return <NotFoundView />;
}
return (
<BasicLayout
breadcrumbs={[
{ name: "Configuration", href: appUrls.configuration() },
{ name: `SMTP: ${configuration.name}` },
]}
>
<Box display="grid" gridTemplateColumns={{ desktop: 3, mobile: 1 }}>
<Box>
<Text>Connect SMTP with Saleor.</Text>
</Box>
</Box>
<SmtpBasicInformationSection configuration={configuration} />
<SmtpSection configuration={configuration} />
<SmtpSenderSection configuration={configuration} />
<SmtpEventsSection configuration={configuration} />
<SmtpChannelsSection configuration={configuration} />
<SmtpDangerousSection configuration={configuration} />
</BasicLayout>
);
};
export default EditSmtpConfigurationPage;

View file

@ -0,0 +1,98 @@
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 { useDashboardNotification } from "@saleor/apps-shared";
import { BasicLayout } from "../../../../../components/basic-layout";
import { parseMessageEventType } from "../../../../../modules/event-handlers/parse-message-event-type";
import { appUrls } from "../../../../../modules/app-configuration/urls";
import { EventForm } from "../../../../../modules/smtp/ui/event-form";
import { smtpUrls } from "../../../../../modules/smtp/urls";
import { TextLink } from "../../../../../components/text-link";
const LoadingView = () => {
return (
<BasicLayout
breadcrumbs={[{ name: "Configuration", href: appUrls.configuration() }, { name: "..." }]}
>
<Text variant="hero">Loading...</Text>
</BasicLayout>
);
};
const NotFoundView = () => {
return (
<BasicLayout
breadcrumbs={[
{ name: "Configuration", href: appUrls.configuration() },
{ name: "SMTP provider" },
{ name: "Not found" },
]}
>
<Text variant="hero">Could not find the requested configuration.</Text>
</BasicLayout>
);
};
const EditSmtpEventPage: NextPage = () => {
const { notifyError } = useDashboardNotification();
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, isLoading } = trpcClient.smtpConfiguration.getConfiguration.useQuery(
{
id: configurationId,
},
{
enabled: !!configurationId && !!eventType,
onSettled(data, error) {
if (error) {
console.error("Error during fetching the configuration: ", error);
}
if (error?.data?.code === "NOT_FOUND" || !data) {
notifyError("The requested configuration does not exist.");
router.replace(appUrls.configuration());
}
},
}
);
// TODO: better error messages
if (!eventType || !configurationId) {
return <>Error: no event type or configuration id</>;
}
if (isLoading) {
return <LoadingView />;
}
if (!configuration) {
return <NotFoundView />;
}
return (
<BasicLayout
breadcrumbs={[
{ name: "Configuration", href: appUrls.configuration() },
{ name: `SMTP: ${configuration.name}`, href: smtpUrls.configuration(configurationId) },
{ name: eventType },
]}
>
<Box display="flex" flexDirection="column" gap={13}>
<Text as="p">
Edit template for {eventType} event. You can learn more about MJML{" "}
<TextLink href="https://mjml.io/" openNewTab={true}>
here
</TextLink>
.
</Text>
<EventForm configuration={configuration} eventType={eventType} />
</Box>
</BasicLayout>
);
};
export default EditSmtpEventPage;

View file

@ -0,0 +1,134 @@
import { Box, Button, Divider, 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 { useForm } from "react-hook-form";
import {
SmtpCreateConfigurationInput,
smtpCreateConfigurationInputSchema,
} from "../../../modules/smtp/configuration/smtp-config-input-schema";
import { BasicLayout } from "../../../components/basic-layout";
import { useRouter } from "next/router";
import { Input } from "../../../components/react-hook-form-macaw/Input";
import { zodResolver } from "@hookform/resolvers/zod";
import { appUrls } from "../../../modules/app-configuration/urls";
import { smtpUrls } from "../../../modules/smtp/urls";
import { setBackendErrors } from "../../../lib/set-backend-errors";
const NewSmtpConfigurationPage: NextPage = () => {
const router = useRouter();
const { notifySuccess, notifyError } = useDashboardNotification();
const { handleSubmit, control, setError, register } = useForm<SmtpCreateConfigurationInput>({
defaultValues: { encryption: "NONE" },
resolver: zodResolver(smtpCreateConfigurationInputSchema),
});
const { mutate } = trpcClient.smtpConfiguration.createConfiguration.useMutation({
onSuccess: async (data, variables) => {
notifySuccess("Configuration saved");
router.push(smtpUrls.configuration(data.id));
},
onError(error) {
setBackendErrors<SmtpCreateConfigurationInput>({
error,
setError,
notifyError,
});
},
});
return (
<BasicLayout
breadcrumbs={[
{ name: "Configuration", href: appUrls.configuration() },
{ name: "Add provider" },
{ name: "SMTP" },
]}
>
<Box display="grid" gridTemplateColumns={{ desktop: 3, mobile: 1 }}>
<Box>
<Text>Connect SMTP with Saleor.</Text>
</Box>
</Box>
<SectionWithDescription
title="Connect SMTP"
description={
<Text>
Provide unique name for your configuration - you can create more than one. For example -
production and development. Then, pass your API Key. Obtain it here.
</Text>
}
>
<BoxWithBorder>
<form
onSubmit={handleSubmit((data, event) => {
mutate({
...data,
});
})}
>
<Box padding={defaultPadding} display="flex" flexDirection="column" gap={10}>
<Input
name="name"
label="Configuration name"
control={control}
helperText="Name of the configuration, for example 'Production' or 'Test'"
/>
<Divider />
<Text variant="heading">SMTP server connection</Text>
<Input
label="Host"
helperText="Server host that will be connected."
control={control}
name="smtpHost"
/>
<Input
label="Port"
name="smtpPort"
control={control}
helperText="Port that will be used"
/>
<Input
label="User"
name="smtpUser"
control={control}
helperText="User for the SMTP server connection"
/>
<Input
label="Password"
name="smtpPassword"
control={control}
helperText="Password for the SMTP server connection"
/>
<Box display="flex" gap={defaultPadding}>
<label>
<input {...register("encryption")} type="radio" value="NONE" />
<Text paddingLeft={defaultPadding}>No encryption</Text>
</label>
<label>
<input {...register("encryption")} type="radio" value="SSL" />
<Text paddingLeft={defaultPadding}>SSL</Text>
</label>
<label>
<input {...register("encryption")} type="radio" value="TLS" />
<Text paddingLeft={defaultPadding}>TLS</Text>
</label>
</Box>
</Box>
<BoxFooter>
<Button type="submit">Save provider</Button>
</BoxFooter>
</form>
</BoxWithBorder>
</SectionWithDescription>
</BasicLayout>
);
};
export default NewSmtpConfigurationPage;

View file

@ -3,9 +3,9 @@ import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { useEffect } from "react";
import { useIsMounted } from "usehooks-ts";
import { useRouter } from "next/router";
import { LinearProgress } from "@material-ui/core";
import { isInIframe } from "../lib/is-in-iframe";
import { appName } from "../const";
import { appUrls } from "../modules/app-configuration/urls";
const IndexPage: NextPage = () => {
const { appBridgeState } = useAppBridge();
@ -14,12 +14,12 @@ const IndexPage: NextPage = () => {
useEffect(() => {
if (isMounted() && appBridgeState?.ready) {
replace("/configuration/channels");
replace(appUrls.configuration());
}
}, [isMounted, appBridgeState?.ready, replace]);
if (isInIframe()) {
return <LinearProgress />;
return <p>Loading</p>;
}
return (

View file

@ -0,0 +1,8 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M9.29289 6.29289C8.90237 6.68342 8.90237 7.31658 9.29289 7.70711L13.5858 12L9.29289 16.2929C8.90237 16.6834 8.90237 17.3166 9.29289 17.7071C9.68342 18.0976 10.3166 18.0976 10.7071 17.7071L15.7071 12.7071C16.0976 12.3166 16.0976 11.6834 15.7071 11.2929L10.7071 6.29289C10.3166 5.90237 9.68342 5.90237 9.29289 6.29289Z"
fill="currentColor"
></path>
</svg>

After

Width:  |  Height:  |  Size: 492 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

View 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

View file

@ -1,21 +1,16 @@
body {
font-family: Inter, -apple-system, "system-ui", "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell,
"Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
color: #111;
color: var(--mu-colors-foreground-text-neutral-plain);
padding: 0;
margin: 0;
background: var(--mu-colors-background-plain);
}
code {
background: #f6f8fa;
border: 1px solid #eaeaea;
border-radius: 5px;
display: inline-block;
margin-top: 10px;
padding: 0.75rem;
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
Bitstream Vera Sans Mono, Courier New, monospace;
letter-spacing: 0.1em;
color: var(--mu-colors-foreground-text-neutral-subdued);
}
code::before {
content: "$ ";
opacity: 0.6;
a {
text-decoration: none;
}

View file

@ -466,6 +466,9 @@ importers:
apps/emails-and-messages:
dependencies:
'@hookform/resolvers':
specifier: ^2.9.10
version: 2.9.11(react-hook-form@7.43.1)
'@monaco-editor/react':
specifier: ^4.4.6
version: 4.4.6(monaco-editor@0.38.0)(react-dom@18.2.0)(react@18.2.0)
@ -476,8 +479,8 @@ importers:
specifier: workspace:*
version: link:../../packages/shared
'@saleor/macaw-ui':
specifier: 0.8.0-pre.72
version: 0.8.0-pre.72(@types/react@18.0.27)(react-dom@18.2.0)(react@18.2.0)
specifier: 0.8.0-pre.83
version: 0.8.0-pre.83(@types/react@18.0.27)(react-dom@18.2.0)(react@18.2.0)
'@sendgrid/client':
specifier: ^7.7.0
version: 7.7.0
@ -506,8 +509,8 @@ importers:
specifier: ^1.0.0
version: 1.0.0(graphql@16.6.0)
'@vitejs/plugin-react':
specifier: ^3.0.1
version: 3.1.0(vite@4.3.1)
specifier: ^4.0.0
version: 4.0.0(vite@4.3.8)
clsx:
specifier: ^1.2.1
version: 1.2.1
@ -566,11 +569,11 @@ importers:
specifier: ^2.9.1
version: 2.9.1(react-dom@18.2.0)(react@18.2.0)
vite:
specifier: ^4.3.1
version: 4.3.1(@types/node@18.13.0)
specifier: ^4.3.8
version: 4.3.8(@types/node@18.13.0)
vitest:
specifier: ^0.30.1
version: 0.30.1(jsdom@20.0.3)
specifier: ^0.31.1
version: 0.31.1(jsdom@20.0.3)
zod:
specifier: ^3.20.2
version: 3.20.2
@ -2962,10 +2965,6 @@ packages:
dependencies:
'@babel/highlight': 7.18.6
/@babel/compat-data@7.20.14:
resolution: {integrity: sha512-0YpKHD6ImkWMEINCyDAD0HLLUH/lPCefG8ld9it8DJB2wnApraKuhgYTvTY1z7UFIfBTGy5LwncZ+5HWWGbhFw==}
engines: {node: '>=6.9.0'}
/@babel/compat-data@7.21.9:
resolution: {integrity: sha512-FUGed8kfhyWvbYug/Un/VPJD41rDIgoVVcR+FuzhzOYyRz5uED+Gd3SLZml0Uw2l2aHFb7ZgdW5mGA3G2cCCnQ==}
engines: {node: '>=6.9.0'}
@ -2975,15 +2974,15 @@ packages:
engines: {node: '>=6.9.0'}
dependencies:
'@ampproject/remapping': 2.2.0
'@babel/code-frame': 7.18.6
'@babel/generator': 7.20.14
'@babel/helper-compilation-targets': 7.20.7(@babel/core@7.20.12)
'@babel/helper-module-transforms': 7.20.11
'@babel/helpers': 7.20.13
'@babel/parser': 7.20.15
'@babel/code-frame': 7.21.4
'@babel/generator': 7.21.9
'@babel/helper-compilation-targets': 7.21.5(@babel/core@7.20.12)
'@babel/helper-module-transforms': 7.21.5
'@babel/helpers': 7.21.5
'@babel/parser': 7.21.9
'@babel/template': 7.20.7
'@babel/traverse': 7.20.13
'@babel/types': 7.20.7
'@babel/traverse': 7.21.5
'@babel/types': 7.21.5
convert-source-map: 1.9.0
debug: 4.3.4
gensync: 1.0.0-beta.2
@ -3021,6 +3020,7 @@ packages:
'@babel/types': 7.20.7
'@jridgewell/gen-mapping': 0.3.2
jsesc: 2.5.2
dev: true
/@babel/generator@7.21.9:
resolution: {integrity: sha512-F3fZga2uv09wFdEjEQIJxXALXfz0+JaOb7SabvVMmjHxeVTuGW8wgE8Vp1Hd7O+zMTYtcfEISGRzPkeiaPPsvg==}
@ -3045,15 +3045,15 @@ packages:
'@babel/types': 7.21.5
dev: true
/@babel/helper-compilation-targets@7.20.7(@babel/core@7.20.12):
resolution: {integrity: sha512-4tGORmfQcrc+bvrjb5y3dG9Mx1IOZjsHqQVUz7XCNHO+iTmqxWnVg3KRygjGmpRLJGdQSKuvFinbIb0CnZwHAQ==}
/@babel/helper-compilation-targets@7.21.5(@babel/core@7.20.12):
resolution: {integrity: sha512-1RkbFGUKex4lvsB9yhIfWltJM5cZKUftB2eNajaDv3dCMEp49iBG0K14uH8NnX9IPux2+mK7JGEOB0jn48/J6w==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0
dependencies:
'@babel/compat-data': 7.20.14
'@babel/compat-data': 7.21.9
'@babel/core': 7.20.12
'@babel/helper-validator-option': 7.18.6
'@babel/helper-validator-option': 7.21.0
browserslist: 4.21.5
lru-cache: 5.1.1
semver: 6.3.0
@ -3138,21 +3138,10 @@ packages:
- supports-color
dev: true
/@babel/helper-environment-visitor@7.18.9:
resolution: {integrity: sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg==}
engines: {node: '>=6.9.0'}
/@babel/helper-environment-visitor@7.21.5:
resolution: {integrity: sha512-IYl4gZ3ETsWocUWgsFZLM5i1BYx9SoemminVEXadgLBa9TdeorzgLKm8wWLA6J1N/kT3Kch8XIk1laNzYoHKvQ==}
engines: {node: '>=6.9.0'}
/@babel/helper-function-name@7.19.0:
resolution: {integrity: sha512-WAwHBINyrpqywkUH0nTnNgI5ina5TFn85HKS0pbPDfxFfhyR/aNQEn4hGi1P1JyT//I0t4OgXUlofzWILRvS5w==}
engines: {node: '>=6.9.0'}
dependencies:
'@babel/template': 7.20.7
'@babel/types': 7.21.5
/@babel/helper-function-name@7.21.0:
resolution: {integrity: sha512-HfK1aMRanKHpxemaY2gqBmL04iAPOPRj7DxtNbiDOrJK+gdwkiNRVpCpUJYbUT+aZyemKN8brqTOxzCaG6ExRg==}
engines: {node: '>=6.9.0'}
@ -3180,33 +3169,12 @@ packages:
'@babel/types': 7.21.5
dev: true
/@babel/helper-module-imports@7.18.6:
resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==}
engines: {node: '>=6.9.0'}
dependencies:
'@babel/types': 7.21.5
/@babel/helper-module-imports@7.21.4:
resolution: {integrity: sha512-orajc5T2PsRYUN3ZryCEFeMDYwyw09c/pZeaQEZPH0MpKzSvn3e0uXsDBu3k03VI+9DBiRo+l22BfKTpKwa/Wg==}
engines: {node: '>=6.9.0'}
dependencies:
'@babel/types': 7.21.5
/@babel/helper-module-transforms@7.20.11:
resolution: {integrity: sha512-uRy78kN4psmji1s2QtbtcCSaj/LILFDp0f/ymhpQH5QY3nljUZCaNWz9X1dEj/8MBdBEFECs7yRhKn8i7NjZgg==}
engines: {node: '>=6.9.0'}
dependencies:
'@babel/helper-environment-visitor': 7.18.9
'@babel/helper-module-imports': 7.18.6
'@babel/helper-simple-access': 7.20.2
'@babel/helper-split-export-declaration': 7.18.6
'@babel/helper-validator-identifier': 7.19.1
'@babel/template': 7.20.7
'@babel/traverse': 7.21.5
'@babel/types': 7.21.5
transitivePeerDependencies:
- supports-color
/@babel/helper-module-transforms@7.21.5:
resolution: {integrity: sha512-bI2Z9zBGY2q5yMHoBvJ2a9iX3ZOAzJPm7Q8Yz6YeoUjU/Cvhmi2G4QyTNyPBqqXSgTjUxRg3L0xV45HvkNWWBw==}
engines: {node: '>=6.9.0'}
@ -3229,14 +3197,9 @@ packages:
'@babel/types': 7.21.5
dev: true
/@babel/helper-plugin-utils@7.20.2:
resolution: {integrity: sha512-8RvlJG2mj4huQ4pZ+rU9lqKi9ZKiRmuvGuM2HlWmkmgOhbs6zEAw6IEiJ5cQqGbDzGZOhwuOQNtZMi/ENLjZoQ==}
engines: {node: '>=6.9.0'}
/@babel/helper-plugin-utils@7.21.5:
resolution: {integrity: sha512-0WDaIlXKOX/3KfBK/dwP1oQGiPh6rjMkT7HIRv7i5RR2VUMwrx5ZL0dwBkKx7+SW1zwNdgjHd34IMk5ZjTeHVg==}
engines: {node: '>=6.9.0'}
dev: true
/@babel/helper-remap-async-to-generator@7.18.9(@babel/core@7.21.8):
resolution: {integrity: sha512-dI7q50YKd8BAv3VEfgg7PS7yD3Rtbi2J1XMXaalXO0W0164hYLnh8zpjRS0mte9MfVp/tltvr/cfdXPvJr1opA==}
@ -3281,12 +3244,6 @@ packages:
- supports-color
dev: true
/@babel/helper-simple-access@7.20.2:
resolution: {integrity: sha512-+0woI/WPq59IrqDYbVGfshjT5Dmk/nnbdpcF8SnMhhXObpTq2KNBdLFRFrkVdbDOyUmHBCxzm5FHV1rACIkIbA==}
engines: {node: '>=6.9.0'}
dependencies:
'@babel/types': 7.21.5
/@babel/helper-simple-access@7.21.5:
resolution: {integrity: sha512-ENPDAMC1wAjR0uaCUwliBdiSl1KBJAVnMTzXqi64c2MG8MPR6ii4qf7bSXDqSFbr4W6W028/rf5ivoHop5/mkg==}
engines: {node: '>=6.9.0'}
@ -3318,10 +3275,6 @@ packages:
resolution: {integrity: sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==}
engines: {node: '>=6.9.0'}
/@babel/helper-validator-option@7.18.6:
resolution: {integrity: sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw==}
engines: {node: '>=6.9.0'}
/@babel/helper-validator-option@7.21.0:
resolution: {integrity: sha512-rmL/B8/f0mKS2baE9ZpyTcTavvEuWhTTW8amjzXNvYG4AwBsqTLikfXsEofsJEfKHf+HQVQbFOHy6o+4cnC/fQ==}
engines: {node: '>=6.9.0'}
@ -3338,16 +3291,6 @@ packages:
- supports-color
dev: true
/@babel/helpers@7.20.13:
resolution: {integrity: sha512-nzJ0DWCL3gB5RCXbUO3KIMMsBY2Eqbx8mBpKGE/02PgyRQFcPQLbkQ1vyy596mZLaP+dAfD+R4ckASzNVmW3jg==}
engines: {node: '>=6.9.0'}
dependencies:
'@babel/template': 7.20.7
'@babel/traverse': 7.21.5
'@babel/types': 7.21.5
transitivePeerDependencies:
- supports-color
/@babel/helpers@7.21.5:
resolution: {integrity: sha512-BSY+JSlHxOmGsPTydUkPf1MdMQ3M81x5xGCOVgWM3G8XH77sJ292Y2oqcp0CbbgxhqBuI46iUz1tT7hqP7EfgA==}
engines: {node: '>=6.9.0'}
@ -3371,7 +3314,7 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
dependencies:
'@babel/types': 7.20.7
'@babel/types': 7.21.5
/@babel/parser@7.21.9:
resolution: {integrity: sha512-q5PNg/Bi1OpGgx5jYlvWZwAorZepEudDMCLtj967aeS7WMont7dUZI46M2XwcIQqvUlMxWfdLFu4S/qSxeUu5g==}
@ -3677,7 +3620,7 @@ packages:
'@babel/core': ^7.0.0-0
dependencies:
'@babel/core': 7.21.8
'@babel/helper-plugin-utils': 7.20.2
'@babel/helper-plugin-utils': 7.21.5
/@babel/plugin-syntax-jsx@7.21.4(@babel/core@7.21.8):
resolution: {integrity: sha512-5hewiLct5OKyh6PLKEYaFclcqtIgCb6bmELouxjF6up5q3Sov7rOayW4RwhbaBL0dit8rA80GNfY+UuDp2mBbQ==}
@ -3770,7 +3713,7 @@ packages:
'@babel/core': ^7.0.0-0
dependencies:
'@babel/core': 7.21.8
'@babel/helper-plugin-utils': 7.20.2
'@babel/helper-plugin-utils': 7.21.5
dev: true
/@babel/plugin-transform-arrow-functions@7.21.5(@babel/core@7.21.8):
@ -4080,7 +4023,7 @@ packages:
'@babel/core': ^7.0.0-0
dependencies:
'@babel/core': 7.20.12
'@babel/helper-plugin-utils': 7.20.2
'@babel/helper-plugin-utils': 7.21.5
/@babel/plugin-transform-react-jsx-self@7.21.0(@babel/core@7.21.8):
resolution: {integrity: sha512-f/Eq+79JEu+KUANFks9UZCcvydOOGMgF7jBrcwjHa5jTZD8JivnhCJYvmlhR/WTXBWonDExPoW0eO/CR4QJirA==}
@ -4089,8 +4032,7 @@ packages:
'@babel/core': ^7.0.0-0
dependencies:
'@babel/core': 7.21.8
'@babel/helper-plugin-utils': 7.20.2
dev: true
'@babel/helper-plugin-utils': 7.21.5
/@babel/plugin-transform-react-jsx-source@7.19.6(@babel/core@7.20.12):
resolution: {integrity: sha512-RpAi004QyMNisst/pvSanoRdJ4q+jMCWyk9zdw/CyLB9j8RXEahodR6l2GyttDRyEVWZtbN+TpLiHJ3t34LbsQ==}
@ -4099,7 +4041,7 @@ packages:
'@babel/core': ^7.0.0-0
dependencies:
'@babel/core': 7.20.12
'@babel/helper-plugin-utils': 7.20.2
'@babel/helper-plugin-utils': 7.21.5
/@babel/plugin-transform-react-jsx-source@7.19.6(@babel/core@7.21.8):
resolution: {integrity: sha512-RpAi004QyMNisst/pvSanoRdJ4q+jMCWyk9zdw/CyLB9j8RXEahodR6l2GyttDRyEVWZtbN+TpLiHJ3t34LbsQ==}
@ -4108,8 +4050,7 @@ packages:
'@babel/core': ^7.0.0-0
dependencies:
'@babel/core': 7.21.8
'@babel/helper-plugin-utils': 7.20.2
dev: true
'@babel/helper-plugin-utils': 7.21.5
/@babel/plugin-transform-react-jsx@7.20.13(@babel/core@7.21.8):
resolution: {integrity: sha512-MmTZx/bkUrfJhhYAYt3Urjm+h8DQGrPrnKQ94jLo7NLuOU+T89a7IByhKmrb8SKhrIYIQ0FN0CHMbnFRen4qNw==}
@ -4120,7 +4061,7 @@ packages:
'@babel/core': 7.21.8
'@babel/helper-annotate-as-pure': 7.18.6
'@babel/helper-module-imports': 7.21.4
'@babel/helper-plugin-utils': 7.20.2
'@babel/helper-plugin-utils': 7.21.5
'@babel/plugin-syntax-jsx': 7.18.6(@babel/core@7.21.8)
'@babel/types': 7.21.5
dev: true
@ -4393,23 +4334,6 @@ packages:
'@babel/parser': 7.20.15
'@babel/types': 7.20.7
/@babel/traverse@7.20.13:
resolution: {integrity: sha512-kMJXfF0T6DIS9E8cgdLCSAL+cuCK+YEZHWiLK0SXpTo8YRj5lpJu3CDNKiIBCne4m9hhTIqUg6SYTAI39tAiVQ==}
engines: {node: '>=6.9.0'}
dependencies:
'@babel/code-frame': 7.21.4
'@babel/generator': 7.21.9
'@babel/helper-environment-visitor': 7.18.9
'@babel/helper-function-name': 7.19.0
'@babel/helper-hoist-variables': 7.18.6
'@babel/helper-split-export-declaration': 7.18.6
'@babel/parser': 7.21.9
'@babel/types': 7.21.5
debug: 4.3.4
globals: 11.12.0
transitivePeerDependencies:
- supports-color
/@babel/traverse@7.21.5:
resolution: {integrity: sha512-AhQoI3YjWi6u/y/ntv7k48mcrCXmus0t79J9qPNlk/lAsFlCiJ047RmbfMOawySTHtywXhbXgpx/8nXMYd+oFw==}
engines: {node: '>=6.9.0'}
@ -4676,7 +4600,7 @@ packages:
'@babel/core': ^7.0.0
dependencies:
'@babel/core': 7.21.8
'@babel/helper-module-imports': 7.18.6
'@babel/helper-module-imports': 7.21.4
'@babel/plugin-syntax-jsx': 7.18.6(@babel/core@7.21.8)
'@babel/runtime': 7.20.13
'@emotion/hash': 0.9.0
@ -5994,10 +5918,10 @@ packages:
peerDependencies:
graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
dependencies:
'@babel/parser': 7.20.15
'@babel/parser': 7.21.9
'@babel/plugin-syntax-import-assertions': 7.20.0(@babel/core@7.21.8)
'@babel/traverse': 7.20.13
'@babel/types': 7.20.7
'@babel/traverse': 7.21.5
'@babel/types': 7.21.5
'@graphql-tools/utils': 9.2.1(graphql@16.6.0)
graphql: 16.6.0
tslib: 2.5.0
@ -7841,8 +7765,8 @@ packages:
transitivePeerDependencies:
- '@types/react'
/@saleor/macaw-ui@0.8.0-pre.72(@types/react@18.0.27)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-9lcFkzf81q9Mxjqd00rWUUvom26YK3WCu8GCcmpqcEFu723/H76hxg2/LUd2cpqARavS1FgO+Vri7jkxkSz7sQ==}
/@saleor/macaw-ui@0.8.0-pre.83(@types/react@18.0.27)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-IMwN8a39ZqjHoO7P3pAQ6kWxqX5U4EhuOgunpR8QU0v6Tm5DfIeUmIr/3jwm5jQbYzZrNNgaboBJy7oCbJ/cUQ==}
engines: {node: '>=16 <19', pnpm: '>=8'}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
@ -9848,7 +9772,7 @@ packages:
resolution: {integrity: sha512-d9ULIT+a4EXLX3UU8FBjauG9NnsZHkHztXoIcTsOKoOw030fyjheN9svkTULjJxtYag9DZz5Jz5qkWZDPxTFwA==}
engines: {node: '>=12'}
dependencies:
'@babel/code-frame': 7.18.6
'@babel/code-frame': 7.21.4
'@babel/runtime': 7.20.13
'@types/aria-query': 5.0.1
aria-query: 5.1.3
@ -10108,11 +10032,14 @@ packages:
/@types/chai-subset@1.3.3:
resolution: {integrity: sha512-frBecisrNGz+F4T6bcc+NLeolfiojh5FxW2klu669+8BARtyQv2C/GkNW6FUodVe4BroGMP/wER/YDGc7rEllw==}
dependencies:
'@types/chai': 4.3.4
'@types/chai': 4.3.5
/@types/chai@4.3.4:
resolution: {integrity: sha512-KnRanxnpfpjUTqTCXslZSEdLfXExwgNxYPdiO2WGUj8+HDjFi8R3k5RVKPeSCzLjCcshCAtVO2QBbVuAV4kTnw==}
/@types/chai@4.3.5:
resolution: {integrity: sha512-mEo1sAde+UCE6b2hxn332f1g1E8WfYRu6p5SvTKr2ZKC1f7gFJXk4h5PyGP9Dt6gCaG8y8XhwnXWC6Iy2cmBng==}
/@types/connect@3.4.35:
resolution: {integrity: sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==}
dependencies:
@ -10880,7 +10807,7 @@ packages:
lodash: 4.17.21
mlly: 1.2.0
outdent: 0.8.0
vite: 4.3.6(@types/node@18.13.0)
vite: 4.3.8(@types/node@18.13.0)
vite-node: 0.28.5
transitivePeerDependencies:
- '@types/node'
@ -10963,6 +10890,21 @@ packages:
- supports-color
dev: true
/@vitejs/plugin-react@4.0.0(vite@4.3.8):
resolution: {integrity: sha512-HX0XzMjL3hhOYm+0s95pb0Z7F8O81G7joUHgfDd/9J/ZZf5k4xX6QAMFkKsHFxaHlf6X7GD7+XuaZ66ULiJuhQ==}
engines: {node: ^14.18.0 || >=16.0.0}
peerDependencies:
vite: ^4.2.0
dependencies:
'@babel/core': 7.21.8
'@babel/plugin-transform-react-jsx-self': 7.21.0(@babel/core@7.21.8)
'@babel/plugin-transform-react-jsx-source': 7.19.6(@babel/core@7.21.8)
react-refresh: 0.14.0
vite: 4.3.8(@types/node@18.13.0)
transitivePeerDependencies:
- supports-color
dev: false
/@vitest/coverage-c8@0.28.4(jsdom@20.0.3):
resolution: {integrity: sha512-btelLBxaWhHnywXRQxDlrvPhGdnuIaD3XulsxcZRIcnpLPbFu39dNTT0IYu2QWP2ZZrV0AmNtdLIfD4c77zMAg==}
dependencies:
@ -10999,6 +10941,14 @@ packages:
'@vitest/utils': 0.30.1
chai: 4.3.7
/@vitest/expect@0.31.1:
resolution: {integrity: sha512-BV1LyNvhnX+eNYzJxlHIGPWZpwJFZaCcOIzp2CNG0P+bbetenTupk6EO0LANm4QFt0TTit+yqx7Rxd1qxi/SQA==}
dependencies:
'@vitest/spy': 0.31.1
'@vitest/utils': 0.31.1
chai: 4.3.7
dev: false
/@vitest/runner@0.28.4:
resolution: {integrity: sha512-Q8UV6GjDvBSTfUoq0QXVCNpNOUrWu4P2qvRq7ssJWzn0+S0ojbVOxEjMt+8a32X6SdkhF8ak+2nkppsqV0JyNQ==}
dependencies:
@ -11015,6 +10965,15 @@ packages:
p-limit: 4.0.0
pathe: 1.1.0
/@vitest/runner@0.31.1:
resolution: {integrity: sha512-imWuc82ngOtxdCUpXwtEzZIuc1KMr+VlQ3Ondph45VhWoQWit5yvG/fFcldbnCi8DUuFi+NmNx5ehMUw/cGLUw==}
dependencies:
'@vitest/utils': 0.31.1
concordance: 5.0.4
p-limit: 4.0.0
pathe: 1.1.0
dev: false
/@vitest/snapshot@0.30.1:
resolution: {integrity: sha512-fJZqKrE99zo27uoZA/azgWyWbFvM1rw2APS05yB0JaLwUIg9aUtvvnBf4q7JWhEcAHmSwbrxKFgyBUga6tq9Tw==}
dependencies:
@ -11022,6 +10981,14 @@ packages:
pathe: 1.1.0
pretty-format: 27.5.1
/@vitest/snapshot@0.31.1:
resolution: {integrity: sha512-L3w5uU9bMe6asrNzJ8WZzN+jUTX4KSgCinEJPXyny0o90fG4FPQMV0OWsq7vrCWfQlAilMjDnOF9nP8lidsJ+g==}
dependencies:
magic-string: 0.30.0
pathe: 1.1.0
pretty-format: 27.5.1
dev: false
/@vitest/spy@0.28.4:
resolution: {integrity: sha512-8WuhfXLlvCXpNXEGJW6Gc+IKWI32435fQJLh43u70HnZ1otJOa2Cmg2Wy2Aym47ZnNCP4NolF+8cUPwd0MigKQ==}
dependencies:
@ -11033,6 +11000,12 @@ packages:
dependencies:
tinyspy: 2.1.0
/@vitest/spy@0.31.1:
resolution: {integrity: sha512-1cTpt2m9mdo3hRLDyCG2hDQvRrePTDgEJBFQQNz1ydHHZy03EiA6EpFxY+7ODaY7vMRCie+WlFZBZ0/dQWyssQ==}
dependencies:
tinyspy: 2.1.0
dev: false
/@vitest/utils@0.28.4:
resolution: {integrity: sha512-l2QztOLdc2LkR+w/lP52RGh8hW+Ul4KESmCAgVE8q737I7e7bQoAfkARKpkPJ4JQtGpwW4deqlj1732VZD7TFw==}
dependencies:
@ -11050,6 +11023,14 @@ packages:
loupe: 2.3.6
pretty-format: 27.5.1
/@vitest/utils@0.31.1:
resolution: {integrity: sha512-yFyRD5ilwojsZfo3E0BnH72pSVSuLg2356cN1tCEe/0RtDzxTPYwOomIC+eQbot7m6DRy4tPZw+09mB7NkbMmA==}
dependencies:
concordance: 5.0.4
loupe: 2.3.6
pretty-format: 27.5.1
dev: false
/@web-std/blob@3.0.4:
resolution: {integrity: sha512-+dibyiw+uHYK4dX5cJ7HA+gtDAaUUe6JsOryp2ZpAC7h4ICsh49E34JwHoEKPlPvP0llCrNzz45vvD+xX5QDBg==}
dependencies:
@ -18834,7 +18815,7 @@ packages:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
engines: {node: '>=8'}
dependencies:
'@babel/code-frame': 7.18.6
'@babel/code-frame': 7.21.4
error-ex: 1.3.2
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4
@ -19100,7 +19081,7 @@ packages:
resolution: {integrity: sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig==}
engines: {node: ^10 || ^12 || >=14}
dependencies:
nanoid: 3.3.4
nanoid: 3.3.6
picocolors: 1.0.0
source-map-js: 1.0.2
@ -21256,6 +21237,10 @@ packages:
/tinybench@2.4.0:
resolution: {integrity: sha512-iyziEiyFxX4kyxSp+MtY1oCH/lvjH3PxFN8PGCDeqcZWAJ/i+9y+nL85w99PxVzrIvew/GSkSbDYtiGVa85Afg==}
/tinybench@2.5.0:
resolution: {integrity: sha512-kRwSG8Zx4tjF9ZiyH4bhaebu+EDz1BOx9hOigYHlUW4xxI/wKIUQUqo018UlU4ar6ATPBsaMrdbKZ+tmPdohFA==}
dev: false
/tinypool@0.3.1:
resolution: {integrity: sha512-zLA1ZXlstbU2rlpA4CIeVaqvWq41MTWqLY3FfsAXgC8+f7Pk7zroaJQxDgxn1xNudKW6Kmj4808rPFShUlIRmQ==}
engines: {node: '>=14.0.0'}
@ -21265,6 +21250,11 @@ packages:
resolution: {integrity: sha512-2ksntHOKf893wSAH4z/+JbPpi92esw8Gn9N2deXX+B0EO92hexAVI9GIZZPx7P5aYo5KULfeOSt3kMOmSOy6uA==}
engines: {node: '>=14.0.0'}
/tinypool@0.5.0:
resolution: {integrity: sha512-paHQtnrlS1QZYKF/GnLoOM/DN9fqaGOFbCbxzAhwniySnzl9Ebk8w73/dd34DAhe/obUbPAOldTyYXQZxnPBPQ==}
engines: {node: '>=14.0.0'}
dev: false
/tinyspy@1.1.1:
resolution: {integrity: sha512-UVq5AXt/gQlti7oxoIg5oi/9r0WpF7DGEVwXgqWSMmyN16+e3tl5lIvTaOpJ3TAtu5xFzWccFRM4R5NaWHF+4g==}
engines: {node: '>=14.0.0'}
@ -22156,7 +22146,7 @@ packages:
picocolors: 1.0.0
source-map: 0.6.1
source-map-support: 0.5.21
vite: 4.3.6(@types/node@18.13.0)
vite: 4.3.8(@types/node@18.13.0)
transitivePeerDependencies:
- '@types/node'
- less
@ -22179,7 +22169,7 @@ packages:
picocolors: 1.0.0
source-map: 0.6.1
source-map-support: 0.5.21
vite: 4.3.6(@types/node@18.13.0)
vite: 4.3.8(@types/node@18.13.0)
transitivePeerDependencies:
- '@types/node'
- less
@ -22200,7 +22190,7 @@ packages:
mlly: 1.2.0
pathe: 1.1.0
picocolors: 1.0.0
vite: 4.3.6(@types/node@18.13.0)
vite: 4.3.8(@types/node@18.13.0)
transitivePeerDependencies:
- '@types/node'
- less
@ -22210,6 +22200,27 @@ packages:
- supports-color
- terser
/vite-node@0.31.1(@types/node@18.13.0):
resolution: {integrity: sha512-BajE/IsNQ6JyizPzu9zRgHrBwczkAs0erQf/JRpgTIESpKvNj9/Gd0vxX905klLkb0I0SJVCKbdrl5c6FnqYKA==}
engines: {node: '>=v14.18.0'}
hasBin: true
dependencies:
cac: 6.7.14
debug: 4.3.4
mlly: 1.2.0
pathe: 1.1.0
picocolors: 1.0.0
vite: 4.3.8(@types/node@18.13.0)
transitivePeerDependencies:
- '@types/node'
- less
- sass
- stylus
- sugarss
- supports-color
- terser
dev: false
/vite@4.3.1(@types/node@18.0.1):
resolution: {integrity: sha512-EPmfPLAI79Z/RofuMvkIS0Yr091T2ReUoXQqc5ppBX/sjFRhHKiPPF/R46cTdoci/XgeQpB23diiJxq5w30vdg==}
engines: {node: ^14.18.0 || >=16.0.0}
@ -22237,8 +22248,8 @@ packages:
dependencies:
'@types/node': 18.0.1
esbuild: 0.17.17
postcss: 8.4.23
rollup: 3.23.0
postcss: 8.4.21
rollup: 3.20.5
optionalDependencies:
fsevents: 2.3.2
@ -22306,6 +22317,38 @@ packages:
optionalDependencies:
fsevents: 2.3.2
/vite@4.3.8(@types/node@18.13.0):
resolution: {integrity: sha512-uYB8PwN7hbMrf4j1xzGDk/lqjsZvCDbt/JC5dyfxc19Pg8kRm14LinK/uq+HSLNswZEoKmweGdtpbnxRtrAXiQ==}
engines: {node: ^14.18.0 || >=16.0.0}
hasBin: true
peerDependencies:
'@types/node': '>= 14'
less: '*'
sass: '*'
stylus: '*'
sugarss: '*'
terser: ^5.4.0
peerDependenciesMeta:
'@types/node':
optional: true
less:
optional: true
sass:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
dependencies:
'@types/node': 18.13.0
esbuild: 0.17.17
postcss: 8.4.23
rollup: 3.23.0
optionalDependencies:
fsevents: 2.3.2
/vitest@0.28.4(jsdom@20.0.3):
resolution: {integrity: sha512-sfWIy0AdlbyGRhunm+TLQEJrFH9XuRPdApfubsyLcDbCRrUX717BRQKInTgzEfyl2Ipi1HWoHB84Nqtcwxogcg==}
engines: {node: '>=v14.16.0'}
@ -22350,7 +22393,7 @@ packages:
tinybench: 2.4.0
tinypool: 0.3.1
tinyspy: 1.1.1
vite: 4.3.6(@types/node@18.13.0)
vite: 4.3.8(@types/node@18.13.0)
vite-node: 0.28.4(@types/node@18.13.0)
why-is-node-running: 2.2.2
transitivePeerDependencies:
@ -22428,6 +22471,72 @@ packages:
- supports-color
- terser
/vitest@0.31.1(jsdom@20.0.3):
resolution: {integrity: sha512-/dOoOgzoFk/5pTvg1E65WVaobknWREN15+HF+0ucudo3dDG/vCZoXTQrjIfEaWvQXmqScwkRodrTbM/ScMpRcQ==}
engines: {node: '>=v14.18.0'}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@vitest/browser': '*'
'@vitest/ui': '*'
happy-dom: '*'
jsdom: '*'
playwright: '*'
safaridriver: '*'
webdriverio: '*'
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@vitest/browser':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
playwright:
optional: true
safaridriver:
optional: true
webdriverio:
optional: true
dependencies:
'@types/chai': 4.3.5
'@types/chai-subset': 1.3.3
'@types/node': 18.13.0
'@vitest/expect': 0.31.1
'@vitest/runner': 0.31.1
'@vitest/snapshot': 0.31.1
'@vitest/spy': 0.31.1
'@vitest/utils': 0.31.1
acorn: 8.8.2
acorn-walk: 8.2.0
cac: 6.7.14
chai: 4.3.7
concordance: 5.0.4
debug: 4.3.4
jsdom: 20.0.3
local-pkg: 0.4.3
magic-string: 0.30.0
pathe: 1.1.0
picocolors: 1.0.0
std-env: 3.3.2
strip-literal: 1.0.1
tinybench: 2.5.0
tinypool: 0.5.0
vite: 4.3.8(@types/node@18.13.0)
vite-node: 0.31.1(@types/node@18.13.0)
why-is-node-running: 2.2.2
transitivePeerDependencies:
- less
- sass
- stylus
- sugarss
- supports-color
- terser
dev: false
/void-elements@3.1.0:
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
engines: {node: '>=0.10.0'}