Refactored NotificationManagement

Fixed default template titles
This commit is contained in:
grimsi
2024-09-24 21:36:43 +02:00
parent 9d8ae28b7a
commit 95d83421a2
6 changed files with 207 additions and 163 deletions
@@ -1,29 +1,14 @@
import React, {useEffect, useState} from "react";
import withConfigPage from "Frontend/components/administration/withConfigPage";
import * as Yup from 'yup';
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import Section from "Frontend/components/general/Section";
import {
Button,
Card,
Chip,
Link,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Textarea,
Tooltip,
useDisclosure
} from "@nextui-org/react";
import {Button, Card, Tooltip, useDisclosure} from "@nextui-org/react";
import {MessageTemplateEndpoint, NotificationEndpoint} from "Frontend/generated/endpoints";
import {toast} from "sonner";
import {PaperPlaneRight, Pencil} from "@phosphor-icons/react";
import MessageTemplateDto from "Frontend/generated/de/grimsi/gameyfin/notifications/templates/MessageTemplateDto";
import TemplateType from "Frontend/generated/de/grimsi/gameyfin/notifications/templates/TemplateType";
import {Form, Formik} from "formik";
import Input from "Frontend/components/general/Input";
import SendTestNotificationModal from "Frontend/components/administration/notifications/SendTestNotificationModal";
import EditTemplateModal from "Frontend/components/administration/notifications/EditTemplateModel";
function NotificationManagementLayout({getConfig, getConfigs, formik}: any) {
@@ -31,8 +16,6 @@ function NotificationManagementLayout({getConfig, getConfigs, formik}: any) {
const testNotificationModal = useDisclosure();
const [availableTemplates, setAvailableTemplates] = useState<MessageTemplateDto[]>([]);
const [selectedTemplate, setSelectedTemplate] = useState<MessageTemplateDto | null>(null);
const [templateContent, setTemplateContent] = useState<string>("");
const [defaultPlaceholders, setDefaultPlaceholders] = useState<string[]>([]);
useEffect(() => {
MessageTemplateEndpoint.getAll().then((response: any) => {
@@ -59,17 +42,6 @@ function NotificationManagementLayout({getConfig, getConfigs, formik}: any) {
async function openEditor(template: MessageTemplateDto) {
setSelectedTemplate(template);
let templateContent = await MessageTemplateEndpoint.read(template.key, TemplateType.MJML);
let defaultPlaceholders = await MessageTemplateEndpoint.getDefaultPlaceholders(TemplateType.MJML);
setDefaultPlaceholders(defaultPlaceholders ? defaultPlaceholders as string[] : []);
if (templateContent === undefined) {
toast.error("Can't read template content");
return;
}
setTemplateContent(templateContent);
editorModal.onOpen();
}
@@ -78,18 +50,6 @@ function NotificationManagementLayout({getConfig, getConfigs, formik}: any) {
testNotificationModal.onOpen();
}
async function saveTemplate(template: MessageTemplateDto) {
await MessageTemplateEndpoint.save(template.key, TemplateType.MJML, templateContent);
}
function generateValidationSchema(placeholders: string[]) {
const shape: { [key: string]: Yup.StringSchema } = {};
placeholders.forEach(placeholder => {
shape[placeholder] = Yup.string().required(`Placeholder ${placeholder} is required`);
});
return Yup.object().shape(shape);
}
return (
<div className="flex flex-col">
<div className="flex flex-row">
@@ -144,122 +104,19 @@ function NotificationManagementLayout({getConfig, getConfigs, formik}: any) {
</div>
</div>
<Modal isOpen={editorModal.isOpen} onOpenChange={editorModal.onOpenChange} size="5xl">
<ModalContent>
{(onClose) => (
<>
<ModalHeader
className="flex flex-col gap-1">Edit {selectedTemplate?.name} Template</ModalHeader>
<ModalBody>
<div className="flex flex-row justify-between items-end">
<table cellPadding="4rem">
<tbody>
<tr>
<td>Required placeholders:</td>
<td>
<div className="flex flex-row gap-2">
{selectedTemplate?.availablePlaceholders?.map((placeholder) =>
<Chip radius="sm"
key={placeholder}
color={templateContent.includes(`{${placeholder as string}}`) ? "success" : "danger"}
>{placeholder}</Chip>
)}
</div>
</td>
</tr>
<tr>
<td>Optional placeholders:</td>
<td>
<div className="flex flex-row gap-2">
{defaultPlaceholders.map((placeholder) =>
<Chip radius="sm"
key={placeholder}
color={templateContent.includes(`{${placeholder as string}}`) ? "success" : "default"}
>{placeholder}</Chip>
)}
</div>
</td>
</tr>
</tbody>
</table>
<small className="text-right">Powered by <Link href="https://documentation.mjml.io/"
target="_blank">mjml.io</Link>
</small>
</div>
<Textarea
size="lg"
autoFocus
disableAutosize
value={templateContent}
onChange={(e) => {
setTemplateContent(e.target.value)
}}
classNames={{
input: "resize-y min-h-[500px]"
}}
/>
</ModalBody>
<ModalFooter>
<Button color="danger" variant="light" onPress={onClose}>
Close
</Button>
<Button color="primary" onPress={async () => {
if (selectedTemplate) {
await saveTemplate(selectedTemplate,);
toast.success("Template saved")
onClose();
}
}}>
Save
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
<EditTemplateModal
isOpen={editorModal.isOpen}
onOpenChange={editorModal.onOpenChange}
selectedTemplate={selectedTemplate}
/>
<Modal isOpen={testNotificationModal.isOpen} onOpenChange={testNotificationModal.onOpenChange} size="3xl">
<ModalContent>
{(onClose) => (
<>
<Formik
initialValues={{}}
onSubmit={async (values) => {
await NotificationEndpoint.sendTestNotification(selectedTemplate?.key, values);
toast.success("Test notification to you has been sent");
onClose();
}}
validationSchema={generateValidationSchema(selectedTemplate?.availablePlaceholders as string[])}
>
<Form>
<ModalHeader className="flex flex-col gap-1">
Send {selectedTemplate?.name} Test Message
</ModalHeader>
<ModalBody>
<p className="text-ls font-semibold mb-4">Fill the placeholders of the
template</p>
{selectedTemplate?.availablePlaceholders?.map((placeholder) =>
<Input key={placeholder} label={placeholder} name={placeholder}/>
)}
</ModalBody>
<ModalFooter>
<Button color="danger" variant="light" onPress={onClose}>
Close
</Button>
<Button color="primary" type="submit">
Send
</Button>
</ModalFooter>
</Form>
</Formik>
</>
)}
</ModalContent>
</Modal>
<SendTestNotificationModal
isOpen={testNotificationModal.isOpen}
onOpenChange={testNotificationModal.onOpenChange}
selectedTemplate={selectedTemplate}
/>
</div>
);
}
const validationSchema = Yup.object({});
export const NotificationManagement = withConfigPage(NotificationManagementLayout, "Notifications", "notifications", validationSchema);
export const NotificationManagement = withConfigPage(NotificationManagementLayout, "Notifications", "notifications");
@@ -0,0 +1,118 @@
import React, {useEffect, useState} from "react";
import {
Button,
Chip,
Link,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Textarea
} from "@nextui-org/react";
import {toast} from "sonner";
import {MessageTemplateEndpoint} from "Frontend/generated/endpoints";
import MessageTemplateDto from "Frontend/generated/de/grimsi/gameyfin/notifications/templates/MessageTemplateDto";
import TemplateType from "Frontend/generated/de/grimsi/gameyfin/notifications/templates/TemplateType";
interface EditTemplateModalProps {
isOpen: boolean;
onOpenChange: () => void;
selectedTemplate: MessageTemplateDto | null;
}
export default function EditTemplateModal({isOpen, onOpenChange, selectedTemplate}: EditTemplateModalProps) {
const [templateContent, setTemplateContent] = useState<string>("");
const [defaultPlaceholders, setDefaultPlaceholders] = useState<string[]>([]);
useEffect(() => {
if (!isOpen) return;
MessageTemplateEndpoint.read(selectedTemplate?.key as string, TemplateType.MJML).then((response: any) => {
setTemplateContent(response as string);
});
MessageTemplateEndpoint.getDefaultPlaceholders(TemplateType.MJML).then((response: any) => {
setDefaultPlaceholders(response as string[]);
});
}, [isOpen]);
async function saveTemplate(template: MessageTemplateDto) {
await MessageTemplateEndpoint.save(template.key, TemplateType.MJML, templateContent);
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="5xl">
<ModalContent>
{(onClose) => (
<>
<ModalHeader
className="flex flex-col gap-1">Edit {selectedTemplate?.name} Template</ModalHeader>
<ModalBody>
<div className="flex flex-row justify-between items-end">
<table cellPadding="4rem">
<tbody>
<tr>
<td>Required placeholders:</td>
<td>
<div className="flex flex-row gap-2">
{selectedTemplate?.availablePlaceholders?.map((placeholder) =>
<Chip radius="sm"
key={placeholder}
color={templateContent.includes(`{${placeholder as string}}`) ? "success" : "danger"}
>{placeholder}</Chip>
)}
</div>
</td>
</tr>
<tr>
<td>Optional placeholders:</td>
<td>
<div className="flex flex-row gap-2">
{defaultPlaceholders.map((placeholder) =>
<Chip radius="sm"
key={placeholder}
color={templateContent.includes(`{${placeholder as string}}`) ? "success" : "default"}
>{placeholder}</Chip>
)}
</div>
</td>
</tr>
</tbody>
</table>
<small className="text-right">Powered by <Link href="https://documentation.mjml.io/"
target="_blank">mjml.io</Link></small>
</div>
<Textarea
size="lg"
autoFocus
disableAutosize
value={templateContent}
onChange={(e) => {
setTemplateContent(e.target.value)
}}
classNames={{
input: "resize-y min-h-[500px]"
}}
/>
</ModalBody>
<ModalFooter>
<Button color="danger" variant="light" onPress={onClose}>
Close
</Button>
<Button color="primary" onPress={async () => {
if (selectedTemplate) {
await saveTemplate(selectedTemplate);
toast.success("Template saved");
onClose();
}
}}>
Save
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,69 @@
import React from "react";
import {Form, Formik} from "formik";
import {Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@nextui-org/react";
import {toast} from "sonner";
import Input from "Frontend/components/general/Input";
import {NotificationEndpoint} from "Frontend/generated/endpoints";
import * as Yup from "yup";
import MessageTemplateDto from "Frontend/generated/de/grimsi/gameyfin/notifications/templates/MessageTemplateDto";
interface SendTestNotificationModalProps {
isOpen: boolean;
onOpenChange: () => void;
selectedTemplate: MessageTemplateDto | null;
}
export default function SendTestNotificationModal({
isOpen,
onOpenChange,
selectedTemplate
}: SendTestNotificationModalProps) {
function generateValidationSchema(placeholders: string[]) {
const shape: { [key: string]: Yup.StringSchema } = {};
placeholders.forEach(placeholder => {
shape[placeholder] = Yup.string().required(`Placeholder ${placeholder} is required`);
});
return Yup.object().shape(shape);
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="3xl">
<ModalContent>
{(onClose) => (
<>
<Formik
initialValues={{}}
onSubmit={async (values) => {
await NotificationEndpoint.sendTestNotification(selectedTemplate?.key, values);
toast.success("Test notification to you has been sent");
onClose();
}}
validationSchema={generateValidationSchema(selectedTemplate?.availablePlaceholders as string[])}
>
<Form>
<ModalHeader className="flex flex-col gap-1">
Send {selectedTemplate?.name} Test Message
</ModalHeader>
<ModalBody>
<p className="text-ls font-semibold mb-4">Fill the placeholders of the template</p>
{selectedTemplate?.availablePlaceholders?.map((placeholder) =>
<Input key={placeholder} label={placeholder} name={placeholder}/>
)}
</ModalBody>
<ModalFooter>
<Button color="danger" variant="light" onPress={onClose}>
Close
</Button>
<Button color="primary" type="submit">
Send
</Button>
</ModalFooter>
</Form>
</Formik>
</>
)}
</ModalContent>
</Modal>
);
}
@@ -1,8 +1,8 @@
package de.grimsi.gameyfin.notifications.templates
class MjmlTemplate() {
abstract class MjmlTemplate {
companion object {
val placeholders: Map<String, String> = mapOf<String, String>(
val placeholders: Map<String, String> = mapOf(
"logo" to "data:image/png;base64,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",
"gradient" to "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAgAAAAABCAYAAACouxZ2AAAAo0lEQVRIS+VUwRGAIAyDWdzA/QdwKzwQKKUpKCennr6AmrYkpHZZN2eKz5Ybw0IhwuPx5+KQlhKr4kWa69iQ22JcuhLsPd/X4bup3KQA1cz5lULyGGAZ/7RpYWfrQvl1ftvctrWZo4vv+uiX5QfFhri9yTNDfhO15+jS86vnt6s7fM/nPINz/8Mzz8yyd3hGfVMx8F3PgHlU+UP30+gsI02Rn3dirSVLy0JP4wAAAABJRU5ErkJggg=="
)
@@ -1,6 +1,6 @@
<mjml>
<mj-head>
<mj-title>[Gameyfin] Password reset request</mj-title>
<mj-title>You have been invited to Gameyfin</mj-title>
<mj-attributes>
<mj-all font-family="Arial, sans-serif"/>
<mj-text font-size="16px"/>
@@ -12,7 +12,7 @@
<mj-image width="128px" src="{logo}"/>
<mj-image height="2px" padding-bottom="20px" src="{gradient}"/>
<mj-text font-size="20px" font-family="helvetica">Hello there,
<mj-text font-size="20px" font-family="helvetica">Congratulations,
<br/>
<br/>
</mj-text>
@@ -1,6 +1,6 @@
<mjml>
<mj-head>
<mj-title>[Gameyfin] Password reset request</mj-title>
<mj-title>[Gameyfin] Welcome</mj-title>
<mj-attributes>
<mj-all font-family="Arial, sans-serif"/>
<mj-text font-size="16px"/>
@@ -12,7 +12,7 @@
<mj-image width="128px" src="{logo}"/>
<mj-image height="2px" padding-bottom="20px" src="{gradient}"/>
<mj-text font-size="20px" font-family="helvetica">Hey {username},
<mj-text font-size="20px" font-family="helvetica">Hello there {username},
<br/>
<br/>
</mj-text>