Update the sendgrid support (#321)

* Update the sendgrid support

* Add changeset
This commit is contained in:
Krzysztof Wolski 2023-03-24 15:33:48 +01:00 committed by GitHub
parent f58043f72b
commit 14ac6144c0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 1401 additions and 619 deletions

View file

@ -0,0 +1,5 @@
---
"saleor-app-emails-and-messages": minor
---
Enable Sendgrid support

View file

@ -59,18 +59,13 @@ export const ChannelsConfigurationTab = () => {
}, [mjmlConfigurations]);
const { data: sendgridConfigurations, isLoading: isSendgridQueryLoading } =
trpcClient.sendgridConfiguration.fetch.useQuery();
trpcClient.sendgridConfiguration.getConfigurations.useQuery({});
const sendgridConfigurationsListData = useMemo(() => {
if (!sendgridConfigurations) {
return [];
}
const keys = Object.keys(sendgridConfigurations.availableConfigurations ?? {}) || [];
return (
keys.map((key) => ({
value: key,
label: sendgridConfigurations.availableConfigurations[key].configurationName,
sendgridConfigurations?.map((configuration) => ({
value: configuration.id,
label: configuration.configurationName,
})) ?? []
);
}, [sendgridConfigurations]);

View file

@ -4,6 +4,7 @@ import { logger as pinoLogger } from "../../lib/logger";
import { AppConfigurationService } from "../app-configuration/get-app-configuration.service";
import { MjmlConfigurationService } from "../mjml/configuration/get-mjml-configuration.service";
import { sendMjml } from "../mjml/send-mjml";
import { SendgridConfigurationService } from "../sendgrid/configuration/get-sendgrid-configuration.service";
import { sendSendgrid } from "../sendgrid/send-sendgrid";
import { MessageEventTypes } from "./message-event-types";
@ -73,16 +74,30 @@ export const sendEventMessages = async ({
}
}
}
if (channelAppConfiguration.sendgridConfigurationId) {
logger.debug("Channel has assigned Sendgrid configuration");
const sendgridConfigurationService = new SendgridConfigurationService({
apiClient: client,
saleorApiUrl: authData.saleorApiUrl,
});
const sendgridConfiguration = await sendgridConfigurationService.getConfiguration({
id: channelAppConfiguration.sendgridConfigurationId,
});
if (sendgridConfiguration) {
const sendgridStatus = await sendSendgrid({
authData,
channel,
event,
payload,
recipientEmail,
sendgridConfiguration,
});
if (sendgridStatus?.errors.length) {
logger.error("Sending message with Sendgrid has failed");
logger.error("Sendgrid errors");
logger.error(sendgridStatus?.errors);
}
}
}
};

View file

@ -1,36 +1,107 @@
import { PrivateMetadataSendgridConfigurator } from "./sendgrid-configurator";
import { SendgridConfigurator, PrivateMetadataSendgridConfigurator } from "./sendgrid-configurator";
import { Client } from "urql";
import { logger as pinoLogger } from "../../../lib/logger";
import { SendgridConfig, SendgridConfiguration } from "./sendgrid-config";
import { FilterConfigurationsArgs, SendgridConfigContainer } from "./sendgrid-config-container";
import { createSettingsManager } from "../../../lib/metadata-manager";
// todo test
export class GetSendgridConfigurationService {
constructor(
private settings: {
apiClient: Client;
saleorApiUrl: string;
}
) {}
const logger = pinoLogger.child({
service: "SendgridConfigurationService",
});
async getConfiguration() {
const logger = pinoLogger.child({
service: "GetSendgridConfigurationService",
saleorApiUrl: this.settings.saleorApiUrl,
});
export class SendgridConfigurationService {
private configurationData?: SendgridConfig;
private metadataConfigurator: SendgridConfigurator;
const { saleorApiUrl, apiClient } = this.settings;
const sendgridConfigurator = new PrivateMetadataSendgridConfigurator(
createSettingsManager(apiClient),
saleorApiUrl
constructor(args: { apiClient: Client; saleorApiUrl: string; initialData?: SendgridConfig }) {
this.metadataConfigurator = new PrivateMetadataSendgridConfigurator(
createSettingsManager(args.apiClient),
args.saleorApiUrl
);
const savedSendgridConfig = (await sendgridConfigurator.getConfig()) ?? null;
logger.debug(savedSendgridConfig, "Retrieved sendgrid config from Metadata. Will return it");
if (savedSendgridConfig) {
return savedSendgridConfig;
if (args.initialData) {
this.configurationData = args.initialData;
}
}
// Fetch configuration from Saleor API and cache it
private async pullConfiguration() {
logger.debug("Fetch configuration from Saleor API");
const config = await this.metadataConfigurator.getConfig();
this.configurationData = config;
}
// Push configuration to Saleor API
private async pushConfiguration() {
logger.debug("Push configuration to Saleor API");
await this.metadataConfigurator.setConfig(this.configurationData!);
}
// Returns configuration from cache or fetches it from Saleor API
async getConfigurationRoot() {
logger.debug("Get configuration root");
if (this.configurationData) {
logger.debug("Using cached configuration");
return this.configurationData;
}
// No cached data, fetch it from Saleor API
await this.pullConfiguration();
if (!this.configurationData) {
logger.warn("No configuration found in Saleor API");
return;
}
return this.configurationData;
}
// Saves configuration to Saleor API and cache it
async setConfigurationRoot(config: SendgridConfig) {
logger.debug("Set configuration root");
this.configurationData = config;
await this.pushConfiguration();
}
async getConfiguration({ id }: { id: string }) {
logger.debug("Get configuration");
return SendgridConfigContainer.getConfiguration(await this.getConfigurationRoot())({ id });
}
async getConfigurations(filter?: FilterConfigurationsArgs) {
logger.debug("Get configuration");
return SendgridConfigContainer.getConfigurations(await this.getConfigurationRoot())(filter);
}
async createConfiguration(config: Omit<SendgridConfiguration, "id" | "events">) {
logger.debug("Create configuration");
const updatedConfigurationRoot = SendgridConfigContainer.createConfiguration(
await this.getConfigurationRoot()
)(config);
await this.setConfigurationRoot(updatedConfigurationRoot);
return updatedConfigurationRoot.configurations[
updatedConfigurationRoot.configurations.length - 1
];
}
async updateConfiguration(config: SendgridConfiguration) {
logger.debug("Update configuration");
const updatedConfigurationRoot = SendgridConfigContainer.updateConfiguration(
await this.getConfigurationRoot()
)(config);
this.setConfigurationRoot(updatedConfigurationRoot);
}
async deleteConfiguration({ id }: { id: string }) {
logger.debug("Delete configuration");
const updatedConfigurationRoot = SendgridConfigContainer.deleteConfiguration(
await this.getConfigurationRoot()
)({ id });
this.setConfigurationRoot(updatedConfigurationRoot);
}
}

View file

@ -1,60 +1,120 @@
import { SendgridConfig as SendgridConfig, SendgridConfiguration } from "./sendgrid-config";
import { messageEventTypes } from "../../event-handlers/message-event-types";
import {
SendgridConfig as SendgridConfigurationRoot,
SendgridConfiguration,
} from "./sendgrid-config";
export const getDefaultEmptySendgridConfiguration = (): SendgridConfiguration => {
const defaultConfig = {
active: false,
export const generateSendgridConfigurationId = () => Date.now().toString();
export const getDefaultEventsConfiguration = (): SendgridConfiguration["events"] =>
messageEventTypes.map((eventType) => ({
active: true,
eventType: eventType,
template: "",
}));
export const getDefaultEmptyConfiguration = (): SendgridConfiguration => {
const defaultConfig: SendgridConfiguration = {
id: "",
active: true,
configurationName: "",
sandboxMode: false,
senderName: "",
senderEmail: "",
senderName: undefined,
senderEmail: undefined,
apiKey: "",
templateInvoiceSentSubject: "Invoice sent",
templateInvoiceSentTemplate: "",
templateOrderCancelledSubject: "Order Cancelled",
templateOrderCancelledTemplate: "",
templateOrderConfirmedSubject: "Order Confirmed",
templateOrderConfirmedTemplate: "",
templateOrderFullyPaidSubject: "Order Fully Paid",
templateOrderFullyPaidTemplate: "",
templateOrderCreatedSubject: "Order created",
templateOrderCreatedTemplate: "",
templateOrderFulfilledSubject: "Order fulfilled",
templateOrderFulfilledTemplate: "",
sandboxMode: false,
events: getDefaultEventsConfiguration(),
};
return defaultConfig;
};
const getSendgridConfigurationById =
(sendgridConfig: SendgridConfig | null | undefined) => (configurationId?: string) => {
if (!configurationId?.length) {
return getDefaultEmptySendgridConfiguration();
interface GetConfigurationArgs {
id: string;
}
const getConfiguration =
(sendgridConfigRoot: SendgridConfigurationRoot | null | undefined) =>
({ id }: GetConfigurationArgs) => {
if (!sendgridConfigRoot || !sendgridConfigRoot.configurations) {
return;
}
const existingConfig = sendgridConfig?.availableConfigurations[configurationId];
if (!existingConfig) {
return getDefaultEmptySendgridConfiguration();
}
return existingConfig;
return sendgridConfigRoot.configurations.find((c) => c.id === id);
};
const setSendgridConfigurationById =
(sendgridConfig: SendgridConfig | null | undefined) =>
(configurationId: string | undefined) =>
(sendgridConfiguration: SendgridConfiguration) => {
const sendgridConfigNormalized = structuredClone(sendgridConfig) ?? {
availableConfigurations: {},
export interface FilterConfigurationsArgs {
ids?: string[];
active?: boolean;
}
const getConfigurations =
(sendgridConfigRoot: SendgridConfigurationRoot | null | undefined) =>
(filter: FilterConfigurationsArgs | undefined): SendgridConfiguration[] => {
if (!sendgridConfigRoot || !sendgridConfigRoot.configurations) {
return [];
}
let filtered = sendgridConfigRoot.configurations;
if (filter?.ids?.length) {
filtered = filtered.filter((c) => filter?.ids?.includes(c.id));
}
if (filter?.active !== undefined) {
filtered = filtered.filter((c) => c.active === filter.active);
}
return filtered;
};
const createConfiguration =
(sendgridConfigRoot: SendgridConfigurationRoot | null | undefined) =>
(sendgridConfiguration: Omit<SendgridConfiguration, "id" | "events">) => {
const sendgridConfigNormalized = structuredClone(sendgridConfigRoot) ?? { configurations: [] };
// for creating a new configurations, the ID has to be generated
const id = configurationId || Date.now();
sendgridConfigNormalized.availableConfigurations[id] ??= getDefaultEmptySendgridConfiguration();
const newConfiguration = {
...sendgridConfiguration,
id: generateSendgridConfigurationId(),
events: getDefaultEventsConfiguration(),
};
sendgridConfigNormalized.configurations.push(newConfiguration);
return sendgridConfigNormalized;
};
sendgridConfigNormalized.availableConfigurations[id] = sendgridConfiguration;
const updateConfiguration =
(sendgridConfig: SendgridConfigurationRoot | null | undefined) =>
(sendgridConfiguration: SendgridConfiguration) => {
const sendgridConfigNormalized = structuredClone(sendgridConfig) ?? { configurations: [] };
const configurationIndex = sendgridConfigNormalized.configurations.findIndex(
(configuration) => configuration.id === sendgridConfiguration.id
);
sendgridConfigNormalized.configurations[configurationIndex] = sendgridConfiguration;
return sendgridConfigNormalized;
};
interface DeleteConfigurationArgs {
id: string;
}
const deleteConfiguration =
(sendgridConfig: SendgridConfigurationRoot | null | undefined) =>
({ id }: DeleteConfigurationArgs) => {
const sendgridConfigNormalized = structuredClone(sendgridConfig) ?? { configurations: [] };
sendgridConfigNormalized.configurations = sendgridConfigNormalized.configurations.filter(
(configuration) => configuration.id !== id
);
return sendgridConfigNormalized;
};
export const SendgridConfigContainer = {
getSendgridConfigurationById,
setSendgridConfigurationById,
createConfiguration,
getConfiguration,
updateConfiguration,
deleteConfiguration,
getConfigurations,
};

View file

@ -1,26 +1,51 @@
import { z } from "zod";
import { messageEventTypes } from "../../event-handlers/message-event-types";
export const sendgridConfigInputSchema = z.object({
availableConfigurations: z.record(
z.object({
export const sendgridConfigurationEventObjectSchema = z.object({
active: z.boolean(),
eventType: z.enum(messageEventTypes),
template: z.string().min(1),
});
export const sendgridConfigurationBaseObjectSchema = z.object({
active: z.boolean(),
configurationName: z.string().min(1),
sandboxMode: z.boolean(),
senderName: z.string().min(0),
senderEmail: z.string().email(),
apiKey: z.string().min(0),
templateInvoiceSentSubject: z.string(),
templateInvoiceSentTemplate: z.string(),
templateOrderCancelledSubject: z.string(),
templateOrderCancelledTemplate: z.string(),
templateOrderConfirmedSubject: z.string(),
templateOrderConfirmedTemplate: z.string(),
templateOrderFullyPaidSubject: z.string(),
templateOrderFullyPaidTemplate: z.string(),
templateOrderCreatedSubject: z.string(),
templateOrderCreatedTemplate: z.string(),
templateOrderFulfilledSubject: z.string(),
templateOrderFulfilledTemplate: z.string(),
})
),
apiKey: z.string().min(1),
senderName: z.string().min(1).optional(),
senderEmail: z.string().email().min(5).optional(),
});
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 const sendgridGetConfigurationsInputSchema = z
.object({
ids: z.array(z.string()).optional(),
active: z.boolean().optional(),
})
.optional();
export const sendgridUpdateEventConfigurationInputSchema = z
.object({
configurationId: z.string(),
})
.merge(sendgridConfigurationEventObjectSchema);
export const sendgridGetEventConfigurationInputSchema = z.object({
configurationId: z.string(),
eventType: z.enum(messageEventTypes),
});

View file

@ -1,26 +1,22 @@
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;
senderName?: string;
senderEmail?: string;
apiKey: string;
templateInvoiceSentSubject: string;
templateInvoiceSentTemplate: string;
templateOrderCancelledSubject: string;
templateOrderCancelledTemplate: string;
templateOrderConfirmedSubject: string;
templateOrderConfirmedTemplate: string;
templateOrderFullyPaidSubject: string;
templateOrderFullyPaidTemplate: string;
templateOrderCreatedSubject: string;
templateOrderCreatedTemplate: string;
templateOrderFulfilledSubject: string;
templateOrderFulfilledTemplate: string;
events: SendgridEventConfiguration[];
}
export type SendgridConfigurationsIdMap = Record<string, SendgridConfiguration>;
export type SendgridConfig = {
availableConfigurations: SendgridConfigurationsIdMap;
configurations: SendgridConfiguration[];
};

View file

@ -1,37 +1,159 @@
import { PrivateMetadataSendgridConfigurator } from "./sendgrid-configurator";
import { logger as pinoLogger } from "../../../lib/logger";
import { sendgridConfigInputSchema } from "./sendgrid-config-input-schema";
import { GetSendgridConfigurationService } from "./get-sendgrid-configuration.service";
import {
sendgridCreateConfigurationSchema,
sendgridDeleteConfigurationInputSchema,
sendgridGetConfigurationInputSchema,
sendgridGetConfigurationsInputSchema,
sendgridGetEventConfigurationInputSchema,
sendgridUpdateEventConfigurationInputSchema,
sendgridUpdateOrCreateConfigurationSchema,
} 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 { createSettingsManager } from "../../../lib/metadata-manager";
import { TRPCError } from "@trpc/server";
export const sendgridConfigurationRouter = router({
fetch: protectedClientProcedure.query(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug("sendgridConfigurationRouter.fetch called");
return new GetSendgridConfigurationService({
// Allow access only for the dashboard users and attaches the
// configuration service to the context
const protectedWithConfigurationService = protectedClientProcedure.use(({ next, ctx }) =>
next({
ctx: {
...ctx,
configurationService: new SendgridConfigurationService({
apiClient: ctx.apiClient,
saleorApiUrl: ctx.saleorApiUrl,
}).getConfiguration();
}),
setAndReplace: protectedClientProcedure
},
})
);
export const sendgridConfigurationRouter = router({
fetch: protectedWithConfigurationService.query(async ({ ctx }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug("sendgridConfigurationRouter.fetch called");
return ctx.configurationService.getConfigurationRoot();
}),
getConfiguration: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(sendgridConfigInputSchema)
.input(sendgridGetConfigurationInputSchema)
.query(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug(input, "sendgridConfigurationRouter.get called");
return ctx.configurationService.getConfiguration(input);
}),
getConfigurations: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(sendgridGetConfigurationsInputSchema)
.query(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug(input, "sendgridConfigurationRouter.getConfigurations called");
return ctx.configurationService.getConfigurations(input);
}),
createConfiguration: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(sendgridCreateConfigurationSchema)
.mutation(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug(input, "sendgridConfigurationRouter.create called");
return await ctx.configurationService.createConfiguration(input);
}),
deleteConfiguration: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(sendgridDeleteConfigurationInputSchema)
.mutation(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug(input, "sendgridConfigurationRouter.delete called");
const existingConfiguration = await ctx.configurationService.getConfiguration(input);
if (!existingConfiguration) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Configuration not found",
});
}
await ctx.configurationService.deleteConfiguration(input);
return null;
}),
updateOrCreateConfiguration: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(sendgridUpdateOrCreateConfigurationSchema)
.mutation(async ({ ctx, input }) => {
const logger = pinoLogger.child({ 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)
.query(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug(input, "sendgridConfigurationRouter.getEventConfiguration or create called");
const configuration = await ctx.configurationService.getConfiguration({
id: input.configurationId,
});
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: "Event configuration not found",
});
}
return event;
}),
updateEventConfiguration: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(sendgridUpdateEventConfigurationInputSchema)
.mutation(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug(input, "sendgridConfigurationRouter.setAndReplace called with input");
logger.debug(input, "sendgridConfigurationRouter.updateEventConfiguration or create called");
const sendgridConfigurator = new PrivateMetadataSendgridConfigurator(
createSettingsManager(ctx.apiClient),
ctx.saleorApiUrl
);
const configuration = await ctx.configurationService.getConfiguration({
id: input.configurationId,
});
await sendgridConfigurator.setConfig(input);
if (!configuration) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Configuration not found",
});
}
return null;
const eventIndex = configuration.events.findIndex((e) => e.eventType === input.eventType);
configuration.events[eventIndex] = {
active: input.active,
eventType: input.eventType,
template: input.template,
};
await ctx.configurationService.updateConfiguration(configuration);
return configuration;
}),
});

View file

@ -1,41 +0,0 @@
interface FetchTemplatesArgs {
apiKey?: string;
}
export const fetchTemplates =
({ apiKey }: FetchTemplatesArgs) =>
async () => {
if (!apiKey) {
console.warn(
"The Sendgrid API key has not been set up yet. Skipping fetching available templates."
);
return [];
}
const response = await fetch(
"https://api.sendgrid.com/v3/templates?generations=dynamic&page_size=18",
{
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
},
}
);
if (!response.ok) {
console.error("Could not fetch available Sendgrid templates");
return [];
}
try {
const resJson = (await response.json()) as {
result?: { id: string; name: string }[];
};
const templates =
resJson.result?.map((r) => ({
value: r.id,
label: r.name,
})) || [];
return templates;
} catch (e) {
console.error("Could not parse the response from Sendgrid", e);
return [];
}
};

View file

@ -1,52 +1,146 @@
import { Controller, useForm } from "react-hook-form";
import {
Divider,
FormControl,
FormControlLabel,
InputLabel,
Switch,
MenuItem,
Select,
TextField,
TextFieldProps,
Typography,
} from "@material-ui/core";
import { Button, makeStyles } from "@saleor/macaw-ui";
import React, { useEffect } from "react";
import { Button, makeStyles, SwitchSelector, SwitchSelectorButton } from "@saleor/macaw-ui";
import React, { useEffect, useState } from "react";
import { SendgridConfiguration } from "../sendgrid-config";
import { useQuery } from "@tanstack/react-query";
import { TemplateSelectionField } from "./template-selection-field";
import { fetchTemplates } from "./fetch-templates";
import { trpcClient } from "../../../trpc/trpc-client";
import { useAppBridge, actions } from "@saleor/app-sdk/app-bridge";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchSenders } from "../../sendgrid-api";
const useStyles = makeStyles({
const useStyles = makeStyles((theme) => ({
field: {
marginBottom: 20,
},
form: {
padding: 20,
editor: {
marginBottom: 20,
},
});
preview: {
marginBottom: 20,
},
sectionHeader: {
marginTop: 20,
},
}));
type Props = {
onSubmit(data: SendgridConfiguration): Promise<void>;
onConfigurationSaved: () => void;
initialData: SendgridConfiguration;
configurationId?: string;
};
export const SendgridConfigurationForm = (props: Props) => {
const { handleSubmit, control, reset } = useForm<SendgridConfiguration>({
const styles = useStyles();
const { appBridge } = useAppBridge();
const [senderId, setSenderId] = useState<string | undefined>(undefined);
const { handleSubmit, control, reset, setError, setValue } = useForm<SendgridConfiguration>({
defaultValues: props.initialData,
});
const { data: sendersChoices, isLoading: isSendersChoicesLoading } = useQuery({
queryKey: ["sendgridSenders"],
queryFn: fetchSenders({ apiKey: props.initialData.apiKey }),
enabled: !!props.initialData.apiKey?.length,
onSuccess(data) {
// we are not keeping senders ID in the database, so we need to find the ID of the sender
// configuration contains nickname and email set up in the Sendgrid account
if (data.length) {
const sender = data?.find((sender) => sender.from_email === props.initialData.senderEmail);
if (sender?.value) {
setSenderId(sender?.value);
}
}
},
});
const queryClient = useQueryClient();
const { mutate: createOrUpdateConfiguration } =
trpcClient.sendgridConfiguration.updateOrCreateConfiguration.useMutation({
onSuccess: async (data, variables) => {
await queryClient.cancelQueries({
queryKey: ["sendgridConfiguration", "getConfigurations"],
});
// Optimistically update to the new value
queryClient.setQueryData<Array<SendgridConfiguration>>(
["sendgridConfiguration", "getConfigurations", undefined],
(old) => {
if (old) {
const index = old.findIndex((c) => c.id === data.id);
// If thats an update, replace the old one
if (index !== -1) {
old[index] = data;
return [...old];
} else {
return [...old, data];
}
} else {
return [data];
}
}
);
// Trigger refetch to make sure we have a fresh data
props.onConfigurationSaved();
appBridge?.dispatch(
actions.Notification({
title: "Configuration saved",
status: "success",
})
);
},
onError(error) {
let isFieldErrorSet = false;
const fieldErrors = error.data?.zodError?.fieldErrors || {};
for (const fieldName in fieldErrors) {
for (const message of fieldErrors[fieldName] || []) {
isFieldErrorSet = true;
setError(fieldName as keyof SendgridConfiguration, {
type: "manual",
message,
});
}
}
const formErrors = error.data?.zodError?.formErrors || [];
const formErrorMessage = formErrors.length ? formErrors.join("\n") : undefined;
appBridge?.dispatch(
actions.Notification({
title: "Could not save the configuration",
text: isFieldErrorSet ? "Submitted form contain errors" : "Error saving configuration",
apiMessage: formErrorMessage,
status: "error",
})
);
},
});
// when the configuration tab is changed, initialData change and form has to be updated
useEffect(() => {
reset(props.initialData);
}, [props.initialData, reset]);
}, [props.initialData, props.configurationId, reset]);
const { data: templateChoices, isLoading: isTemplateChoicesLoading } = useQuery({
queryKey: ["sendgridTemplates"],
queryFn: fetchTemplates({ apiKey: props.initialData.apiKey }),
enabled: !!props.initialData?.apiKey.length,
});
const styles = useStyles();
// fill sender email and name when sender is changed
useEffect(() => {
const sender = sendersChoices?.find((choice) => choice.value === senderId);
if (sender) {
setValue("senderName", sender.nickname);
setValue("senderEmail", sender.from_email);
} else {
setValue("senderName", undefined);
setValue("senderEmail", undefined);
}
}, [senderId, sendersChoices]);
const CommonFieldProps: TextFieldProps = {
className: styles.field,
@ -58,86 +152,162 @@ export const SendgridConfigurationForm = (props: Props) => {
return (
<form
onSubmit={handleSubmit((data, event) => {
props.onSubmit(data);
createOrUpdateConfiguration({
...data,
});
})}
className={styles.form}
>
{isNewConfiguration ? (
<Typography variant="h4" paragraph>
<Typography variant="h2" paragraph>
Create a new configuration
</Typography>
) : (
<Typography variant="h4" paragraph>
Configuration {props.initialData?.configurationName}
<Typography variant="h2" paragraph>
Configuration
<strong>{` ${props.initialData.configurationName} `}</strong>
</Typography>
)}
<Controller
control={control}
name="active"
render={({ field: { value, onChange } }) => {
return (
<FormControlLabel
control={
<Switch value={value} checked={value} onChange={(event, val) => onChange(val)} />
}
label="Active"
/>
);
}}
/>
<Controller
control={control}
name="sandboxMode"
render={({ field: { value, onChange } }) => {
return (
<FormControlLabel
control={
<Switch value={value} checked={value} onChange={(event, val) => onChange(val)} />
}
label="Sandbox mode"
/>
);
}}
/>
<Controller
name="configurationName"
control={control}
render={({ field: { onChange, value } }) => (
render={({ field: { onChange, value }, fieldState: { error }, formState: { errors } }) => (
<TextField
label="Configuration name"
value={value}
onChange={onChange}
error={!!error}
helperText={error?.message}
{...CommonFieldProps}
/>
)}
/>
<Controller
name="senderName"
control={control}
render={({ field: { onChange, value } }) => (
<TextField label="Sender name" value={value} onChange={onChange} {...CommonFieldProps} />
)}
/>
<Controller
name="senderEmail"
control={control}
render={({ field: { onChange, value } }) => (
<TextField label="Sender email" value={value} onChange={onChange} {...CommonFieldProps} />
name="active"
render={({ field: { value, name, onChange } }) => (
<div className={styles.field}>
{/* TODO: fix types in the MacawUI */}
{/* @ts-ignore: MacawUI use wrong type for */}
<SwitchSelector key={name} className={styles.field}>
{[
{ label: "Active", value: true },
{ label: "Disabled", value: false },
].map((button) => (
// @ts-ignore: MacawUI use wrong type for SwitchSelectorButton
<SwitchSelectorButton
value={button.value.toString()}
onClick={() => onChange(button.value)}
activeTab={value.toString()}
key={button.label}
>
{button.label}
</SwitchSelectorButton>
))}
</SwitchSelector>
</div>
)}
/>
<Divider />
<Typography variant="h3" paragraph className={styles.sectionHeader}>
API configuration
</Typography>
<Controller
name="apiKey"
control={control}
render={({ field: { onChange, value } }) => (
<TextField label="API key" value={value} onChange={onChange} {...CommonFieldProps} />
render={({ field: { onChange, value }, fieldState: { error } }) => (
<TextField
label="Sendgrid API key"
value={value}
onChange={onChange}
helperText={error?.message}
error={!!error}
{...CommonFieldProps}
/>
)}
/>
<Controller
name="templateOrderCreatedSubject"
control={control}
render={({ field: { onChange, value } }) => (
name="sandboxMode"
render={({ field: { value, name, onChange } }) => (
<div className={styles.field}>
{/* TODO: fix types in the MacawUI */}
{/* @ts-ignore: MacawUI use wrong type for */}
<SwitchSelector key={name} className={styles.field}>
{[
{ label: "Live", value: false },
{ label: "Sandbox", value: true },
].map((button) => (
// @ts-ignore: MacawUI use wrong type for SwitchSelectorButton
<SwitchSelectorButton
value={button.value.toString()}
onClick={() => onChange(button.value)}
activeTab={value.toString()}
key={button.label}
>
{button.label}
</SwitchSelectorButton>
))}
</SwitchSelector>
</div>
)}
/>
<Divider />
{/* Sender can be chosen after the API key is saved in the configuration */}
{!isNewConfiguration && (
<>
<Typography variant="h3" paragraph className={styles.sectionHeader}>
Sender details
</Typography>
<FormControl className={styles.field} fullWidth>
<InputLabel>Sender</InputLabel>
<Select
variant="outlined"
value={senderId}
disabled={isSendersChoicesLoading}
onChange={(event, val) => {
if (val) {
const node = val as React.ReactElement;
setSenderId(node.props.value);
return;
}
setSenderId(undefined);
}}
>
<MenuItem key="none" value={undefined}>
No sender
</MenuItem>
{!!sendersChoices &&
sendersChoices.map((choice) => (
<MenuItem key={choice.value} value={choice.value}>
{choice.label}
</MenuItem>
))}
</Select>
{!sendersChoices?.length && (
<Typography variant="caption" color="textSecondary">
Please set up and verify senders in your Sendgrid dashboard.
</Typography>
)}
</FormControl>
<Controller
name="senderName"
control={control}
render={({ field: { onChange, value }, fieldState: { error } }) => (
<TextField
label="Order Created Email subject"
label="Sender name"
disabled={true}
error={!!error}
helperText={error?.message}
value={value}
onChange={onChange}
{...CommonFieldProps}
@ -146,172 +316,24 @@ export const SendgridConfigurationForm = (props: Props) => {
/>
<Controller
name="senderEmail"
control={control}
name="templateOrderCreatedTemplate"
render={({ field: { value, onChange } }) => {
return (
<FormControl className={styles.field} disabled={isTemplateChoicesLoading} fullWidth>
<InputLabel>Template for Order Created</InputLabel>
<TemplateSelectionField
value={value}
onChange={onChange}
templateChoices={templateChoices}
/>
</FormControl>
);
}}
/>
<Controller
name="templateOrderFulfilledSubject"
control={control}
render={({ field: { onChange, value } }) => (
render={({ field: { onChange, value }, fieldState: { error } }) => (
<>
<TextField
label="Order Fulfilled Email subject"
label="Sender email"
value={value}
disabled={true}
helperText={error?.message}
error={!!error}
onChange={onChange}
{...CommonFieldProps}
/>
</>
)}
/>
<Controller
control={control}
name="templateOrderFulfilledTemplate"
render={({ field: { value, onChange } }) => {
return (
<FormControl className={styles.field} disabled={isTemplateChoicesLoading} fullWidth>
<InputLabel>Template for Order Fulfilled</InputLabel>
<TemplateSelectionField
value={value}
onChange={onChange}
templateChoices={templateChoices}
/>
</FormControl>
);
}}
/>
<Controller
name="templateOrderConfirmedSubject"
control={control}
render={({ field: { onChange, value } }) => (
<TextField
label="Order Confirmed Email subject"
value={value}
onChange={onChange}
{...CommonFieldProps}
/>
</>
)}
/>
<Controller
control={control}
name="templateOrderConfirmedTemplate"
render={({ field: { value, onChange } }) => {
return (
<FormControl className={styles.field} disabled={isTemplateChoicesLoading} fullWidth>
<InputLabel>Template for Order Confirmed</InputLabel>
<TemplateSelectionField
value={value}
onChange={onChange}
templateChoices={templateChoices}
/>
</FormControl>
);
}}
/>
<Controller
name="templateOrderCancelledSubject"
control={control}
render={({ field: { onChange, value } }) => (
<TextField
label="Order Cancelled Email subject"
value={value}
onChange={onChange}
{...CommonFieldProps}
/>
)}
/>
<Controller
control={control}
name="templateOrderCancelledTemplate"
render={({ field: { value, onChange } }) => {
return (
<FormControl className={styles.field} disabled={isTemplateChoicesLoading} fullWidth>
<InputLabel>Template for Order Cancelled</InputLabel>
<TemplateSelectionField
value={value}
onChange={onChange}
templateChoices={templateChoices}
/>
</FormControl>
);
}}
/>
<Controller
name="templateOrderFullyPaidSubject"
control={control}
render={({ field: { onChange, value } }) => (
<TextField
label="Order Fully Paid Email subject"
value={value}
onChange={onChange}
{...CommonFieldProps}
/>
)}
/>
<Controller
control={control}
name="templateOrderFullyPaidTemplate"
render={({ field: { value, onChange } }) => {
return (
<FormControl className={styles.field} disabled={isTemplateChoicesLoading} fullWidth>
<InputLabel>Template for Order Fully Paid</InputLabel>
<TemplateSelectionField
value={value}
onChange={onChange}
templateChoices={templateChoices}
/>
</FormControl>
);
}}
/>
<Controller
name="templateInvoiceSentSubject"
control={control}
render={({ field: { onChange, value } }) => (
<TextField
label="Invoice sent Email subject"
value={value}
onChange={onChange}
{...CommonFieldProps}
/>
)}
/>
<Controller
control={control}
name="templateInvoiceSentTemplate"
render={({ field: { value, onChange } }) => {
return (
<FormControl className={styles.field} disabled={isTemplateChoicesLoading} fullWidth>
<InputLabel>Template for Invoice Sent</InputLabel>
<TemplateSelectionField
value={value}
onChange={onChange}
templateChoices={templateChoices}
/>
</FormControl>
);
}}
/>
<Button type="submit" fullWidth variant="primary">
Save configuration
</Button>

View file

@ -1,16 +1,18 @@
import { CircularProgress, Paper } from "@material-ui/core";
import React, { useEffect, useState } from "react";
import { makeStyles } from "@saleor/macaw-ui";
import { ConfigurationsList } from "../../../app-configuration/ui/configurations-list";
import React from "react";
import { IconButton, makeStyles } from "@saleor/macaw-ui";
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
import { AppColumnsLayout } from "../../../ui/app-columns-layout";
import { trpcClient } from "../../../trpc/trpc-client";
import { SendgridConfiguration } from "../sendgrid-config";
import {
getDefaultEmptySendgridConfiguration,
SendgridConfigContainer,
} from "../sendgrid-config-container";
import { SendgridConfigurationForm } from "./sendgrid-configuration-form";
import { getDefaultEmptyConfiguration } from "../sendgrid-config-container";
import { NextRouter, useRouter } from "next/router";
import SideMenu from "../../../app-configuration/ui/side-menu";
import { SendgridConfiguration } from "../sendgrid-config";
import { LoadingIndicator } from "../../../ui/loading-indicator";
import { Add } from "@material-ui/icons";
import { useQueryClient } from "@tanstack/react-query";
import { sendgridUrls } from "../../urls";
import { SendgridTemplatesCard } from "./sendgrid-templates-card";
const useStyles = makeStyles((theme) => {
return {
@ -24,101 +26,149 @@ const useStyles = makeStyles((theme) => {
display: "flex",
flexDirection: "column",
gap: 20,
},
loaderContainer: {
margin: "50px auto",
display: "flex",
alignItems: "center",
justifyContent: "center",
maxWidth: 600,
},
};
});
type Configurations = {
name: string;
id: string;
};
interface SendgridConfigurationTabProps {
configurationId?: string;
}
export const SendgridConfigurationTab = () => {
const styles = useStyles();
const { appBridge } = useAppBridge();
const [configurationsListData, setConfigurationsListData] = useState<Configurations[]>([]);
const [activeConfigurationId, setActiveConfigurationId] = useState<string>();
const [initialData, setInitialData] = useState<SendgridConfiguration>();
const {
data: configurationData,
refetch: refetchConfig,
isLoading,
} = trpcClient.sendgridConfiguration.fetch.useQuery(undefined, {
onSuccess(data) {
if (!data.availableConfigurations) {
const navigateToFirstConfiguration = (
router: NextRouter,
configurations?: SendgridConfiguration[]
) => {
if (!configurations || !configurations?.length) {
router.replace(sendgridUrls.configuration());
return;
}
const keys = Object.keys(data.availableConfigurations);
setConfigurationsListData(
keys.map((key) => ({ id: key, name: data.availableConfigurations[key].configurationName }))
);
setActiveConfigurationId(keys[0]);
const firstConfigurationId = configurations[0]?.id;
if (firstConfigurationId) {
router.replace(sendgridUrls.configuration(firstConfigurationId));
return;
}
};
export const SendgridConfigurationTab = ({ configurationId }: SendgridConfigurationTabProps) => {
const styles = useStyles();
const { appBridge } = useAppBridge();
const router = useRouter();
const queryClient = useQueryClient();
const {
data: configurations,
refetch: refetchConfigurations,
isLoading: configurationsIsLoading,
isFetching: configurationsIsFetching,
isRefetching: configurationsIsRefetching,
} = trpcClient.sendgridConfiguration.getConfigurations.useQuery(undefined, {
onSuccess(data) {
if (!configurationId) {
console.log("no conf id! navigate to first");
navigateToFirstConfiguration(router, data);
}
},
});
const { mutate, error: saveError } = trpcClient.sendgridConfiguration.setAndReplace.useMutation({
onSuccess() {
refetchConfig();
const { mutate: deleteConfiguration } =
trpcClient.sendgridConfiguration.deleteConfiguration.useMutation({
onError: (error) => {
appBridge?.dispatch(
actions.Notification({
title: "Could not remove the configuration",
text: error.message,
status: "error",
})
);
},
onSuccess: async (_data, variables) => {
await queryClient.cancelQueries({
queryKey: ["sendgridConfiguration", "getConfigurations"],
});
// remove value from the cache after the success
queryClient.setQueryData<Array<SendgridConfiguration>>(
["sendgridConfiguration", "getConfigurations"],
(old) => {
if (old) {
const index = old.findIndex((c) => c.id === variables.id);
if (index !== -1) {
delete old[index];
return [...old];
}
}
}
);
// if we just deleted the configuration that was selected
// we have to update the URL
if (variables.id === configurationId) {
router.replace(sendgridUrls.configuration());
}
refetchConfigurations();
appBridge?.dispatch(
actions.Notification({
title: "Success",
text: "Saved configuration",
text: "Removed successfully",
status: "success",
})
);
},
});
useEffect(() => {
setInitialData(
activeConfigurationId
? SendgridConfigContainer.getSendgridConfigurationById(configurationData)(
activeConfigurationId
)
: getDefaultEmptySendgridConfiguration()
);
}, [activeConfigurationId, configurationData]);
if (configurationsIsLoading || configurationsIsFetching) {
return <LoadingIndicator />;
}
if (isLoading) {
return (
<div className={styles.loaderContainer}>
<CircularProgress color="primary" />
</div>
);
const configuration = configurations?.find((c) => c.id === configurationId?.toString());
if (configurationId && !configuration) {
return <div>Configuration not found</div>;
}
return (
<AppColumnsLayout>
<ConfigurationsList
// TODO: FIXME
listItems={[]}
activeItemId={activeConfigurationId}
onItemClick={setActiveConfigurationId}
<SideMenu
title="Configurations"
selectedItemId={configurationId}
headerToolbar={
<IconButton
variant="secondary"
onClick={() => {
router.replace(sendgridUrls.configuration());
}}
>
<Add />
</IconButton>
}
onClick={(id) => router.replace(sendgridUrls.configuration(id))}
onDelete={(id) => {
deleteConfiguration({ id });
}}
items={configurations?.map((c) => ({ label: c.configurationName, id: c.id })) || []}
/>
<div className={styles.configurationColumn}>
<Paper elevation={0} className={styles.formContainer}>
{!!initialData && (
{configurationsIsLoading || configurationsIsFetching ? (
<LoadingIndicator />
) : (
<>
<SendgridConfigurationForm
onSubmit={async (data) => {
const newConfig =
SendgridConfigContainer.setSendgridConfigurationById(configurationData)(
activeConfigurationId
)(data);
mutate(newConfig);
onConfigurationSaved={() => refetchConfigurations()}
initialData={configuration || getDefaultEmptyConfiguration()}
configurationId={configurationId}
/>
{!!configurationId && !!configuration && (
<SendgridTemplatesCard
configurationId={configurationId}
configuration={configuration}
onEventChanged={() => {
refetchConfigurations();
}}
initialData={initialData}
configurationId={activeConfigurationId}
/>
)}
{saveError && <span>{saveError.message}</span>}
</Paper>
</>
)}
</div>
</AppColumnsLayout>
);

View file

@ -0,0 +1,227 @@
import { Controller, useForm } from "react-hook-form";
import {
FormControl,
Grid,
InputLabel,
MenuItem,
Select,
TextField,
TextFieldProps,
Typography,
} from "@material-ui/core";
import {
BackSmallIcon,
Button,
IconButton,
makeStyles,
SwitchSelector,
SwitchSelectorButton,
} from "@saleor/macaw-ui";
import React from "react";
import { SendgridConfiguration, SendgridEventConfiguration } from "../sendgrid-config";
import {
MessageEventTypes,
messageEventTypesLabels,
} from "../../../event-handlers/message-event-types";
import { trpcClient } from "../../../trpc/trpc-client";
import { useRouter } from "next/router";
import { sendgridUrls } from "../../urls";
import { useAppBridge, actions } from "@saleor/app-sdk/app-bridge";
import { useQuery } from "@tanstack/react-query";
import { fetchTemplates } from "../../sendgrid-api";
const useStyles = makeStyles((theme) => ({
viewContainer: {
padding: theme.spacing(2),
},
header: {
display: "flex",
justifyContent: "flex-start",
alignItems: "center",
gap: theme.spacing(2),
marginBottom: theme.spacing(2),
margin: "0 auto",
},
previewHeader: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: theme.spacing(1),
marginTop: theme.spacing(2),
marginBottom: theme.spacing(2),
},
field: {
marginBottom: theme.spacing(3),
},
editor: {
marginBottom: theme.spacing(3),
},
preview: {
marginBottom: theme.spacing(3),
},
form: {
maxWidth: 800,
},
}));
type EventConfigurationFormProps = {
initialData: SendgridEventConfiguration;
configurationId: string;
eventType: MessageEventTypes;
configuration: SendgridConfiguration;
};
export const EventConfigurationForm = ({
initialData,
configurationId,
eventType,
configuration,
}: EventConfigurationFormProps) => {
const router = useRouter();
const { appBridge } = useAppBridge();
const { handleSubmit, control, getValues, setError } = useForm<SendgridEventConfiguration>({
defaultValues: initialData,
});
const styles = useStyles();
const { data: templateChoices, isLoading: isTemplateChoicesLoading } = useQuery({
queryKey: ["sendgridTemplates"],
queryFn: fetchTemplates({ apiKey: configuration.apiKey }),
enabled: !!configuration.apiKey?.length,
});
const CommonFieldProps: TextFieldProps = {
className: styles.field,
fullWidth: true,
};
const { mutate: updateEventConfiguration } =
trpcClient.sendgridConfiguration.updateEventConfiguration.useMutation({
onSuccess: (data) => {
appBridge?.dispatch(
actions.Notification({
title: "Configuration saved",
status: "success",
})
);
},
onError: (error) => {
let isFieldErrorSet = false;
const fieldErrors = error.data?.zodError?.fieldErrors || {};
for (const fieldName in fieldErrors) {
for (const message of fieldErrors[fieldName] || []) {
isFieldErrorSet = true;
setError(fieldName as keyof SendgridEventConfiguration, {
type: "manual",
message,
});
}
}
const formErrors = error.data?.zodError?.formErrors || [];
const formErrorMessage = formErrors.length ? formErrors.join("\n") : undefined;
appBridge?.dispatch(
actions.Notification({
title: "Could not save the configuration",
text: isFieldErrorSet ? "Submitted form contain errors" : "Error saving configuration",
apiMessage: formErrorMessage,
status: "error",
})
);
},
});
return (
<div className={styles.viewContainer}>
<div className={styles.header}>
<IconButton
variant="secondary"
onClick={() => {
router.push(sendgridUrls.configuration(configurationId));
}}
>
<BackSmallIcon />
</IconButton>
<Typography variant="h2">
{messageEventTypesLabels[eventType]} event configuration
</Typography>
</div>
<Grid container spacing={2}>
<Grid item xs={12} lg={7}>
<form
onSubmit={handleSubmit((data, event) => {
updateEventConfiguration({ ...data, configurationId });
})}
className={styles.form}
>
<Controller
control={control}
name="active"
render={({ field: { value, name, onChange } }) => (
<div className={styles.field}>
{/* TODO: fix types in the MacawUI */}
{/* @ts-ignore: MacawUI use wrong type for */}
<SwitchSelector key={name} className={styles.field}>
{[
{ label: "Active", value: true },
{ label: "Disabled", value: false },
].map((button) => (
// @ts-ignore: MacawUI use wrong type for SwitchSelectorButton
<SwitchSelectorButton
value={button.value.toString()}
onClick={() => onChange(button.value)}
activeTab={value.toString()}
key={button.label}
>
{button.label}
</SwitchSelectorButton>
))}
</SwitchSelector>
</div>
)}
/>
<Controller
control={control}
name="template"
render={({ field: { value, onChange } }) => {
return (
<FormControl className={styles.field} fullWidth>
<InputLabel>Template</InputLabel>
<Select
variant="outlined"
value={value}
onChange={(event, val) => {
onChange(event.target.value);
}}
>
<MenuItem key="none" value={undefined}>
No template
</MenuItem>
{!!templateChoices &&
templateChoices.map((choice) => (
<MenuItem key={choice.value} value={choice.value}>
{choice.label}
</MenuItem>
))}
</Select>
{!templateChoices?.length && (
<Typography variant="caption" color="textSecondary">
No templates found in your account. Visit Sendgrid dashboard and create one.
</Typography>
)}
</FormControl>
);
}}
/>
<Button type="submit" fullWidth variant="primary">
Save configuration
</Button>
</form>
</Grid>
</Grid>
</div>
);
};

View file

@ -0,0 +1,135 @@
import { Divider, Paper, Typography } from "@material-ui/core";
import React from "react";
import {
EditIcon,
IconButton,
List,
ListHeader,
ListItem,
ListItemCell,
makeStyles,
SwitchSelector,
SwitchSelectorButton,
} from "@saleor/macaw-ui";
import { useRouter } from "next/router";
import { messageEventTypesLabels } from "../../../event-handlers/message-event-types";
import { trpcClient } from "../../../trpc/trpc-client";
import { useAppBridge, actions } from "@saleor/app-sdk/app-bridge";
import { SendgridConfiguration } from "../sendgrid-config";
import { sendgridUrls } from "../../urls";
const useStyles = makeStyles((theme) => {
return {
spaceBetween: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
},
rowActions: {
display: "flex",
justifyContent: "flex-end",
gap: theme.spacing(1),
},
tableRow: {
minHeight: "48px",
"&::after": {
display: "none",
},
},
};
});
interface SendgridTemplatesCardProps {
configurationId: string;
configuration: SendgridConfiguration;
onEventChanged: () => void;
}
export const SendgridTemplatesCard = ({
configurationId,
configuration,
onEventChanged,
}: SendgridTemplatesCardProps) => {
const classes = useStyles();
const router = useRouter();
const { appBridge } = useAppBridge();
const { mutate: updateEventConfiguration } =
trpcClient.sendgridConfiguration.updateEventConfiguration.useMutation({
onSuccess(_data, variables) {
onEventChanged();
appBridge?.dispatch(
actions.Notification({
title: variables.active ? "Event enabled" : "Event disabled",
status: "success",
})
);
},
});
return (
<Paper elevation={0}>
<ListHeader>
<ListItem className={classes.tableRow}>
<ListItemCell>Supported events and templates</ListItemCell>
</ListItem>
</ListHeader>
<List gridTemplate={["1fr"]}>
<Divider />
{configuration.events.map((eventConfiguration) => (
<React.Fragment key={eventConfiguration.eventType}>
<ListItem className={classes.tableRow}>
<ListItemCell>
<div className={classes.spaceBetween}>
<Typography>{messageEventTypesLabels[eventConfiguration.eventType]}</Typography>
<div className={classes.rowActions}>
{/* TODO: fix types in the MacawUI */}
{/* @ts-ignore: MacawUI use wrong type for */}
<SwitchSelector key={eventConfiguration.eventType}>
{[
{ label: "Active", value: true },
{ label: "Disabled", value: false },
].map((button) => (
// @ts-ignore: MacawUI use wrong type for SwitchSelectorButton
<SwitchSelectorButton
value={button.value.toString()}
onClick={() => {
updateEventConfiguration({
configurationId,
...eventConfiguration,
active: button.value,
});
}}
activeTab={eventConfiguration.active.toString()}
key={button.label}
>
{button.label}
</SwitchSelectorButton>
))}
</SwitchSelector>
<IconButton
variant="secondary"
onClick={(event) => {
event.stopPropagation();
event.preventDefault();
router.push(
sendgridUrls.eventConfiguration(
configurationId,
eventConfiguration.eventType
)
);
}}
>
<EditIcon />
</IconButton>
</div>
</div>
</ListItemCell>
</ListItem>
<Divider />
</React.Fragment>
))}
</List>
</Paper>
);
};

View file

@ -1,54 +0,0 @@
import { AuthData } from "@saleor/app-sdk/APL";
import { appRouter } from "../trpc/trpc-app-router";
import { logger as pinoLogger } from "../../lib/logger";
interface GetSendgridSettingsArgs {
authData: AuthData;
channel: string;
}
export const getSendgridSettings = async ({ authData, channel }: GetSendgridSettingsArgs) => {
const logger = pinoLogger.child({
fn: "getMjmlSettings",
channel,
});
const caller = appRouter.createCaller({
appId: authData.appId,
saleorApiUrl: authData.saleorApiUrl,
token: authData.token,
ssr: true,
});
const sendgridConfigurations = await caller.sendgridConfiguration.fetch();
const appConfigurations = await caller.appConfiguration.fetch();
const channelAppConfiguration = appConfigurations?.configurationsPerChannel[channel];
if (!channelAppConfiguration) {
logger.warn("App has no configuration for this channel");
return;
}
if (!channelAppConfiguration.active) {
logger.warn("App configuration is not active for this channel");
return;
}
const sendgridConfigurationId = channelAppConfiguration.sendgridConfigurationId;
if (!sendgridConfigurationId?.length) {
logger.warn("Sendgrid configuration has not been chosen for this channel");
return;
}
const configuration = sendgridConfigurations?.availableConfigurations[sendgridConfigurationId];
if (!configuration) {
logger.warn(`The Sendgrid configuration with id ${sendgridConfigurationId} does not exist`);
return;
}
if (!configuration.active) {
logger.warn(`The Sendgrid configuration ${configuration.configurationName} is not active`);
return;
}
return configuration;
};

View file

@ -1,16 +1,13 @@
import { logger as pinoLogger } from "../../lib/logger";
import { AuthData } from "@saleor/app-sdk/APL";
import { SendgridConfiguration } from "./configuration/sendgrid-config";
import { getSendgridSettings } from "./get-sendgrid-settings";
import { MailService } from "@sendgrid/mail";
import { MessageEventTypes } from "../event-handlers/message-event-types";
interface SendSendgridArgs {
authData: AuthData;
channel: string;
recipientEmail: string;
event: MessageEventTypes;
payload: any;
sendgridConfiguration: SendgridConfiguration;
}
export interface EmailServiceResponse {
@ -20,65 +17,55 @@ export interface EmailServiceResponse {
}[];
}
const eventMapping = (event: SendSendgridArgs["event"], settings: SendgridConfiguration) => {
switch (event) {
case "ORDER_CREATED":
return {
templateId: settings.templateOrderCreatedTemplate,
subject: settings.templateOrderCreatedSubject || "Order created",
};
case "ORDER_FULFILLED":
return {
templateId: settings.templateOrderFulfilledTemplate,
subject: settings.templateOrderFulfilledSubject || "Order fulfilled",
};
case "ORDER_CONFIRMED":
return {
template: settings.templateOrderConfirmedTemplate,
subject: settings.templateOrderConfirmedSubject || "Order confirmed",
};
case "ORDER_CANCELLED":
return {
template: settings.templateOrderCancelledTemplate,
subject: settings.templateOrderCancelledSubject || "Order cancelled",
};
case "ORDER_FULLY_PAID":
return {
template: settings.templateOrderFullyPaidTemplate,
subject: settings.templateOrderFullyPaidSubject || "Order fully paid",
};
case "INVOICE_SENT":
return {
template: settings.templateInvoiceSentTemplate,
subject: settings.templateInvoiceSentSubject || "Invoice sent",
};
}
};
export const sendSendgrid = async ({
authData,
channel,
payload,
recipientEmail,
event,
sendgridConfiguration,
}: SendSendgridArgs) => {
const logger = pinoLogger.child({
fn: "sendSendgrid",
event,
});
if (!sendgridConfiguration.senderEmail) {
logger.debug("Sender email has not been specified, skipping");
return {
errors: [
{
message: "Sender email has not been set up",
},
],
};
}
const settings = await getSendgridSettings({ authData, channel });
const eventSettings = sendgridConfiguration.events.find((e) => e.eventType === event);
if (!eventSettings) {
logger.debug("No active settings for this event, skipping");
return {
errors: [
{
message: "No active settings for this event",
},
],
};
}
if (!settings?.active) {
logger.debug("Sendgrid is not active, skipping");
return;
if (!eventSettings.active) {
logger.debug("Event settings are not active, skipping");
return {
errors: [
{
message: "Event settings are not active",
},
],
};
}
logger.debug("Sending an email using Sendgrid");
const { templateId, subject } = eventMapping(event, settings);
const { template } = eventSettings;
if (!templateId) {
if (!template) {
logger.error("No template defined in the settings");
return {
errors: [{ message: `No template specified for the event ${event}` }],
@ -87,35 +74,21 @@ export const sendSendgrid = async ({
try {
const mailService = new MailService();
mailService.setApiKey(settings.apiKey);
mailService.setApiKey(sendgridConfiguration.apiKey);
await mailService.send({
mailSettings: {
sandboxMode: {
enable: settings.sandboxMode,
enable: sendgridConfiguration.sandboxMode,
},
},
from: {
email: settings.senderEmail,
name: sendgridConfiguration.senderName,
email: sendgridConfiguration.senderEmail,
},
to: {
email: recipientEmail,
},
personalizations: [
{
from: {
email: settings.senderEmail,
},
to: [
{
email: recipientEmail,
},
],
subject,
to: recipientEmail,
dynamicTemplateData: payload,
},
],
templateId,
templateId: template,
});
logger.debug("Email has been send");
} catch (error) {

View file

@ -0,0 +1,78 @@
interface FetchTemplatesArgs {
apiKey?: string;
}
export const fetchTemplates =
({ apiKey }: FetchTemplatesArgs) =>
async () => {
if (!apiKey) {
console.warn(
"The Sendgrid API key has not been set up yet. Skipping fetching available templates."
);
return [];
}
const response = await fetch(
"https://api.sendgrid.com/v3/templates?generations=dynamic&page_size=18",
{
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
},
}
);
if (!response.ok) {
console.error("Could not fetch available Sendgrid templates");
return [];
}
try {
const resJson = (await response.json()) as {
result?: { id: string; name: string }[];
};
const templates =
resJson.result?.map((r) => ({
value: r.id,
label: r.name,
})) || [];
return templates;
} catch (e) {
console.error("Could not parse the response from Sendgrid", e);
return [];
}
};
export const fetchSenders =
({ apiKey }: FetchTemplatesArgs) =>
async () => {
if (!apiKey) {
console.warn(
"The Sendgrid API key has not been set up yet. Skipping fetching available senders ."
);
return [];
}
const response = await fetch("https://api.sendgrid.com/v3/verified_senders?page_size=18", {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
if (!response.ok) {
console.error("Could not fetch available Sendgrid senders");
return [];
}
try {
const resJson = (await response.json()) as {
results?: { id: string; nickname: string; from_email: string }[];
};
const senders =
resJson.results?.map((r) => ({
value: r.id,
label: `${r.nickname} (${r.from_email})`,
nickname: r.nickname,
from_email: r.from_email,
})) || [];
return senders;
} catch (e) {
console.error("Could not parse the response from Sendgrid", e);
return [];
}
};

View file

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

View file

@ -24,9 +24,8 @@ export const ConfigurationPageBaseLayout = ({ children }: Props) => {
{ key: "mjml", label: "MJML", url: "/configuration/mjml" },
{
key: "sendgrid",
label: "Sendgrid (Coming soon!)",
label: "Sendgrid",
url: "/configuration/sendgrid",
disabled: true,
},
];
@ -42,7 +41,7 @@ export const ConfigurationPageBaseLayout = ({ children }: Props) => {
<div className={styles.appContainer}>
<PageTabs value={activePath} onChange={navigateToTab}>
{tabs.map((tab) => (
<PageTab key={tab.key} value={tab.key} label={tab.label} disabled={tab.disabled} />
<PageTab key={tab.key} value={tab.key} label={tab.label} />
))}
</PageTabs>
{children}

View file

@ -0,0 +1,19 @@
import { NextPage } from "next";
import React from "react";
import { useRouter } from "next/router";
import { ConfigurationPageBaseLayout } from "../../../modules/ui/configuration-page-base-layout";
import { SendgridConfigurationTab } from "../../../modules/sendgrid/configuration/ui/sendgrid-configuration-tab";
const SendgridConfigurationPage: NextPage = () => {
const router = useRouter();
const configurationId = router.query.configurationId
? router.query.configurationId[0] // optional routes are passed as an array
: undefined;
return (
<ConfigurationPageBaseLayout>
<SendgridConfigurationTab configurationId={configurationId} />
</ConfigurationPageBaseLayout>
);
};
export default SendgridConfigurationPage;

View file

@ -0,0 +1,77 @@
import { NextPage } from "next";
import React from "react";
import { useRouter } from "next/router";
import { trpcClient } from "../../../../../modules/trpc/trpc-client";
import { parseMessageEventType } from "../../../../../modules/event-handlers/parse-message-event-type";
import { ConfigurationPageBaseLayout } from "../../../../../modules/ui/configuration-page-base-layout";
import { LoadingIndicator } from "../../../../../modules/ui/loading-indicator";
import { EventConfigurationForm } from "../../../../../modules/sendgrid/configuration/ui/sendgrid-event-configuration-form";
const EventConfigurationPage: NextPage = () => {
const router = useRouter();
const configurationId = router.query.configurationId as string;
const eventTypeFromQuery = router.query.eventType as string | undefined;
const eventType = parseMessageEventType(eventTypeFromQuery);
const {
data: eventConfiguration,
isError,
isFetched,
isLoading,
} = trpcClient.sendgridConfiguration.getEventConfiguration.useQuery(
{
configurationId,
// if event type is not valid, it calling the query will not be enabled
// so we can safely cast it
eventType: eventType!,
},
{
enabled: !!configurationId && !!eventType,
}
);
const { data: configuration } = trpcClient.sendgridConfiguration.getConfiguration.useQuery(
{
id: configurationId,
},
{
enabled: !!configurationId,
}
);
if (!eventType || !configurationId) {
return <>Error: no event type or configuration id</>;
}
if (isLoading) {
return (
<ConfigurationPageBaseLayout>
<LoadingIndicator />
</ConfigurationPageBaseLayout>
);
}
if (isError) {
return (
<>
Error: could not load the config: fetched: {isFetched} is error {isError}
</>
);
}
if (!eventConfiguration || !configuration) {
return <>Error: no configuration with given id</>;
}
return (
<ConfigurationPageBaseLayout>
<EventConfigurationForm
initialData={eventConfiguration}
configurationId={configurationId}
configuration={configuration}
eventType={eventType}
/>
</ConfigurationPageBaseLayout>
);
};
export default EventConfigurationPage;